preface

Hello, everyone. I’m a sea monster.

I believe that when many students write a single test, the biggest problem is not how to write test code, but: “what should be tested?” “How deep to test”, “What not to test”.

While Testing the React component recently, Kent (a contributor to the React Testing Library) wrote an article called Testing Implementation Details that asked why not test the code Implementation Details. This question is very well written, and I will share this article with you today.

The translation will try to use more authentic language, which also means adding a layer of Buf to the original text. If you want to see the original text, click here.


start

I used to avoid using certain apis (shallow rendering, instance(), state(), and find(‘ComponentName’) when using enzymes, and when reviewing PR for others, I’ll also tell them to try not to use these apis. The main reason for this is that these apis measure a lot of the Implementation Details of the code. Then, a lot of people ask, why not test the implementation Details of the code? It’s simple: Testing is hard enough, and we shouldn’t make it more complicated with so many rules.

Why is testing “implementation details” bad?

Why is testing implementation details bad? There are two main reasons:

  • False Negative: Code runs successfully during refactoring, but test case crashes
  • False Positive: when the application code actually breaks, but the test case passes

Note: Testing here refers to “determining whether the software works”. If the test passes, Positive, the code works. If the test fails, Negative and the code is not available. False means “incorrect”, i.e. incorrect test results.

If you don’t understand the above, it doesn’t matter. Let’s talk about it one by one. Let’s look at this Accordion component first:

// Accordion.js
import * as React from 'react'
import AccordionContents from './accordion-contents'

class Accordion extends React.Component {
  state = {openIndex: 0}
  setOpenIndex = openIndex= > this.setState({openIndex})
  render() {
    const {openIndex} = this.state
    return (
      <div>
        {this.props.items.map((item, index) => (
          <>
            <button onClick={()= > this.setOpenIndex(index)}>
              {item.title}
            </button>
            {index === openIndex ? (
              <AccordionContents>{item.contents}</AccordionContents>
            ) : null}
          </>
        ))}
      </div>
    )
  }
}

export default Accordion
Copy the code

I’m sure some of you will say: why are we still using the outdated Class Component notation instead of the Function Component notation? Don’t worry, read on and find some interesting things (I’m sure those who have used the pathos can guess what they are).

Here is a test code to test the implementation details in the Accordion component above:

// __tests__/accordion.enzyme.js
import * as React from 'react'
// Why not use shadow Render, please see https://kcd.im/shallow
import Enzyme, {mount} from 'enzyme'
import EnzymeAdapter from 'enzyme-adapter-react-16'
import Accordion from '.. /accordion'

// Set the Adpater for the Enzymes
Enzyme.configure({adapter: new EnzymeAdapter()})

test('setOpenIndex sets the open index state properly'.() = > {
  const wrapper = mount(<Accordion items={} [] />)
  expect(wrapper.state('openIndex')).toBe(0)
  wrapper.instance().setOpenIndex(1)
  expect(wrapper.state('openIndex')).toBe(1)
})

test('Accordion renders AccordionContents with the item contents'.() = > {
  const hats = {title: 'Favorite Hats'.contents: 'Fedoras are classy'}
  const footware = {
    title: 'Favorite Footware'.contents: 'Flipflops are the best',}const wrapper = mount(<Accordion items={[hats, footware]} / >)
  expect(wrapper.find('AccordionContents').props().children).toBe(hats.contents)
})
Copy the code

There are many students who have used Enzyme to write code similar to the above. Okay, now let’s do something…

“False errors” in refactoring

I know most people don’t like writing tests, especially UI tests. There are thousands of reasons, but the one I hear the most about is that most people spend an extraordinary amount of time working on these tests.

Every time I change something, the test breaks! – voice

If the test code is not written well, it can seriously drag down your development efficiency. Let’s take a look at the problems this type of test code can cause.

For example, if we want to reconstitute this component, we can expand multiple items, and this change can only change the implementation of the code, not the behavior of the existing component. The refactored code looks like this:

class Accordion extends React.Component {
  state = {openIndexes: [0]}
  setOpenIndex = openIndex= > this.setState({openIndexes: [openIndex]})
  render() {
    const {openIndexes} = this.state
    return (
      <div>
        {this.props.items.map((item, index) => (
          <>
            <button onClick={()= > this.setOpenIndex(index)}>
              {item.title}
            </button>
            {openIndexes.includes(index) ? (
              <AccordionContents>{item.contents}</AccordionContents>
            ) : null}
          </>
        ))}
      </div>
    )
  }
}
Copy the code

Change openIndex to openIndexes above to allow accordions to display multiple AccordionContents at once. If the test case setOpenIndex sets the open index state properly (💥kaboom💥), the test case returns an error:

expect(received).toBe(expected)

Expected value to be (using ===):
  0
Received:
  undefined
Copy the code

Since we changed openIndex to openIndexes, the value of openIndex becomes undefined in the test. However, does this error really indicate that there is a problem with our component? No! In the real world, the component works just fine.

This situation is known as “false error”. It means that the test case failed, but it crashed because of a problem with the test code, not because the business code/application code caused the crash.

ToEqual ([0]) and toEqual(1) to toEqual([1]) :

test('setOpenIndex sets the open index state properly'.() = > {
  const wrapper = mount(<Accordion items={} [] />)
  expect(wrapper.state('openIndexes')).toEqual([0])
  wrapper.instance().setOpenIndex(1)
  expect(wrapper.state('openIndexes')).toEqual([1])})Copy the code

To summarize: When refactoring, these test cases that test “implementation details” are likely to produce “false errors,” resulting in a lot of test code that is difficult to maintain and annoying.

“False correctness”

Ok, now let’s look at another case, false true. Let’s say your colleague sees this code right now

<button onClick={()= > this.setOpenIndex(index)}>{item.title}</button>
Copy the code

This. SetOpenIndex (index) => this. SetOpenIndex (index) => this.setopenIndex (index) => this.setopenIndex (index) => this.

<button onClick={this.setOpenIndex}>{item.title}</button>
Copy the code

A run test, alas, perfect through ~ ✅✅, did not go to the browser to run the page to submit the code, such as others a pull code, the page can not be used. (If you’re not sure why you can’t use onClick={this.setOpenIndex}, search the bind operation for Class Component onClick.)

So what’s the problem here? Don’t we already have a test case to prove that “whenever setOpenIndex is called, the state changes”? Right! There is. However, this does not prove that setOpenIndex is actually bound to onClick! So we’ll write another test case to test that setOpenIndex is really bound to onClick.

Do you see the problem? Because we only tested a very small implementation detail in the business, we had to fill in many other test cases to test other irrelevant implementation details, and we would never be able to fill in all the implementation details of the test code.

This is the “false truth” mentioned above. It means that when we run the test, the use cases pass, but in fact there is a problem in the business code/application code, and the use cases should throw errors! So how do we cover these cases? Ok, so we’ll just have to write another test to make sure the status update works when the button is clicked. Then, we had to add a 100 percent coverage metric to make sure there were no problems. Also write plugins for ESLint to prevent others from using the API.

Instead of writing tests to patch false true and false false, just do all the tests. Wouldn’t it be nice to have a tool that could solve this problem? Yes, there is!

Implementation details are no longer tested

Of course, you could use Enzyme to rewrite the test cases and restrict other people from using the API, but I would probably choose React Testing Library because the API itself restricts developers. If someone wants to use it for “implementation details” tests, It’s going to be very difficult.

Let’s take a look at RTL testing:

// __tests__/accordion.rtl.js
import '@testing-library/jest-dom/extend-expect'
import * as React from 'react'
import {render, screen} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Accordion from '.. /accordion'

test('can open accordion items to see the contents'.() = > {
  const hats = {title: 'Favorite Hats'.contents: 'Fedoras are classy'}
  const footware = {
    title: 'Favorite Footware'.contents: 'Flipflops are the best',
  }
  render(<Accordion items={[hats, footware]} / >)

  expect(screen.getByText(hats.contents)).toBeInTheDocument()
  expect(screen.queryByText(footware.contents)).not.toBeInTheDocument()

  userEvent.click(screen.getByText(footware.title))

  expect(screen.getByText(footware.contents)).toBeInTheDocument()
  expect(screen.queryByText(hats.contents)).not.toBeInTheDocument()
})
Copy the code

A single test case validates all component behavior. Whether or not openIndex, openIndexes, or tacosAreTasty is called, the use case will pass. This will solve these “false errors”. An error is also reported if the onClick click event is not properly bound. This also solves the problem of false correctness. The advantage is that we no longer need to remember the complicated implementation logic, but simply focus on the behavior of the component in the ideal case, so that we can figure out the real scenario that the user is using.

What exactly are Implementation Details?

To put it simply:

Implementaion Details are things that people who use your code won’t use, see, or know about.

So who is the user of our code? The first is a real user who interacts with the page. The second is the developer who uses the code. For the React Component, users can be divided into End User and Developer. We only need to focus on these two users.

The question then becomes: What parts of our code do these two types of users see, use, and know about? For End users, they only interact with the render function. Developer, on the other hand, interacts with Props passed by the component. So it’s enough for our test case to just interact with the Props passed in and the render function that outputs the content.

This is exactly what the React Testing Library does: pass the Mock Props to the Accordion component and use the RTL API to validate the render function output and test the click event.

Now back to the Enzyme library, which developers use to access state and openIndex for tests. This makes no sense to either of the above users because they don’t need to know what function was called, which index was changed, or whether the index was stored as an array or a string. However, Enzyme test cases are basically measuring things that other people don’t care about.

This is also why the Enzyme test cases are so prone to “false errors”, because when we use it to write test cases that End User and Developer don’t care about, we are actually creating a third User perspective: Tests themselves! . The Tests user happens to be the one that no one will care about. Therefore, automated testing should only serve the users of the production environment, not this third party who will not care.

The more similar your tests are to how your software is used, the more confidence it can give you — Kent

The React Hooks?

Another reason not to use Enzyme is that Enzyme has a lot of problems using React Hooks. It turns out that when testing the code implementation details, any changes in the implementation details can have a significant impact on the test. This is a big problem, because if you move from a Class Component to a Function Component, it’s hard to guarantee that your test case won’t break something in it. The React Testing Library does a good job of avoiding these problems.

Implementation detail free and refactor friendly.

conclusion

How can we avoid testing “implementation details”? Use the correct tools like React Testing Library

If you still don’t know what to test, follow this process:

  • If it crashes, which untested code has the worst impact? (Check process)
  • Try to narrow the test case down to one unit or several units of code (e.g., pressing the checkout button will send a /checkout request)
  • Think about who are the real users of this code? (For example: Developer renders the checkout form and End User uses it to click the button)
  • Write a list of actions to the user and manually test to make sure it works (render the form in the shopping cart with fake data, click the checkout button, make sure the fake /checkout request executes and gets a successful response, make sure the success message is displayed)
  • Turn this manual checklist into automated tests

Well, this foreign language will give you here, I hope to help you in the single test. In general, more attention should be paid to testing componentsPropsAs well asrenderOut of the content. Testing “implementation details” is a bit like lying. Each time we lie, we have to tell more lies to get around the first lie. When we Test a detail, we only get a glimpse of it, which creates a non-existent user: Test.

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 ❤️