preface

Hello, everyone. I’m a sea monster.

I do not know whether there is duplication of test code and test data when we write front-end single test? Then they have to write some functions and classes to encapsulate their. Over time, however, it becomes clear that excessive encapsulation makes your test cases harder and harder to read.

So what is a good wrapper when writing test code? Kent: AHA Testing


Positive start

“AHA coding principles” refer to “Avoid Hasty Abstraction”. I have a different feeling about how this applies to writing maintainable tests. Because I’ve seen so many people write tests that are on two extremes of this axis: either ANA (Absolutely No Abstraction) with No Abstraction at all, or DRY (Don’t Repeat Yourself) with No repetition at all. I think this can be interpreted as overly abstract. (ANA is my current name)

The key to writing high maintenance test code is to find the perfect balance on this axis.

ANA Testing

One of the best “no abstraction at all” examples I’ve seen is writing tests to Express’s Route Handler. To help you understand that writing tests in ANA is bad, here is a classic example of how to maintain the code base and test cases. You may now think that these test cases also guarantee code quality, which is fine. But is such a use case really ok? Let’s take a look at the test code to identify the differences in the Route Handler below.

Don’t take too long

import * as blogPostController from '.. /blog-post'

// load the application-wide mock for the database.
// I guess that means this is AANA (Almost Absolutely No Abstraction)
// but I didn't want to write out a whole db mock for this blog post 😅
jest.mock('.. /.. /lib/db')

test('lists blog posts for the logged in user'.async() = > {const req = {
    locale: {
      source: 'default'.language: 'en'.region: 'GB',},user: {
      guid: '0336397b-e29d-4b63-b94d-7e68a6fa3747'.isActive: false.picture: 'http://placehold.it/32x32'.age: 30.name: {
        first: 'Francine'.last: 'Oconnor',},company: 'ACME'.email: '[email protected]'.latitude: 51.507351.longitude: -0.127758.favoriteFruit: 'banana',},body: {},
    cookies: {},
    query: {},
    params: {
      bucket: 'photography',},header(name) {
      return {
        Authorization: 'Bearer TEST_TOKEN',
      }[name]
    },
  }
  const res = {
    clearCookie: jest.fn(),
    cookie: jest.fn(),
    end: jest.fn(),
    locals: {
      content: {},},json: jest.fn(),
    send: jest.fn(),
    sendStatus: jest.fn(),
    set: jest.fn(),
  }
  const next = jest.fn()

  await blogPostController.loadBlogPosts(req, res, next)

  expect(res.json).toHaveBeenCalledTimes(1)
  expect(res.json).toHaveBeenCalledWith({
    posts: expect.arrayContaining([
      expect.objectContaining({
        title: 'Test Post 1'.subtitle: 'This is the subtitle of Test Post 1'.body: 'The is the body of Test Post 1',
      }),
    ]),
  })
})

test('returns an empty list when there are no blog posts'.async() = > {const req = {
    locale: {
      source: 'default'.language: 'en'.region: 'GB',},user: {
      guid: '0336397b-e29d-4b63-b94d-7e68a6fa3747'.isActive: false.picture: 'http://placehold.it/32x32'.age: 30.name: {
        first: 'Francine'.last: 'Oconnor',},company: 'ACME'.email: '[email protected]'.latitude: 31.230416.longitude: 121.473701.favoriteFruit: 'banana',},body: {},
    cookies: {},
    query: {},
    params: {
      bucket: 'photography',},header(name) {
      return {
        Authorization: 'Bearer TEST_TOKEN',
      }[name]
    },
  }
  const res = {
    clearCookie: jest.fn(),
    cookie: jest.fn(),
    end: jest.fn(),
    locals: {
      content: {},},json: jest.fn(),
    send: jest.fn(),
    sendStatus: jest.fn(),
    set: jest.fn(),
  }
  const next = jest.fn()

  await blogPostController.loadBlogPosts(req, res, next)

  expect(res.json).toHaveBeenCalledTimes(1)
  expect(res.json).toHaveBeenCalledWith({
    posts: [],})})Copy the code

See the difference? The difference here is that the first example can return a Post, but the second use case does not return the Post! So what makes the difference? Also, why in the first case blogPostController. LoadBlogPosts (the req, res, next) call res. To return to the Post in json, whereas the second use case does not return?

If you can’t think of the answers to these questions. That’s okay. I’ll talk about it later. If you can figure it out, it means you are suitable for playing the game of “Let’s find 猹”. If all tests are written like this, the written use cases will be difficult for others to read and maintain.

