Author introduction: Ke Peilin, Meituan Review engineer.
Front-end testing continues to be a hot topic in 2019. When I attended Vueconf in May, the Vue unit testing keynote speaker asked participants how many teams had introduced unit testing. Surprisingly, only a few hands went up. At that time, the author’s team had not introduced front-end testing, but considering the necessity of testing and the team was working on a new project, I went back and fully integrated front-end testing in the new project.
Most Internet teams today follow an agile development pace. In fact, automated testing is essential to achieving “agile”. Quick online and quick verification on the business side put forward higher requirements on the response of the technical side: faster online and continuous online. Factoring in the fact that people are moving and applications are getting bigger, the cost of future iterations will only get higher. Of course, the cost of iteration of this project is also related to the complexity of the project. For example, the ordering business of the author is sufficiently complex, and some minor changes will actually involve a lot of content, which will not be friendly to the newcomers who just joined the team. Therefore, it is essential that the project have front-end testing to ensure the quality and stability of the business iteration.
What is front-end testing?
What we often call unit testing is really just one type of front-end testing. Front-end testing is divided into unit testing, UI testing, integration testing and end-to-end testing.
- Unit testing: Refers to the examination and verification of the smallest testable units in software, usually by independently testing a single function.
- UI testing: Is the testing of graphical interactive interfaces.
- Integration testing: Tests how different modules in an application integrate and work together, as its name suggests.
- End to end testing (E2E) : Testing from the user’s point of view, treating our program as a black box. I don’t know how you do it internally. I just open the browser and type the test content on the page to see if it’s what I want.
Technology selection
The framework for front-end testing is a flower.
- Unit tests include Mocha, Ava, Karma, Jest, Jasmine, etc.
- UI tests include ReactTestUtils, Test Render, Enzyme, react-testing-library, vue-test-utils, etc.
- E2e tests include Nightwatch, Cypress, Phantomjs, Puppeteer, etc.
Because our project uses React technology stack, the selection and use of React technology are mainly introduced here.
Unit testing
The framework | assertions | The simulation | The snapshot | Asynchronous test | The environment | Concurrent test | Test coverage |
---|---|---|---|---|---|---|---|
Mocha | It is not supported by default and can be configured | It is not supported by default and can be configured | It is not supported by default and can be configured | friendly | The global environment | no | Need to configure |
Ava | The default support | If no, third-party configuration is required | The default support | friendly | isolation | is | If no, third-party configuration is required |
Jasmine | The default support | The default support | The default support | Don’t friendly | The global environment | no | Don’t need to configure |
Jest | The default support | The default support | The default support | friendly | isolation | is | Don’t need to configure |
Karma | If no, third-party configuration is required | If no, third-party configuration is required | If no, third-party configuration is required | If no, third-party configuration is required | – | – | Need to configure |
- Mocha is the most ecological and widely used single test framework, but it requires a lot of configuration to achieve its high scalability.
- Ava is a more lightweight, efficient and simple single test framework, but it is not stable enough, and the CPU will burst when there are too many concurrent running files.
- Jasmine is the “elder brother” of the single-test framework, out-of-the-box, but asynchronous test support is weak.
- Jest is based on Jasmine with a lot of changes and features added, again out of the box, but with good asynchronous testing support.
- Karma can be tested in real browsers, has powerful adapters, can be configured with other single test frameworks, and is typically used in conjunction with Mocha or Jasmine etc.
Each framework has its own advantages and disadvantages, there is no best framework, only the most suitable framework. Augular’s default testing framework is Karma + Jasmine, while React’s default testing framework is Jest.
Jest is recommended and used by various React applications. It is based on Jasmine, and has been extensively modified and added features, again out of the box, support for assertions, emulation, snapshots, etc. The Create React App will have Jest configured by default for new projects, so we can use it without much modification.
UI test
Although there are official Test frameworks such as ReactTestUtils and Test Render, their APIS are quite complex, and the official documentation also recommends using the react-testing-library or Enzyme libraries.
Note: We recommend using React Testing Library which is designed to enable and encourage writing tests that use your components as the end users do. Alternatively, Airbnb has released a testing utility called Enzyme, which makes it easy to assert, manipulate, and traverse your React Components’ output.
React Testing Library and Enzyme are based on ReactTestUtils and Test Render, which encapsulate more concise and easy-to-use apis.
Enzyme comes out earlier, but it usually lags behind the implementation of React (about six months, for example hooks are not supported, I’m not sure if they are now). The React Testing Library came out late, but tends to support new features in React, which was a huge benefit for me when Testing Hooks.
Enzyme is tested from the perspective of code implementation, based on state and props, while React Testing Library is tested from the perspective of user experience, so it is tested based on DOM. It may also have a better development experience and more consistent testing. This approach makes refactoring a breeze while implementing accessibility best practices.
Of course, because the Enzyme comes out earlier, its surrounding ecology is better, many big factories have used it, but some are also doing migration. I wanted to try updating a better framework, so I chose the React Testing Library.
E2e test
The framework | Whether cross-browser support | implementation | advantages | disadvantages |
---|---|---|---|---|
Nightwatch | is | Selenium | Can be used with other frameworks. Applicable to partial functional test scenarios | There is no TypeScript support, and the community culture is a bit weaker than the other frameworks |
Cypress | no | Chrome | Easy to debug and log, use Mocha as its test structure, if the single test uses Mocha, all tests use the same structure, looks more “standard” | Lack of advanced features |
Testcafe | is | Testcafe | Support TS, parallel testing, out of the box | Advanced functions such as screen recording and DOM snapshot are not supported |
Puppeteer | no | Chrome | Fast speed, easy debugging | Headless Chrome does not support installing extensions |
Puppeteer is a library from the Google Chrome team, and although it is newer than other E2E frameworks, it also has a large community. With a cleaner, easy-to-use API and faster running speed, it has gradually become the industry’s benchmark for automated testing, capturing the hearts of many Selenium users. Take a look at NPM Trends for e2E testing frameworks over the years.
conclusion
After analysis, the technology selection of our project is Jest + React Testing Library + Puppeteer
For the Vue project, Jest + Vue- test-utils + Puppeteer is used in order to keep the technology stack unified
Writing principles
- When testing code, only tests are considered, not internal implementations
- The data should be as close to reality as possible
- Take full account of the boundary conditions of the data
- Key, complex, core code, focus on testing
- Use AOP(beforeEach, afterEach) to reduce the amount of test code and avoid useless functionality
- The combination of testing and functional development facilitates design and code refactoring
Write a description
Future projects are generated based on Talos. In fact, create-react-app is used to generate React projects and Vue-CLI@3 is used to generate Vue projects. The default configuration of Jest itself does not need to make major changes.
- The folder for unit tests and UI tests is named tests and the test file ends with.test.js
- Place the Tests folder in the same directory as the code they are testing so that relative path imports have shorter paths
- The e2E test folder is named e2e and stored in the root directory with SRC
- Both VScode and WebStorm have corresponding Jest plug-ins, which can be used for code completion, debug and automatic operation when writing code after installation
How to Write tests
In fact, Jest’s syntax is pretty simple, you only need to be familiar with a few apis to quickly start testing.
Unit testing of utility class functions
// lib/utils.js
export function hexToRGB(hexColor) {
const result = / ^ #? ([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hexColor);
return result ? [parseInt(result[1].16), parseInt(result[2].16), parseInt(result[3].16] : []; }// lib/__tests__/utils.test.js
import { hexToRGB } from '.. /util';
describe('Convert hexadecimal color to RGB', () => {
it('small', () => {
expect(hexToRGB('#ffc150')).toEqual([255.193.80]);
});
it(The 'capital', () => {
expect(hexToRGB('#FFC150')).toEqual([255.193.80]);
});
});
Copy the code
Simply give the input of the function, then call the function and verify that its output is as expected.
Unit tests for Redux
Test the Reducer
Reducer merges the action into the previous state and returns the new state. Because the project uses Immutable. Js, you need to use the merge operation.
// store/reducers/cart.js
import Immutable from 'immutable';
import { UPDATE_CART_DISH_LIST, UPDATE_CART_DISH_SORT_MAP_LIST } from '.. /actions/cart';
export const initialState = Immutable.Map({
cartDishList: Immutable.Map({}),
cartDishSortMapList: Immutable.List([]),
});
export default (state = initialState, action) => {
switch (action.type) {
case UPDATE_CART_DISH_LIST:
return state.merge({
cartDishList: action.cartDishList,
});
case UPDATE_CART_DISH_SORT_MAP_LIST:
return state.merge({
cartDishSortMapList: action.cartDishSortMapList,
});
default:
returnstate; }};// store/reducers/__tests__/cart.js
import Immutable from 'immutable';
import cartReducer, { initialState } from '.. /cart';
import { UPDATE_CART_DISH_LIST, UPDATE_CART_DISH_SORT_MAP_LIST } from '.. /.. /actions/cart';
describe('cart reducer', () => {
it('Return initialized state', () => {
expect(cartReducer(undefined, {})).toEqual(initialState);
});
it('Update shopping cart', () = > {const state = Immutable.Map({ cartDishList: null });
const action = { type: UPDATE_CART_DISH_LIST, cartDishList: Immutable.Map({}) };
const newState = Immutable.Map({ cartDishList: Immutable.Map({}) });
expect(cartReducer(state, action)).toEqual(newState);
});
it('Update shopping cart order', () = > {const state = Immutable.Map({ cartDishSortMapList: null });
const action = { type: UPDATE_CART_DISH_SORT_MAP_LIST, cartDishSortMapList: Immutable.List([]) };
const newState = Immutable.Map({ cartDishSortMapList: Immutable.List([]) });
expect(cartReducer(state, action)).toEqual(newState);
});
});
Copy the code
Testing normal Action
A normal Action is a function that returns a normal object and is fairly easy to test.
// store/actions/cart.js
export const UPDATE_SHOPID = 'UPDATE_SHOPID';
export function updateShopIdAction(shopId) {
return {
type: UPDATE_SHOPID,
shopId,
};
}
// store/actions/__tests__/cart.test.js
import { updateShopIdAction, UPDATE_SHOPID } from '.. /cart';
test('update shopId', () = > {const expectedAction = { type: UPDATE_SHOPID, shopId: '111' };
expect(updateShopIdAction('111')).toEqual(expectedAction);
});
Copy the code
Test composite actions with middleware
Redux-thunk middleware is used in the project and we need to use redux-mock-store to apply the middleware to the mock store.
// store/actions/cart.js
export function updateShopIdAction(shopId) {
return {
type: UPDATE_SHOPID,
shopId,
};
}
export function updateTableNumAction(tableNum) {
return {
type: UPDATE_TABLE_NUM,
tableNum,
};
}
export function updateBaseInfo(shopId, tableNum) {
return (dispatch) = > {
dispatch(updateShopIdAction(shopId));
dispatch(updateTableNumAction(tableNum));
};
}
// store/actions/__tests__/cart.test.js
import configureStore from 'redux-mock-store';
import Immutable from 'immutable';
import thunk from 'redux-thunk';
import { updateBaseInfo, UPDATE_SHOPID, UPDATE_TABLE_NUM } from '.. /cart';
const middlewares = [thunk];
const mockStore = configureStore(middlewares);
test('updateBaseInfo', () = > {const store = mockStore(Immutable.Map({}));
store.dispatch(updateBaseInfo('111'.'111'));
const actions = store.getActions();
const expectPayloads = [{ type: UPDATE_SHOPID, shopId: '111' }, { type: UPDATE_TABLE_NUM, tableNum: '111' }];
expect(actions).toEqual(expectPayloads);
});
Copy the code
Testing asynchronous actions
We need the axios-mock-adapter package to simulate the request. Create-react-app installs this package by default.
// store/asyncActions/shop.js
import { loadShopInfoAction } from '.. /actions/main';
import shop from '.. /.. /api/shop';
import Loading from '.. /.. /components/common/Loading';
export const getShopInfo = (mtShopId, tableNum) = > (dispatch) => {
Loading.show();
return shop.getShopInfo({ mtShopId, tableNum })
.then((res) = > {
Loading.close();
const result = res.data;
dispatch(loadShopInfoAction(result));
})
.catch((e) = > {
Loading.close();
console.error(e);
});
};
// store/asyncActions/__tests__/shop.test.js
import configureStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import MockAdapter from 'axios-mock-adapter';
import instance from '@lib/axios';
import { getShopInfo } from '.. /main';
import { LOAD_SHOP_INFO } from '.. /.. /actions/main';
const middlewares = [thunk];
const mockStore = configureStore(middlewares);
const mockHttp = new MockAdapter(instance); // inherits the configuration from axios.js
test('getShopInfo', () = > {const store = mockStore({});
const expectPayloads = [{ type: LOAD_SHOP_INFO, shopInfo: { peopleCount: 0}}]; mockHttp.onGet('/shopInfo')
.reply(200, {
data: { peopleCount: 0}}); store.dispatch(getShopInfo()) .then((a)= > {
const actions = store.getActions();
expect(actions).toEqual(expectPayloads);
});
});
Copy the code
UI test
For unit testing, it’s low cost and high return, while for UI testing, it’s probably more like high cost and low return. However, it is necessary to test some common components. As I mentioned earlier, when the project code is complex enough, a change to a common component can lead to an online Case.
Function component
Here’s a quick test of the add/subtract component (with some logic reduced).
import React from 'react';
import Immutable from 'immutable';
import './NumberCount.less';
const NumberCount = (props) = > {
const { dish, count, addToCart, minusDish, minusPoint,} = props;
return (
<div className="number-count">
{
count > 0
? (
<>
<div className="minus">
<span className="minus-icon" />
<span className="minus-trigger" onClick={() => minusDishToCart(dish)} />
</div>
<div className="num">{count}</div>
</>
)
: null
}
<div className="plus">
<span className="plus-icon" />
<span className="plus-trigger" onClick={() => addToCart(dish)} />
</div>
</div>
);
};
export default NumberCount;
Copy the code
For this component, the logic is relatively simple. Our test points are whether the events of add and subtract dishes buttons are triggered correctly, whether the minus button and quantity are displayed when the quantity is 0, and whether the display is correct when the quantity is not 0.
import React from 'react';
import { render, cleanup, fireEvent } from '@testing-library/react';
import Immutable from 'immutable';
import NumberCount from '.. /NumberCount';
afterEach(cleanup);
test('Click on the add button', () = > {const props = {
dish: Immutable.fromJS({ spuId: 111 }),
count: 0.addToCart: jest.fn(),
};
const{ container } = render(<NumberCount {... props} />); const addButton = container.querySelector('.plus-trigger'); const minusButton = container.querySelector('.minus-trigger'); const numDiv = container.querySelector('.num'); expect(minusButton).toBeNull(); expect(numDiv).toBeNull(); fireEvent.click(addButton); expect(props.addToCart).toBeCalledWith(props.dish); }); () => {const props = {dish: Immutable. FromJS ({spuId: 111}), count: 1, addToCart: jest.fn(), minusDish: jest.fn(), }; const { container } = render(<NumberCount {... props} />); const minusButton = container.querySelector('.minus-trigger'); const numDiv = container.querySelector('.num'); expect(numDiv.innerHTML).toBe('1'); fireEvent.click(minusButton); expect(props.minusDish).toBeCalledWith(props.dish); });Copy the code
Higher-order components wrapped with Connect
Although theoretically all common components in Components should be stateless, sometimes some common components can be easier to use and cheaper to develop as stateful components. Redux officially recommends testing components directly before the Connect package.
In order to be able to test the App component itself without having to deal with the decorator, we recommend you to also export the undecorated component.
I’ll give you an example here.
import React from 'react';
import { connect } from 'react-redux';
import Immutable from 'immutable';
import ImmutableBaseComponent from './ImmutableBaseComponent';
import { selectSkuDish, toggleMultiPanelAction } from '.. /.. /store/actions/cart';
import { computeCount } from '@modules/cartHelper';
import './SelectDish.less';
// The component that is not connected needs to be exported for testing
export class SelectDish extends ImmutableBaseComponent {
togglePanel = (e, spuDish) = > {
e.stopPropagation();
this.props.selectSkuDish(spuDish);
this.props.toggleMultiPanelAction(true);
}
render() {
const { spuDish, cartDishList } = this.props;
const count = computeCount(spuDish, cartDishList);
return (
<div className="select-dish" onClick={e= >This.togglepanel (e, spuDish)}> Select {count > 0 &&<span>{count}</span> }
</div>); }}const mapStateToProps = state= > ({
cartDishList: state.getIn(['cart'.'cartDishList'])});const mapDispatchToProps = dispatch= > ({
selectSkuDish: spuDish= > dispatch(selectSkuDish(spuDish)),
toggleMultiPanelAction: show= > dispatch(toggleMultiPanelAction(show)),
});
export default connect(mapStateToProps, mapDispatchToProps)(SelectDish);
Copy the code
You can see that the SelectDish before the package is exported in the code, so you can test it as follows:
import React from 'react';
import { render, cleanup, fireEvent } from '@testing-library/react';
import Immutable from 'immutable';
import { SelectDish } from '.. /SelectDish';
afterEach(cleanup);
test('Choose a variety of dishes', () = > {const props = {
spuDish: Immutable.fromJS({ spuId: '111' }),
cartDishList: Immutable.fromJS({}),
selectSkuDish: jest.fn(),
toggleMultiPanelAction: jest.fn(),
};
const { container } = render(<SelectDish {. props} / >);
const selectButton = container.querySelector('.select-dish');
fireEvent.click(selectButton);
expect(props.selectSkuDish).toBeCalledWith(props.spuDish);
expect(props.toggleMultiPanelAction).toBeCalledWith(true);
});
Copy the code
Writing test tips
When writing module tests or UI tests, you may find some difficult points to test, such as Localstorage, or some delay function firing. Let’s take a look at how to handle these situations.
LocalStorage
Since Jest’s environment is based on jsDOM, we need to emulate the behavior of LocalStorage. Reference Vue2.0 data detection methods. Create a new file and add the following code
// config/jest/browserMocks.js
const localStorageMock = (function () {
let store = {};
return {
getItem(key) {
return store[key] || null;
},
setItem(key, value) {
store[key] = value.toString();
},
removeItem(key) {
deletestore[key]; }, clear() { store = {}; }}; } ());Object.defineProperty(window.'localStorage', {
value: localStorageMock,
});
Copy the code
You then need to configure this file as a startup file in the Jest configuration
setupFiles: [
'<rootDir>/config/jest/browserMocks.js',
]
Copy the code
The time delay function
Jest provides apis such as Jest.usefaketimers (), Jest.Runalltimers (), and Jest.Userealtimers () to complete the tests. Take a look at the FOLLOWING UI test of the Toast component.
import React from 'react';
import './Toast.less';
class Toast extends React.Component {
static close() {
Toast.beforeClose && Toast.beforeClose();
if (Toast.timer) {
clearTimeout(Toast.timer);
Toast.timer = null;
}
if (Toast.toastWrap) {
document.body.removeChild(Toast.toastWrap);
Toast.toastWrap = null;
}
}
componentDidMount() {
const { duration, beforeClose } = this.props;
Toast.beforeClose = beforeClose;
if (duration > 0) {
Toast.timer = setTimeout((a)= > {
Toast.close();
}, duration);
}
}
render() {
if (Toast.toastWrap) Toast.close();
const { content, hasMask } = this.props;
return (
<div className="toast-wrap">
{ hasMask && <div className="toast-mask" /> }
<div className="toast-box">
{content}
</div>
</div>
);
}
}
export default Toast;
Copy the code
Test whether the contents of the Toast popover are consistent and whether the beforeClose event is triggered when the popover closes.
import React from 'react';
import { render, cleanup } from '@testing-library/react';
import Toast from '.. /Toast';
afterEach(cleanup);
test('Normal popover', () => {
jest.useFakeTimers();
const props = {
duration: 2000.content: 'hello world'.beforeClose: jest.fn(),
};
const { container } = render(<Toast {. props} / >);
const toastDiv = container.querySelector('.toast-box');
expect(toastDiv.innerHTML).toBe('hello world');
expect(props.beforeClose).not.toBeCalled();
jest.runAllTimers();
expect(props.beforeClose).toBeCalled();
jest.useRealTimers();
});
Copy the code
E2e test
For E2E testing, we didn’t have to write a lot of code, after all, we had professional QA classmates. In my opinion, we only need to cover the main process simply, for example, in our ordering business, we enter the menu page from the selection page at the beginning, add and subtract dishes, and then enter the order page to place orders. E2e also needs to configure Jest a little bit. Create a new jest-e2e.config.js file that does not conflict with the single-test configuration.
module.exports = {
preset: 'jest-puppeteer'.testRegex: 'e2e/.*\\.test\\.js$'};Copy the code
Add a line of command to package.json
"scripts": {
"test:e2e": "jest -c jest-e2e.config.js --detectOpenHandles"
}
Copy the code
A little bit of e2E code here.
// e2e/regression.test.js
const puppeteer = require('puppeteer');
const TARGET_URL = 'XXX';
const width = 375;
const height = 667;
test('Main flow'.async() = > {const browser = await puppeteer.launch({ headless: false });
const context = await browser.createIncognitoBrowserContext();
const page = await context.newPage();
await page.goto(TARGET_URL, {
waitUntil: 'networkidle2'.// Wait until you are free
});
await page.setViewport({ width, height });
await page.waitForSelector('.people-count');
await page.screenshot({ path: 'e2e/screenshots/main.png' });
await page.click('.people-count:nth-child(1)');
await page.click('.start-btn');
await page.waitFor(3000);
await page.screenshot({ path: 'e2e/screenshots/menu.png' });
// Add regular dishes
await page.click('.meal-list-style > ul > li:nth-child(8) .plus-trigger');
// Expand the shopping cart
await page.click('#cart-chef');
await page.screenshot({ path: 'e2e/screenshots/cart.png' });
await page.click('#cart-chef');
// Go to the single page
await page.click('.cart-bar > .btn.highlight');
await page.waitFor(3000);
await page.screenshot({ path: 'e2e/screenshots/order-confirm.png' });
await browser.close();
}, 20000);
Copy the code
Test coverage
You can view the test coverage of a project by adding a command line to the project.
"scripts": {
"cov": "node scripts/test.js --coverage"
}
Copy the code
Tests are written to ensure the quality of the project and the development experience, so in principle full coverage is not possible.
Because most of our current projects are agile development, there are many UI style changes or functional requirements, and time does not allow us to achieve better test coverage.
Therefore, we write tests that target abstracted functions (grouped in the Modules folder), actions that operate on data streams, and common components (in the Components folder under comon).
Only unit tests and UI tests are counted for test coverage, not E2E. E2e does not require much writing, since most of the key logic is already covered by unit tests, and e2E simply needs to simulate the main process.
Change the scope of test statistics through collectCoverageFrom configuration in Jest. The final project test coverage requirement is Statement 60%, Branches 60%, Functions 60%, and Lines 60%. Related configurations are as follows:
{
collectCoverageFrom: [
'src/components/common/**/*.{js,jsx,ts,tsx}'.'src/modules/**/*.{js,jsx,ts,tsx}'.'src/lib/**/*.{js,jsx,ts,tsx}'.'src/store/**/*.{js,jsx,ts,tsx}'.'src/constants/**/*.{js,jsx,ts,tsx}'.'src/api/**/*.{js,jsx,ts,tsx}',].coverageThreshold: {
global: {
statements: 60.branches: 60.functions: 60.lines: 60,,}}}Copy the code
Here is the test coverage under the Store/Actions folder in our project
You can see the overall test coverage for the entire folder at the top, as well as the specific coverage for each of the following files. Click file to see the coverage of specific code.
conclusion
There is a cost to adding tests to a project, especially UI testing. Everything needs to be balanced between cost and benefit. As mentioned above, low-cost unit tests cover as much as possible, while high-cost UI tests cover only common components.
Front-end testing does bring considerable benefits to a project, as it can lead to significant quality improvements over long iterations.
- The first is to reduce the number of bugs in the test environment, by running a single test can detect some logic errors.
- Secondly, it covers boundary conditions not covered by many QA students (the author conveniently fixes several questions 😁 when writing the test later), because our test writing principle is to fully consider the boundary conditions of data.
- Easy to refactor.
- When new functionality is added to the original logic, the quality and stability of the iteration can be greatly improved by running previous tests.
This article mainly summarizes the author’s experience and precipitation of writing test in React project, while there is no in-depth study of Vue project. However, Vue has a feature, basically important libraries such as VUe-Router and Vuex are officially maintained, and Vue Test Utils is also the official unit testing tool library of vue.js. The documentation is quite detailed and can be referred to when writing tests for Vue projects.