preface

On a daily basis, we tend to have little time for automated testing, especially in the context of agile development. However, automated testing can help us to improve the robustness of our code and functionality, and reduce the potential for bugs.

Especially in the complex system, the role of automated testing can not be ignored. This article is my own learning record, using the test framework JEST and the front-end framework React to comb through the automated testing.

Daily development usually involves the development of business code as well as the development of functions and component libraries. For these two aspects of automated testing, in the mode and process have their own requirements and emphasis. This has derived two test methods, unit test and integration test, as well as TDD and BDD test development process.

Unit testing

Unit testing, as the name implies, can be understood as testing a unit of the system, and this unit can be a function, a component, for this form of testing, we only care about the function of the individual unit. Test cases object functions within the current unit.

Integration testing

Integrate multiple units together and test, focusing on whether the overall function of the system is normal after all units are connected in series. The test case at this point takes an independent system composed of multiple units as an object.

The above are two test methods, but sometimes it is difficult to balance the degree of refinement of the test with the complex operation process of the system, so it is necessary to make a trade-off, and adopt different test + development process for different development subjects and business scenarios.

TDD: Test-Driven Development

In this mode, test cases are written first, and functions are improved under the guidance of test cases. When test cases are written and passed, corresponding functions are completed. TDD’s pattern is suitable for development bodies that require system code quality and test coverage, such as functions and component libraries. But often as the code changes, the test cases need to be adjusted accordingly.

BDD: Behavior Driven Development

Test cases simulate the user’s behavior and are usually written with the user’s behavior as a guide after the business code is developed. When the test case runs, the overall flow of the system is considered smooth. BDD mode is suitable for normal business code development, because business requirements may change frequently, but the operation process may not change. When the business code changes, the original test cases can be used to continue to run the code, saving the development time.

I think in normal projects, it’s common to use a combination of TDD and BDD for testing, with TDD taking care of method classes, individual component testing. BDD is responsible for testing the overall business module.

Start with a Demo to understand automated testing

Let’s take a look at front end automation testing with a demo. We’ll start by setting up the environment and looking at the tools and configuration items associated with JEST and React.

Setting up the Test Environment

If the project is created using create-React-app, a JEST test environment will be integrated internally. After NPM run eject exposes the configuration items, the jest configuration items can be seen in the Jest field of package.json. You can also copy and paste the configuration items into the new jest.config.js.

Create-react-app generates jest configuration items

** matches any folder, * matches any file name

Module.exports = {// Tests which directories to export"roots": [
      "<rootDir>/src"], // When generating test coverage reports, count files in which directories end with which suffix. .d.ts is a type declaration file in ts, so it does not participate in statistics"collectCoverageFrom": [
      "src/**/*.{js,jsx,ts,tsx}".! "" src/**/*.d.ts"], // Use react-app-polyfill/jsdom to resolve some js compatibility issues"setupFiles": [
      "react-app-polyfill/jsdom"], // Once the test environment is setup, the files in it are executed. In this case, what setuptests.js does is introduce matchers with jsDOM extensions."setupFilesAfterEnv": [
      "<rootDir>/src/setupTests.js"], // When the test runs, some test files will be executed. This configuration item matches the files to be executed with the re."testMatch": [
      "<rootDir>/src/**/__tests__/**/*.{js,jsx,ts,tsx}"."<rootDir>/src/**/*.{spec,test}.{js,jsx,ts,tsx}"], // Since the test environment is executed in Node, there is no DOM or Window API, so the value of this configuration item emulates some of the WINDOW or DOM APIS"testEnvironment": "jest-environment-jsdom-fourteen"// When the imported file matches the key's re of the transform configuration item, parse and transform the file with value"transform": {
      "^.+\\.(js|jsx|ts|tsx)$": "<rootDir>/node_modules/babel-jest"."^.+\\.css$": "<rootDir>/config/jest/cssTransform.js"."^ (? ! .*\\.(js|jsx|ts|tsx|css|json)$)": "<rootDir>/config/jest/fileTransform.js"}, // If the imported file matches the key's re, it will be ignored"transformIgnorePatterns": [
      "[/\\\\]node_modules[/\\\\].+\\.(js|jsx|ts|tsx)$"."^.+\\.module\\.(css|sass|scss)$"], // When importing a module that is not found in node_modules, you need to find it in a custom path, which can be written here"modulePaths"[], // For csS-Module, use identity-obj-proxy to convert styles from.selector: {width: 20px} to {.selector:'.selector'} forms like this, // the purpose of the test is to ignore the style, so simplify processing"moduleNameMapper": {
      "^.+\\.module\\.(css|sass|scss)$": "identity-obj-proxy"}, // When importing a file in the test file, if there is no suffix in the file name, we will find the file according to the suffix below"moduleFileExtensions": [
      "js"."ts"."tsx"."json"."jsx"."node"
    ],

    // npm run testCommand, jest will listen for file changes. These are listening plug-ins, or you can use jest's native listening mode directly"watchPlugins": [
      "jest-watch-typeahead/filename"."jest-watch-typeahead/testname"]}Copy the code