If you had 20 or more of these test cases in one file, would it be hard to watch? You may say, “No exaggeration. Don’t laugh. I’ve seen a lot of this. Let me give you an example:

  • A new guy just joined the new team
  • And then asked to add a test case
  • Copy the previous test code, modify it, and submit it as if there is no problem
  • Review: The test passed, the code should be no problem Approve
  • PR merger

So, to better deal with this situation, you might want to consider the following two questions:

Can your test cases quickly make it clear that they differ and where those differences come from?

Never write “completely non-abstract” test code.

DRY Testing

I can’t give a good example of DRY testing code right now. Just know this: Tests can become very difficult to maintain when you want to make everything DRY, as in:

  • A new guy just joined the new team
  • He was asked to add a test case
  • Copy the previous test code and add a line to the test utility functionifStatement to pass the test
  • Review: The test passed, the code should be no problem Approve
  • PR merger

Another situation I see a lot in DRY testing is the abuse of describe and IT nesting and beforeEach. The more you try to share variables with nesting, the harder it is to understand the logic of the test code. For this, check out my [Test Isolation with React])(kentcdodds.com/blog/test-i…) This article.


Here’s what it says. Here the author does not give a good example, I will add a little here. Here’s an example:

import { getUserById } from 'src/db/user';
import { mockUser, mockAttributes } from 'tests/utils/constants';
import { insertUser } from 'tests/utils/db';

describe('test DB'.() = > {
  it('test getUserById'.async() = > {const user = {
      id: '123'. mockUser, ... mockAttributes, }await insertUser(user);
    
    const resultUser = await getUserById('123'); expect(resultUser).toEqual(user); })})Copy the code

We put mockUser, mockAttributes, and insertUser under tests/utils, making it easy to overabstract test case construction. Because someone looking at the use case for the first time will need to find the user data under tests/utils/contants and then the insertUser implementation under tests/utils/db, Then you can piece together a complete user input.

In the final toEqual, you have to go back and look at what the user is, and this causes a high mental burden on the person reading the use case. He has to have a memory of the user in his brain at all times, and the use case becomes very difficult to follow, especially in test files with a lot of use cases. This mental burden becomes heavier and heavier.

Therefore, it is recommended to add a setup function in front of it to generate the corresponding user content, and then add the “uniqueness” of the use case in a very obvious way (name change, etc.).

import { getUserById } from 'src/db/user';
import { getMockUser } from 'tests/utils/user';
import { insertUser } from 'tests/utils/db';

const setup = () = > {
  return getMockUser();
}

describe('test DB'.() = > {
  it('test getUserById'.async() = > {const mockUser = setup();
    
    mockUser.name = 'Jack';

    await insertUser(mockUser);

    const resultUser = await getUserById('123');

    expect(resultUser.name).toEqual('Jack'); })})Copy the code

I’m still putting getMockUser and insertUser in tests/utils, because it’s possible to use them elsewhere, so I need to do some abstraction. However, we put mockUser.name = ‘Jack’ on the second line, which should be obvious to first-time readers: the name field is the main focus of this use case.

The following toEqual(‘Jack’) also makes it clear to anyone reading the use case that you now expect ‘Jack’, not mockUser.name, because writing a variable would be very uncomfortable: “What if this variable is changed?”

In general, we should write our use cases not from a development perspective, but from a document reader’s perspective, and use case readability should take precedence over code readability.

The end.


AHA Testing

The first example above is definitely an abstract disaster (but it should also serve as a guideline for AHA programming ideas). Now let’s make the above test easier to understand. First, let’s clarify the differences between the two use cases.

import * as blogPostController from '.. /blog-post'

// load the application-wide mock for the database.
jest.mock('.. /.. /lib/db')

function setup(overrides = {}) {
  const req = {
    locale: {
      source: 'default'.language: 'en'.region: 'GB',},user: {
      guid: '0336397b-e29d-4b63-b94d-7e68a6fa3747'.isActive: false.picture: 'http://placehold.it/32x32'.age: 30.name: {
        first: 'Francine'.last: 'Oconnor',},company: 'ACME'.email: '[email protected]'.latitude: 51.507351.longitude: -0.127758.favoriteFruit: 'banana',},body: {},
    cookies: {},
    query: {},
    params: {
      bucket: 'photography',},header(name) {
      return {
        Authorization: 'Bearer TEST_TOKEN', }[name] }, ... overrides, }const res = {
    clearCookie: jest.fn(),
    cookie: jest.fn(),
    end: jest.fn(),
    locals: {
      content: {},},json: jest.fn(),
    send: jest.fn(),
    sendStatus: jest.fn(),
    set: jest.fn(),
  }
  const next = jest.fn()

  return {req, res, next}
}

