Original address: medium.com/javascript-… Translation address: github.com/xiao-T/note… The copyright of this article belongs to the original author. Translation is for study only.


Unit testing is a great discipline, reducing bugs by 40-80%. At the same time, there are several important benefits:

  • Improve application structure and maintainability.
  • Let developers focus more on the development experience before implementing it, leading to better apis and better compositing capabilities.
  • Provides quick feedback whenever a file is saved, whether it is correct or not. This can be avoidedconsole.log()And click the UI directly to verify that the changes are correct. As a newcomer to unit testing, you may need to spend an additional 15 to 30 percent of your TIME on the TDD process to understand how to test various components, but TDD experienced developers will save implementation time.
  • Provides strong security when adding features or refactoring existing features.

In some cases unit testing is relatively easy. Unit tests, for example, are more effective for pure functions: a function, which means that the same input always gets the same output, with no side effects.

However, UI components do not fall into this category, which makes TDD more difficult and requires writing tests first.

Writing test cases first is necessary for me to list some of the benefits, such as improved architecture, a better development experience, and faster feedback during application development. Practice using TDD as a developer. Many developers like to write the business before they write the tests, and if you don’t write the tests first, you lose out on many of the benefits of unit testing.

Still, it’s worth it to write tests first. TDD and unit testing make it easier to write UI components, easier to maintain, and easier to combine and reuse components.

One of my latest inventions in the testing world is the implementation of RITEway, a unit testing framework that simply encapsulates Tape and makes writing tests easier and more maintainable.

No matter what testing framework you use, the following tips can help you write better, more testable, more readable, and more composable UI components:

  • Use pure function components for UI components: same props, always same render. If your application requires state, you can use container components to manage state and side effects, and then wrap purely functional components.
  • Isolate the application’s business logic in the pure Reducer function
  • Use container components to isolate side effects

Function component

A function component, which means the same props, will render the same UI without side effects. Such as:

import React from 'react';
const Hello = ({ userName }) = > (
  <div className="greeting">Hello, {userName}!</div>
);
export default Hello;
Copy the code

Such components are usually very easy to test. You need a way to select the location component (in this case, we selected the component by the class name Greeting), and then you need to know what the component outputs. For writing test cases for purely functional components, I use render- Component in RITEway.

First, you need to install RITEway:

npm install --save-dev riteway
Copy the code

RITEway uses the React-dom /server renderToStaticMarkup() internally and then wraps the output as a Cheerio object for selection. If you’re not using RITEway, you can create your own function to render the React component as a static tag and use Cheerio to do that.

Once you have the Cheerio object, you can write the test like this:

import { describe } from 'riteway';
import render from 'riteway/render-component';
import React from 'react';
import Hello from '.. /hello';
describe('Hello component'.async assert => {
  const userName = 'Spiderman';
  const $ = render(<Hello userName={userName} />);
  assert({
    given: 'a username',
    should: 'Render a greeting to the correct username.',
    actual: $('.greeting')
      .html()
      .trim(),
    expected: `Hello, ${userName}!`
  });
});
Copy the code

But there’s nothing magical about it. What if you need to test stateful components or components that have side effects? This is the real magic of TDD for the React component, because the answer to this question is the same as the answer to another very important question: “How do I make components easier to maintain and debug?” .

The answer is: Isolate state and side effects from components. You can encapsulate state and side effects into a container component, and then pass state down as props for a purely functional component.