If the project is completely self-configured, you can install jest in the project, then NPX jest –init, initialize a jest. Config. js file to configure the test environment, of course, you can refer to create-react-app generated jest configuration items.

This is a basic jEST test environment, but it’s not enough to test the React project.

Test the React component using the Enzyme

React components are an important concept, so it is important to test them flexibly and easily.

Testing components, involving components props, state, and internal methods. For this scenario, you can use enzyme to test the component.

Enzyme is a tool for React test launched by Airbnb. Components can be rendered in the test environment by the method provided by Enzyme, and then obtain or verify the status and behavior of components through other apis.

Take a simple component as an example:

import React from 'react';

function App() {
  return (
    <div className="App" data-test='container'>
      hello world
    </div>
  );
}

export default App;

Copy the code

If you test this component, you need to install the enzyme first. The last number of the adaptor must be the same as the react version in your current project.

npm i --save-dev enzyme enzyme-adapter-react-16
Copy the code

Once installed, introduce and configure the enzyme in the test case file.

import React from 'react';
import App from './App';
import Enzyme, { shallow } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';

Enzyme.configure({ adapter: new Adapter() });

test('Verify that the App component is properly mounted', () => {
  const wrapper = shallow(<App />)
  expect(wrapper.find('[data-test="container"]').length).toBe(1)
});
Copy the code

This test code verifies that data-test=”container” exists. In order to decouple from the business code, the test case selector (find) should not use business-specific tags. Here we add an attribute to the container to be tested: data-test=’container’.

Shallow: Render the component shallow: render the component shallow: render the component shallow: render the component shallow: render the component shallow: render the component shallow: render the component shallow: render the component shallow: render the component shallow: render the component shallow: render the component to call some enzyme methods.

Of course, it is not possible to write a test file and introduce the enzyme once. You can import the enzyme import and configuration work into the file configured by the setupFilesAfterEnv configuration item in jest. Config. js when the test environment is ready.

import Enzyme from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';

Enzyme.configure({ adapter: new Adapter() });
Copy the code

Shallow and mount

In the test case, we also need to render the component, but write it like this:

const wrapper = shallow(<App />)
Copy the code

Instead of shallow rendering, the nested component will be replaced with a tag if it has been shallow. Therefore, only the first layer of the component is rendered. The purpose of this is to focus only on the current component while unit testing the component, and to greatly improve performance.

This is matched by the mount method, which renders all nested components, eliminating the need for shallow rendering of components and focusing on how multiple components work together.

Extension matchers

In the above test case, the native matcher provided by Jest is called, which can be more easily tested by using some matchers provided by jest-enzyme for the React component.

First, install the jest-enzyme:

npm install jest-enzyme --save-dev
Copy the code

Then, you need to add the path of the jest-enzyme principal file to the jest-config. js, setupFilesAfterEnv, in order to initialize the jest-enzyme after the test environment is ready.