test('lists blog posts for the logged in user'.async() = > {const {req, res, next} = setup()

  await blogPostController.loadBlogPosts(req, res, next)

  expect(res.json).toHaveBeenCalledTimes(1)
  expect(res.json).toHaveBeenCalledWith({
    posts: expect.arrayContaining([
      expect.objectContaining({
        title: 'Test Post 1'.subtitle: 'This is the subtitle of Test Post 1'.body: 'The is the body of Test Post 1',
      }),
    ]),
  })
})

test('returns an empty list when there are no blog posts'.async() = > {const {req, res, next} = setup()
  req.user.latitude = 31.230416
  req.user.longitude = 121.473701

  await blogPostController.loadBlogPosts(req, res, next)

  expect(res.json).toHaveBeenCalledTimes(1)
  expect(res.json).toHaveBeenCalledWith({
    posts: [],})})Copy the code

What is the difference between the first and second use cases? The user of the first use case is in London, and the user of the second is in Shanghai.

With a little more abstract code, we can clearly distinguish between the input and output before the use case, and the test code will be easier to understand and maintain.

Test React with the AHA thought

When testing React components, I usually have a function named renderFoo that acts as setup. Such as:

import * as React from 'react'
import {render, screen} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import LoginForm from '.. /login-form'

function renderLoginForm(props) {
  render(<LoginForm {. props} / >)
  const usernameInput = screen.getByLabelText(/username/i)
  const passwordInput = screen.getByLabelText(/password/i)
  const submitButton = screen.getByText(/submit/i)
  return {
    usernameInput,
    passwordInput,
    submitButton,
    changeUsername: value= > userEvent.type(usernameInput, value),
    changePassword: value= > userEvent.type(passwordInput, value),
    submitForm: () = > userEvent.click(submitButton),
  }
}

test('submit calls the submit handler'.() = > {
  const handleSubmit = jest.fn()
  const {changeUsername, changePassword, submitForm} = renderLoginForm({
    onSubmit: handleSubmit,
  })
  const username = 'chucknorris'
  const password = 'ineednopassword'
  changeUsername(username)
  changePassword(password)
  submitForm()
  expect(handleSubmit).toHaveBeenCalledTimes(1)
  expect(handleSubmit).toHaveBeenCalledWith({username, password})
})
Copy the code

If there are only two or three test cases like the one above, or the test code is very short, I would think this is premature optimization. But if your use cases are all slightly different from each other (error states, etc.), then this is a good way to abstract.

Nesting Nesting

Suggest see “https://kentcdodds.com/blog/avoid-nesting-when-youre-testing” this file.

Jest – in – case and the test. Each

If you’re just testing pure functions, you’re in luck, because they’re the easiest to test. You can simplify your test code with simple abstractions that make it more obvious what the inputs and outputs are when they are called.

Such as:

import add from '.. /add'

test('adds one and two to equal three'.() = > {
  expect(add(1.2)).toBe(3)
})

test('adds three and four to equal seven'.() = > {
  expect(add(3.4)).toBe(7)
})

test('adds one hundred and two to equal one hundred two'.() = > {
  expect(add(100.2)).toBe(102)})Copy the code

We can also use a more elegant version of jest-in-case:

import cases from 'jest-in-case'
import add from '.. /add'

cases(
  'add'.({first, second, result}) = > {
    expect(add(first, second)).toBe(result)
  },
  [
    {first: 1.second: 2.result: 3},
    {first: 3.second: 4.result: 7},
    {first: 100.second: 2.result: 102},],)Copy the code

I probably wouldn’t write it this way for such a simple use case, but it’s nice to be able to add more test cases by adding inputs and outputs directly after the array. A good example of this kind of thinking is the rTL-CSS-JS test case. Other contributors will find adding use cases in such a structure too much fun!

Of course, this can also be applied to some non-pure functions, but it might be more abstract. (Here’s an example. It’s not very good, but I think it works.)

I personally like jest in-case, but jest already has test.each built in, which should help you.

conclusion

Although our Test code could have improved its look and readability with better use-case names and more comments, a simple Setup abstraction function (also called the Test Object Factory) would have eliminated them. So, my point is that high-quality, meaningful code abstractions can significantly reduce the cost of writing and maintaining test code.


Well, that brings us to this foreign article. In general, I agree with the author, because we will inevitably have to construct a bunch of Mock objects, instances, and Mock content may vary slightly from test case to test case.

And if you make it too abstract, you put it intests/utilsTo generate objects would be too abstract and difficult to understand, placing a very high mental burden on the person reading the use case. So the best way is to write one in the current test filesetupFunction to generate the basic Mock object, and then make subtle adjustments to the application case to show as much differentiation as possible. This not only keeps the reader up to speed with the use case, but also gives a clear view of the inputs and outputs of the use case.

If you like my share, you can come to a wave of three key, like, in the look is my biggest power, than the heart ❤️