But isn’t the Hooks API designed to flatten the component hierarchy and avoid deeper nesting? Not exactly. It is still a good idea to separate components into three categories:

  • Display /UI components
  • Program logic/Business rules – Deals with solving user-related problems.
  • Side effects ((I/O, network, Disk, etc.)

In my experience, if you keep display /UI separate from procedural logic and side effects, it improves your development experience. This rule applies in every language or framework I’ve used, including React Hooks.

Let’s create a Counter component to demonstrate stateful components. First, we need to create the UI component. It should include something like: “Clicks: 13” to indicate how many times the button is clicked. The value of the button is “Click”.

Writing unit tests for this display component is simple. We just need to test if the button is rendered (we don’t care what the value of the button is — different languages display it differently depending on the user’s Settings). We also want to know if the correct number of clicks is displayed. We need to write two tests: one to test whether the button displays, and another to verify that the number of clicks is displayed correctly.

When working with TDD, I tend to use two different assertions to ensure that components can properly display the related props. If you write only one test, it is possible that it will correspond exactly to the hard-code in the component. To avoid this, you can write different test cases with two different values.

In this example, we create a component called

that has a property called Clicks for clicks. To use it, you simply set a CLICKS property for the component to indicate the number you want to display.

Let’s take a look at how unit tests ensure that components render. We need to create a new file: click-counter/click-counter-component.test.js:

import { describe } from 'riteway';
import render from 'riteway/render-component';
import React from 'react';
import ClickCounter from '.. /click-counter/click-counter-component';
describe('ClickCounter component'.async assert => {
  const createCounter = clickCount= >
    render(<ClickCounter clicks={ clickCount} / >); { const count = 3; const $ = createCounter(count); assert({ given: 'a click count', should: 'render the correct number of clicks.', actual: parseInt($('.clicks-count').html().trim(), 10), expected: count }); } { const count = 5; const $ = createCounter(count); assert({ given: 'a click count', should: 'render the correct number of clicks.', actual: parseInt($('.clicks-count').html().trim(), 10), expected: count }); }});Copy the code

To make it easier to write test cases, I like to create small factory functions. In this example, createCounter takes a numeric parameter and returns a rendered component:

const createCounter = clickCount= >
  render(<ClickCounter clicks={ clickCount} / >);Copy the code

With the test case in hand, it’s time to implement the ClickCounter component. I put the components and test files in the same directory and named them click-counter-component.js. First, we’ll write the component’s framework, and then you’ll see the test case reporting an error:

import React, { Fragment } from 'react';
export default() = ><Fragment>
  </Fragment>
;
Copy the code

If we save and then run the test case, you will see an error TypeError, it triggers the Node UnhandledPromiseRejectionWarning. In the end, the Node will not be used DeprecationWarning annoying warning, but throw a UnhandledPromiseRejectionError error. We encountered this TypeError because our selector returned NULL, and then we tried calling null’s trim() method. We can fix this by rendering the desired structure:

import React, { Fragment } from 'react';
export default() = ><Fragment>
    <span className="clicks-count">3</span>
  </Fragment>
;
Copy the code

Very good. Now, we should have one test that passes and one that fails:

# ClickCounter component
ok 2 Given a click count: should render the correct number of clicks.
not ok 3 Given a click count: should render the correct number of clicks.
  ---
    operator: deepEqual
    expected: 5
    actual:   3
    at: assert (/home/eric/dev/react-pure-component-starter/node_modules/riteway/source/riteway.js:15:10)
...
Copy the code

To fix it, we need to set count to prop for the component and render it with the real value:

import React, { Fragment } from 'react';
export default ({ clicks }) =>
  <Fragment>
    <span className="clicks-count">{ clicks }</span>
  </Fragment>
;
Copy the code

Now, we’ve passed all the tests:

TAP version 13
# Hello component
ok 1 Given a username: should Render a greeting to the correct username.
# ClickCounter componentok 2 Given a click count: should render the correct number of clicks. ok 3 Given a click count: should render the correct number of clicks. 1.. 3# tests 3
#pass 3
# ok
Copy the code

It’s time to test the click button. First, add the test case, which will obviously fail:

{
  const $ = createCounter(0);
  assert({
    given: 'expected props'.should: 'render the click button.'.actual: $('.click-button').length,
    expected: 1
  });
}
Copy the code

Here’s what happens when the test fails:

not ok 4 Given expected props: should render the click button
  ---
    operator: deepEqual
    expected: 1
    actual:   0
...
Copy the code

Now, let’s implement button clicking:

export default ({ clicks }) =>
  <Fragment>
    <span className="clicks-count">{ clicks }</span>
    <button className="click-button">Click</button>
  </Fragment>
;
Copy the code

The test passed:

TAP version 13
# Hello component
ok 1 Given a username: should Render a greeting to the correct username.
# ClickCounter componentok 2 Given a click count: should render the correct number of clicks. ok 3 Given a click count: should render the correct number of clicks. ok 4 Given expected props: should render the click button. 1.. 4# tests 4
#pass 4
# ok
Copy the code

Now we just need to implement the state-related logic and related events.

Unit tests for Stateful components

The method I told you is too complex for ClickCounter, but most applications are more complex than this component. State is often stored in a database or shared between multiple components. A popular practice in the React community is to start with the component’s local state and then, as needed, promote state to the parent or global component.

It turns out that if you manage state locally from the start using pure function components, it’s easier to manage later. For this and other reasons (such as confusion in the React lifecycle, consistency of state, and avoiding common bugs), I prefer to use reducer management component state. For the local component state, you can introduce it using the React Hook API useReducer.

If you need to use a state management framework such as Redux, you’ve already done half of your work, such as unit testing, etc.

If you need to lift the state to be managed by a state manager like Redux, you’re already half way there before you even start: Unit tests and all.

If you use useReducer to maintain state locally from the start, it will be easier to transition to Redux and reuse previous unit tests.

First, I created the corresponding test file for state Reducer. I’m going to put it in the same directory, just with a different file name. I’ll call it click-counter/click-counter-reducer.test.js:

import { describe } from 'riteway';
import { reducer, click } from '.. /click-counter/click-counter-reducer';
describe('click counter reducer'.async assert => {
  assert({
    given: 'no arguments'.should: 'return the valid initial state'.actual: reducer(),
    expected: 0
  });
});
Copy the code

I like to start with assertions to ensure that the reducer can produce a normal initial value. If, later, you decide to use Redux, it will call each Reducer without state to initialize state for the store. This also makes it easier to provide valid initial state or component state for unit tests.

Of course, I also need to create a reducer file: click-counter/click-counter-reducer.

const click = (a)= > {};
const reducer = (a)= > {};
export { reducer, click };
Copy the code

At first, I simply exported the empty Reducer and Action Creator. For more information on Action Creators and selectors, check out “10 Tips for Improving the Redux System.” Today, we’re not going to dive into the React/Redux design pattern, but understanding these issues, even if you don’t use Redux, helps to understand what we’re doing today.

First, we will see the test fail:

# click counter reducer
not ok 5 Given no arguments: should return the valid initial state
  ---
    operator: deepEqual
    expected: 0
    actual:   undefined
Copy the code

Now, LET me fix the problem in the test case:

const reducer = (a)= > 0;
Copy the code

Now that the initials-related test cases are ready to pass, it’s time to add more meaningful test cases:

assert({
    given: 'initial state and a click action'.should: 'add a click to the count'.actual: reducer(undefined, click()),
    expected: 1
  });
  assert({
    given: 'a click count and a click action'.should: 'add a click to the count'.actual: reducer(3, click()),
    expected: 4
  });
Copy the code

We saw that both test cases failed (two that should have returned 1 and 4, respectively, returned 0). Let’s fix them.

Note that I used click() as a public API from the Reducer. In my opinion, you should treat reducer as part of your application rather than interacting directly with it. Instead, the public API for the Reducer should be Action Creators and selectors.

I did not write the test cases separately for Action Creators and selectors. I always test them in conjunction with reducer. Testing the Reducer is testing action Creators and selectors, and vice versa. If you follow these rules, you will need fewer test cases, but if you test them individually, you can still achieve the same testing and coverage.

const click = (a)= > ({
  type: 'click-counter/click'});const reducer = (state = 0, { type } = {}) = > {
  switch (type) {
    case click().type: return state + 1;
    default: returnstate; }};export { reducer, click };
Copy the code

All unit tests should now pass:

TAP version 13
# Hello component
ok 1 Given a username: should Render a greeting to the correct username.
# ClickCounter component
ok 2 Given a click count: should render the correct number of clicks.
ok 3 Given a click count: should render the correct number of clicks.
ok 4 Given expected props: should render the click button.
# click counter reducerok 5 Given no arguments: should return the valid initial state ok 6 Given initial state and a click action: should add a click to the count ok 7 Given a click count and a click action: should add a click to the count 1.. 7# tests 7
#pass 7
# ok
Copy the code

Final step: Bind the behavior event to the component. We can handle this using container components. I created a file named index.js in my local directory. It reads as follows:

import React, { useReducer } from 'react';
import Counter from './click-counter-component';
import { reducer, click } from './click-counter-reducer';
export default() = > {const [clicks, dispatch] = useReducer(reducer, reducer());
  return <Counter
    clicks={ clicks }
    onClick={()= > dispatch(click())}
  />;
};
Copy the code

That’s it. This component is just used to manage state and then pass state down as a prop for a purely functional component. Open the app in your browser and click the button to see if it works.

So far, we haven’t had the problem of viewing components and handling styles in the browser. For clarity, I will add a label and some Spaces to the ClickCounter component. The onClick event is also bound. The code is as follows:

import React, { Fragment } from 'react';
export default ({ clicks, onClick }) =>
  <Fragment>
    Clicks: <span className="clicks-count">{ clicks }</span>&nbsp;
    <button className="click-button" onClick={onClick}>Click</button>
  </Fragment>
;
Copy the code

All test cases can still pass.

What about tests for container components? I don’t write unit tests for container components. Instead, I use functional tests, which run in a browser or emulator where the user can interact with the real UI and run end-to-end tests. Your application requires two types of tests (unit and functional). I find it a bit redundant to write unit tests for container components (those that are intended to connect to the Reducer), and it is difficult to implement the right unit tests. In general, you need to simulate the dependencies of various container components in order to work properly.

During this time, we only tested components that were important and didn’t depend on side effects: we tested if we could render correctly, and if state was managed correctly. You still need to run the component in the browser and see if the button works correctly.

This is the same whether you implement functional/E2E testing for the React component or any other framework. See “Behavior Driven Development (BDD) and Functional Testing” for details.

The next step

Sign up for TDD Day: Get 5 hours of high-quality video content and interactive lessons on TDD. This is a great crash course to improve your team’s TDD skills. Regardless of your current TDD experience, you will learn more.