"setupFilesAfterEnv": ["./node_modules/jest-enzyme/lib/index.js"'"]
Copy the code

After using the jest-enzyme, our test case code can be changed to

test('Verify that the App component is properly mounted', () => {
  const wrapper = shallow(<App />)
  expect(wrapper.find('[data-test="container"]')).toExist()
});
Copy the code

The toExist method is the matcher provided by jas-enzyme, and the completed matchers are listed here, as needed.

Demo of actual combat

Once the environment is ready, develop a simple demo using TDD and BDD combined with unit testing and integration testing to understand automated testing under both processes.

There are three function points

  • Enter the text, press Enter, and add a record to the list
  • Enter at the same time the input box content empty
  • Clicking Delete deletes the record

Code structure: The Input component is responsible for entering content, and the List component is responsible for displaying data and providing deletion capabilities. The two components are nested within a parent component (App).

    <div className="App">
      <Input
        onAddData={onAddData}
      />
      <List
        list={list}
        onDelete={onDelete}
      />
    </div>
Copy the code

TDD + unit testing

TDD requires code to be guided by testing, with a slight focus on testing. Using a process that combines unit testing with test-driven development, functionality should be combed out one by one, and test cases written should focus on a single unit.

Go back to the demo and write the test code first and then the business code for the above three function points and components’ respective responsibilities, so that the business finally passes the test and completes the development. Also use unit tests to ensure that the test cases are written only for the functionality of the component itself.

Start with the Input component and sort out the function of the component.

  • After entering the content, press Enter, the onAddData method passed in should be called, and the argument received is the final input
  • After entering the content, press Enter. The content of the input box should be cleared

Starting with # 1, write the test code:

test('Enter something, hit Enter, the onAddData for the Input component should be called and receive the correct arguments.', () = > {const fn = jest.fn()
  const wrapper = shallow(<Input
    onAddData={fn}
  />)
  const input = wrapper.find('[data-test="input"]')
  input.simulate('keyup', {
    keyCode: 13,
    target: { value: 'hello' }
  })
  expect(fn).toHaveBeenCalledWith('hello')
})
Copy the code

The test code verifies that the function passed into the Input component will not be called after the Input returns, and verifies that the correct value can be received.

The Mock Functions of Jest are used here. After rendering the component using the shallow provided by the enzyme, find the input and simulate the keyup event, verify in the following flow that fn is called and receives the correct value.

At this point, the test will not pass because the business code has not been written. Let’s look at the implementation of the Input component at this point:

const Input = (props) = > {
  const onChange = e= > {
    setValue(e.target.value)
  }
  return <input
    type="text"
    data-test="input"
    onKeyUp={(e)= > {
      if (e.keyCode === 13) {
        props.onAddData(e.target.value)
        setValue('')
      }
    }}
  />
}
Copy the code

App.js complements the onAddData function

function App() {
  const [ list, setList ] = useState([])
  const onAddData = value= > {
    setList([ ...list, value ])
  }
  return (
    <div className="App">
      <Input onAddData={onAddData}/>
    </div>
  );
}
Copy the code

Again, when you enter, the input field should be empty. Write your test code against this point

test('If I hit Enter, the Input field for the Input component should be empty.', () = > {const wrapper = shallow(<Input onAddData={()= > {}} />)
  const input = wrapper.find('[data-test="input"]')
  input.simulate('keyup', {
    keyCode: 13,
    target: { value: 'hello' }
  })
  expect(input.text()).toBe('')
})
Copy the code

This logic is then filled in in the Input component

const Input = (props) = > {
  const [ value, setValue ] = useState(' ') // Add new code for the test case
  const onChange = e= > {
    setValue(e.target.value)
  }
  return <input
    type="text"
    value={value}
    onChange={onChange}
    data-test="input"
    onKeyUp={(e)= >{if (e.keycode === 13) {props. OnAddData (e.target.value) // New code for the test case setValue('')}}} />}Copy the code

Run the tests. If both test cases pass, the Input component is almost complete. Let’s examine the List component:

  • The list data is received and rendered correctly
  • Click the Delete button and onDelete should be called and receive the index of the current list item

Start writing test cases from article 1

import React from 'react'
import { shallow } from 'enzyme'
import List from './List'
test('The list component received the list data and should render the corresponding number of list items', () = > {const list = ['hello'.'world']
  const wrapper = shallow(<List
    list={list}
  />)
  const items = wrapper.find('[data-test="list-item"]')
  expect(items.length).toBe(2)
  expect(items.at(0).text()).toBe('hello')
  expect(items.at(1).text()).toBe('world')
})
Copy the code

We pass an array to the List component and find the elements that should be rendered, judging their length and their respective contents. So let’s implement it

const List = (props) = > {
  return <div className="list">
    {
      props.list.map((item, index) => {
        return <p key={item}>
          <span data-test="list-item">{item}</span>
          <button>delete</button>
        </p>})}</div>

}
Copy the code

App.js passes the list data to the List component

function App() {
  const [ list, setList ] = useState([])
  const onAddData = value= > {
    setList([ ...list, value ])
  }
  return (
    <div className="App">
      <Input onAddData={onAddData}/>
      <List list={list}/>
    </div>
  );
}
Copy the code

Click the Delete button and onDelete should be called and receive the index of the current list item. The test code is much the same as the first test case for the Input component:

test('Click the delete button and the onDelete method of the List component should be called and receive the correct arguments.', () = > {const list = ['hello'.'world']
  const fn = jest.fn()
  const wrapper = shallow(<List
    list={list}
    onDelete={fn}
  />)
  const deleteBtn = wrapper.find('[data-test="delete-btn"]')
  deleteBtn.at(1).simulate('click')
  expect(fn).toHaveBeenCalledWith(1)
})
Copy the code

Then complete the code for this function

const List = (props) = > {
  const onDelete = index= > {
    props.onDelete(index)
  }
  return <div className="list">
    {
      props.list.map((item, index) => {
        return <p key={item} >
          <span data-test="list-item">{item}</span>
          <button
            onClick={()= >OnDelete (index)} data-test='delete-btn' > Delete</button>
        </p>})}</div>
}
Copy the code

Add deletion logic to app.js

function App() {
  const [ list, setList ] = useState([])
  const onAddData = value= > {
    setList([ ...list, value ])
  }
  const onDelete = index= > {
    const listData = [ ...list ]
    listData.splice(index, 1)
    setList(listData)
  }
  return (
    <div className="App">
      <Input onAddData={onAddData} />
      <List list={list} onDelete={onDelete} />
    </div>
  );
}
Copy the code

At this point, the demo is developed using the TDD+ unit test model. By writing test cases first and developing them later, TDD ensures that the code for each function is tested and bugs are much less. At the same time, when writing test code, it is natural to think about how to organize the code of this function, which also improves the maintainability of the code to a certain extent.

Unit testing guarantees very high test coverage, but in a business development scenario, it raises several issues:

  • The amount of code increases, with many test cases written to test the functionality, sometimes even more unit test code than business code.
  • Business coupling is high, test cases use some simulated data from business, and test cases need to be reorganized when business code changes.
  • Concerns are too independent, and because unit tests focus only on the health of a single unit, there is no guarantee that the whole of multiple units is healthy.

These issues suggest that using unit tests for business testing may not be a wise approach, so here’s a test approach for business scenarios.

BDD + integration testing

BDD is actually to simulate the user’s behavior. After the business code is completed, test cases are used to simulate the user’s operation behavior. Since the focus has risen to the level of the whole system, the integration test should ignore the behavior of individual components to ensure the smooth system behavior.

Since the business code is done first and then tested, take a look at the final code:

App component

function App() {
  const [ list, setList ] = useState([])
  const onAddData = value= > {
    setList([ ...list, value ])
  }
  const onDelete = index= > {
    const listData = [ ...list ]
    listData.splice(index, 1)
    setList(listData)
  }
  return (
    <div className="App">
      <Input onAddData={onAddData} />
      <List list={list} onDelete={onDelete} />
    </div>
  );
}
Copy the code

Input component

const Input = (props) = > {
  const [ value, setValue ] = useState(' ')
  const onChange = e= > {
    setValue(e.target.value)
  }
  return <input
    type="text"
    value={value}
    onChange={onChange}
    data-test="input"
    onKeyUp={(e)= > {
      if (e.keyCode === 13) {
        props.onAddData(e.target.value)
        setValue('')
      }
    }}
  />
}
Copy the code

The List component

const List = (props) = > {
  const onDelete = index= > {
    props.onDelete(index)
  }
  return <div className="list">
    {
      props.list.map((item, index) => {
        return <p key={item} >
          <span data-test="list-item">{item}</span>
          <button
             onClick={()= >OnDelete (index)} data-test='delete-btn' > Delete</button>
        </p>})}</div>
}
Copy the code

Now comb through the functions of demo, there are two points:

  • After you enter, the list should show what you typed
  • Click the delete button of the list item to delete this item

Write test cases for each function. Different from the unit test, our test object is a system composed of Input, List and App. The App component contains all logic, and the App component and internal nested components should be rendered in the test case, so the enzyme shallow method is no longer used. Use the mount method instead for deep rendering.

Here is the test code for these two functions:

import React from 'react'
import App from './App'
import { mount } from 'enzyme'

test('Input component enters the content, press Enter, List component should display the content ', () = > {const appWrapper = mount(<App />) const input = appWrapper.find('[data-test="input"]') input.simulate('keyup', { keyCode: 13, target: { value: 'hello' } }) const items = appWrapper.find('[data-test="list-item"]') expect(items.length).toBe(1) Expect (items.at(0).text()).tobe ('hello')}) test(' Click on the delete button in the List, the corresponding record in the List should be deleted ', () => {const appWrapper = mount()<App />Const input = appWrapper. Find ('[data-test="input"]') input.simulate('keyup', {keyCode: 13, target: { value: 'hello' } }) const deleteBtn = appWrapper.find('[data-test="delete-btn"]') deleteBtn.at(0).simulate('click') const items  = appWrapper.find('[data-test="list-item"]') expect(items.length).toBe(0) })Copy the code

The first test case, after rendering the App, finds the input field, simulates the carriage return event, and passes in the corresponding content. It then finds the list item, and if the list is 1 in length and the content is Hello, the test passes.

In the second test case, 1 data should be added before the delete button is found to simulate the clicking event. If the length of the list item is 0 at this time, the test passes.

From the above demo, we can see that integration testing, compared with unit testing, focuses more on the collaboration of multiple components. If there is no problem with one component itself, but something goes wrong with other components, the whole process will not pass the test. Combined with BDD, business code is paid more attention to during development, and tedious test cases need not be written first. And as long as the operation process does not change, the test cases are basically unchanged, which is more suitable for the development of ordinary business.

conclusion

Automated testing does increase development effort to a certain extent, but the improved stability of the tested system gives us more confidence. The two combined modes of development + automated testing introduced in this paper can deal with different development scenarios. I hope you can choose the appropriate way to introduce automated testing according to your own scenarios. It is very helpful to improve the robustness of the system or deepen the front-end engineering.