Front-end automated testing: Testing Library
introduction
Front-end testing:
static testing eslint, TypeScript
unit testing jest, mocha
integration testing enzyme, react-testing-library, mock
crawler ,
front-end and back-end decoupling
Why introduce automated testing
Testing allows developers to consider problems from the user's perspective and ensure that each function of the component can run normally through testing.
When writing unit tests, in most cases the component code will be adjusted repeatedly. Through constant polishing, careless considerations during development are avoided, thereby improving the quality of the component. The same is true for business modules that change infrequently.
According to the book "Google Software Testing", the definition of testing certification levels is as follows:
-
Level 1
- Use test coverage tools.
- Use continuous integration.
- The test grades are small, medium, and large.
- Create a collection of smoke tests (main process test cases).
- Mark which tests are non-deterministic (test results are not unique).
-
Level 2
- If any test runs with a red (failed) result it will not be published.
- Smoke testing is required before every code submission. (Self-test, simply follow the main process)
- The overall code coverage of various types should be greater than 50%.
- The coverage rate of small tests should be greater than 10%.
-
Level 3
- All important code changes are tested.
- The coverage of small tests is greater than 50%.
- All new important features must be verified through integration testing.
-
Level 4
- Smoke tests are automatically run before any new code is committed.
- The smoke test must be run within 30 minutes.
- There is no testing for uncertainty.
- Overall test coverage should be no less than 40%.
- The code coverage of small tests should be no less than 25%.
- All important functionality should be verified by integration tests.
-
Level 5
-
For each important defect fix, a test case should be added corresponding to it.
-
Actively use available code analysis tools.
-
The overall test coverage is no less than 60%.
-
Small test code coverage should be no less than 40%.
-
Choice of testing tools
Jest, Mocha, Testing Library and Enzyme are the most widely used tools for front-end automated testing.
Simple rules to follow
AAA mode : Arrange, Act, Assert
1. Arrangement
The orchestration phase can be divided into two parts. Render the component and get the required DOM elements .
2. Execution
After preparing the content to be tested, you can use the tool API of the testing library to perform simulation operations. For example, use React Testing Library
simulate fireEvent
click events. (This step is not necessary, you can also directly assert the content obtained by the arrangement)
fireEvent.click(mockFn)
3. Assertion
Assertion is also judgment. Use the built-in assertion statements in Jest or React Testing Library to judge the above content, and pass if it passes.
Introduction
The Testing Library is a lightweight testing solution for testing web pages by querying and interacting with DOM nodes (either using JsDOM
/ Jest
emulating or in the browser). Provide query nodes in a way that is close to how users find elements on the page.
The Testing Library can be applied to all major frameworks (including React
, , Angular
and Vue
) and can be Cypress
used as the core in the testing framework.
This article explores the React Testing Library (RTL)
Thought
The Testing Library encourages testing to avoid implementation details (such as a component's internal state, internal methods, lifecycle, subcomponents) and instead emphasizes focusing on content similar to what the user actually interacts with.
The more closely the tests are written to resemble how users of the software will use them, the more confidence they can bring to the test.
core
Inquire
type
There are three ways to query elements in RTL , namely getBy
, queryBy
, findBy
. If you want to query multiple elements, use xxxAllBy
instead. The differences are as follows:
Query type | 0 corresponds to | 1 corresponds | >1 corresponds | async/await await |
---|---|---|---|---|
getBy… | Report an error | return | Report an error | no |
queryBy… | null | return | Report an error | no |
findBy… | Report an error | return | Report an error | yes |
getAllBy… | Report an error | Return array | Return array | no |
queryAllBy… | [ ] | Return array | Return array | no |
findAllBy… | Report an error | Return array | Return array | yes |
Generally speaking, getBy is used to query for normally existing elements (if not found, an error will be reported), queryBy is used to query for elements that do not exist (if not found, no error will be reported), and findBy is used to query asynchronous elements that need to be waited for.
Using *ByRole will read the hidden element name as "". You can consider another better query method. If you still need to use *ByRole to query, you can refer to the official manual . Problem details
priority
id、class
According to the guiding ideology of the test library, certain priorities should be observed when using the query API (from the user's perspective, you can directly use what you see on the page as the selector instead of using selectors that the user cannot see ) .
-
regular
-
getByRole finds
特定角色
elements with -
label
getByLabelText finds elements matching the given text -
getByPlaceholderText finds
占位符属性
elements with -
getByText finds
文本节点
elements with -
getByDisplayValue finds
value
control elements with
-
-
semantic query
-
getByAltText finds
alt
elements with attributes and text corresponding to alt that match the text -
getByTitle returns
title
the element that has the attribute and matches the text corresponding to title
-
-
With testId
-
getByTestId (Consider avoiding meaningless attributes in the production environment, you can
babel-plugin-react-remove-properties
removedata-test
the test auxiliary selector)// .bablerc { "env": { "production": { "plugins": ["react-remove-properties"] } } }
-
practice
Since Test Library emphasizes testing from the user's perspective, it prefers to use Accessibility
elements on the DOM. (But sometimes it is more efficient to only use the style selector or directly use the style selector. It can only be said to find the balance between philosophy and life )
Check out the following points for the ARIA role method corresponding to the HTML element :
1. Use Chrome Developer Tools
Elements > Accessibility
2. Using logRoles
the API provided by RTL
import {
render, logRoles } from '@testing-library/react';
test('find ARIA role', () => {
const {
container } = render(<Component />);
logRoles(container);
})
3. Use third-party plug-ins
Accessing elements is even easier using a Google Chrome plug-in.
User action
With fireEvent, interactive events generated by actual users can be simulated.
There is also a high-level library under the Testing Library
@testing-library/user-event
that provides more interactive events than fireEvent.
fireEvent(node: HTMLElement, event: Event)
fireEvent
Corresponding eventMap
event set attributes
Commonly used assertions
RTL extends jest's API and defines its own assertion functions. All assertion functions are included in @testing-library/jest-dom
the package. For details, see: Built-in assertion library
toBeDisabled
toBeEnabled
toBeEmptyDOMElement
toBeInTheDocument
toBeInvalid
toBeRequired
toBeValid
toBeVisible
toContainElement
toContainHTML
toHaveAttribute
toHaveClass
toHaveFocus
toHaveFormValues
toHaveStyle
toHaveTextContent
toHaveValue
toHaveDisplayValue
toBeChecked
toBePartiallyChecked
toHaveDescription
Is
The use RTL
requires Jest, so let's first learn the basic use of Jest in a "wild way" .
introduce
Jest is a testing framework RTL
and a testing solution. RTL
The meaning of existence is to write tests in a more beautiful and powerful way.
assertion matcher
judge true or false
-
toBeNull
-
toBeUndefined
-
toBeDefined
-
toBeTruthy
-
toBeFalsy
Number related
-
toBeGeaterThan is greater than a certain number
-
toBeGeaterThanOrEqual is greater than or equal to
-
toBeLessThan is less than a certain number
-
toBeLessThanOrEqual is less than or equal to
-
toBeCloseTo equality judgment of floating point numbers
not modifier
match opposite of expected
string matching
- toMatch
Array, collection related
-
toContain determines whether an array or collection contains an element
-
toHaveLength determines the length of the array
Function related
-
toHaveBeenCalled determines whether mockFunc is called
-
toHaveBeenCalledWith('_test_param') calls whether the parameter received is _test_param
-
The number of toHaveBeenCalledTimes(2) calls
-
toHaveReturned whether there is a return value
-
toHaveReturnedWith('_test_return') whether the return value is _test_return
Abnormally related
- toThrow determines whether the thrown exception meets expectations
Mock function
Jest's three commonly used Mock function APIs are jest.fn()
, jest.spyOn()
, jest.mock()
.
In unit testing, more often than not, you do not need to care about the execution process and results of the internal calling methods. You only need to confirm whether they are called correctly.
The Mock function provides three features:
- Capture function calls
- Set function return value
- Change the internal implementation of a function
1. Jest.fn()
Default returns undefined
as the return value.
const test_click = jest.fn(1,2,3);
expect(test_click).toHaveBeenCalledTimes(1) // 测试是否调用了 1 次
expect(test_click).toHaveBeenCalledWith(1,2,3) // 测试是否调用了 1 次
You can also customize the return value and implement it internally
// 自定义返回值
let customFn = jest.fn().mockReturnValue('default');
expect(customFn()).toBe('default');
// 自定义内部实现
let customInside = jest.fn((num1, num2) => num1 + num2);
expect(customInside(10, 10)).toBe(20);
// 返回 Promise
test('jest 返回 Promise', async() => {
let mockFn = jest.fn().mockResolveValue('promise');
let result = await mockFn();
expect(result).toBe('promise');
expect(Object.prototype.toString.call(mockFn())).toBe('[object Promise]')
})
Grouping and hooks
Use describe
keywords to wrap tests.
describe('分别测试', () => {
/** testItem **/
/** hook 函数 **/
}
It should be noted that the running order of the describe block is always before the execution order of the test function. doubt?
// describe 块内程序的执行顺序()
describe('1', () => {
console.log('1');
describe('2', () => {
console.log('2'); });
describe('3', () => {
console.log('3');
describe('4', () => {
console.log('4'); })
describe('5', () => {
console.log('5'); })
})
describe('6', () => {
console.log('6'); })
})
describe('7', () => {
console.log('7');
it('(since there has to be at least one test)', () => {
console.log('8') });
})
// 测试结果,顺序 1-8
You can use the hook function provided by jest to prepare data for the singleton.
The hook functions provided by Jest are:
-
beforeEach
-
beforeAll
-
afterEach
-
afterAll
Need to pay attention to describe
the hook function used externally and the execution order of the hook function internally:
beforeAll(() => console.log('outside beforeAll'));
beforeEach(() => console.log('outside beforeEach'));
afterAll(() => console.log('outside afterAll'));
afterEach(() => console.log('outside afterEach'));
describe('', () => {
beforeAll(() => console.log('intside beforeAll'));
beforeEach(() => console.log('intside beforeEach'));
afterAll(() => console.log('intside afterAll'));
afterEach(() => console.log('intside afterEach'));
test('test1', () => console.log('test1 run'));
test('test2', () => console.log('test2 run'));
})
/** 结果
outside beforeAll
inside beforeAll
outside beforeEach
inside beforeEach
test1 run
inside afterEach
outside afterEach
outside beforeEach
inside beforeEach
test2 run
inside afterEach
outside afterEach
inside afterAll
outside afterAll
**/
Test async modules
Pass in the done parameter in the function that includes the test , and Jest will end the test after the done callback function is executed. If done is never called, the test case fails and a timeout error is output.
import timeout from './timeout'
test('测试timer', (done) => {
timeout(() => {
expect(2+2).toBe(4)
done()
})
})
If the asynchronous function returns a Promise, you can return the Promise directly, and Jest will wait for the resolve status of the Promise. If you expect a Promise to be Rejected, you need to use .catch
a method and add expect.assertions
a certain number of assertions to verify that it is called.
test('the data is peanut butter', () => {
return fetchData().then(data => {
expect(data).toBe('peanut butter');
});
});
test('the fetch fails with an error', () => {
expect.assertions(1);
return fetchData().catch(e => expect(e).toMatch('error'));
});
test('the data is peanut butter', () => {
return expect(fetchData()).resolves.toBe('peanut butter');
});
test('the fetch fails with an error', () => {
return expect(fetchData()).rejects.toMatch('error');
});
With async/await
test('the data is peanut butter', async () => {
const data = await fetchData();
expect(data).toBe('peanut butter');
});
test('the fetch fails with an error', async () => {
expect.assertions(1);
try {
await fetchData();
} catch (e) {
expect(e).toMatch('error');
}
});
When testing React
components, you often encounter useEffect
scenarios where State is updated in or asynchronously updated. In this case, the processing of test assertions requires the use of Act
APIs. Case description and usage
Configuration file
// 一下列举常用配置项目,所有配置属性访问官网手册 https://jestjs.io/docs/configuration
// 默认地,Jest 会运行所有的测试用例然后产出所有的错误到控制台中直至结束。
// bail 配置选项可以让 Jest 在遇到第一个失败后就停止继续运行测试用例,默认值 0
bail: 1,
// 每次测试前自动清除模拟调用和实例,相当于每次测试前都调用 jest.clearAllMocks,默认 false
clearMocks: false,
// 指示在执行测试时是否应收集覆盖率信息,通过使用覆盖率收集语句改造所有已执行文件,所以开启后可能会显著减慢
// 测试速度,默认 false
collectCoverage: false,
// 指定测试覆盖率统计范围,使用 glob 模式匹配,默认 undefined
collectCoverageFrom: ['src/**/*.{js,jsx,ts,tsx}', '!src/**/*.d.ts'],
// 指定忽略匹配的覆盖范围,默认 ["/node_modules"]
coveragePathIgnorePatterns: ["/node_modules/"],
// 配置一组在所有测试环境中可用的全局变量, 默认 {}
globals: {},
// 用于给模块路径映射到不同的模块, 默认 null
moduleNameMapper: {
"\\.(css|less|scss|sss|styl)$": "jest-css-modules" | "identity-obj-proxy"
}
// 指定 Jest 根目录,Jest 只会在根目录下测试用例并运行
rootDir: './',
// 设置 Jest 搜索文件的目录路径列表,默认 []
roots: [],
// 指定创建测试环境前的准备文件,针对每个测试文件都会运行一次
setupFiles: ['react-app-polyfill/jsdom'],
// 指定测试环境创建完成后为每个测试文件编写的配置文件
setupFilesAfterEnv: ['<rootDir>/src/setupTests.ts'],
// 配置 Jest 匹配测试文件的规则, 使用 glob 规则
testMatch: [
'<rootDir>/src/**/__tests__/**/*.{js,jsx,ts,tsx}',
'<rootDir>/src/**/*.{spec,test}.{js,jsx,ts,tsx}'
],
// 用于指定测试用例运行环境,默认 node
testEnvironment: 'jsdom',
// 配置文件处理模块应该忽略的文件
transformIgnorePatterns: [
'[/\\\\]node_modules[/\\\\].+\\.(js|jsx|ts|tsx)$',
'^.+\\.module\\.(css|sass|scss)$'
],
// 指定转换器: js jsx ts tsx 使用 babel-jest 进行转换 css 使用 cssTransform.js 进行转换 其他文件使用 fileTransform.js
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'
},
// 转化器忽略文件: node_modules 目录下的所有 js jsx ts tsx cssModule 中的所有 css sass scss
transformIgnorePatterns: [
'[/\\\\]node_modules[/\\\\].+\\.(js|jsx|ts|tsx)$',
'^.+\\.module\\.(css|sass|scss)$'
],
// 测试超时时间,默认 5000 毫秒
testTimeout: 5000
Practice and Debugging
When a test fails, you should first check whether running the test case alone failed. Just
test
change totest.only
.
// 编写一条测试
test(describe, () => {
render(<Component />);
expect();
})
// describe 把多条测试包裹起来进行分组
describe(describe, () => {
test('test1', fn);
test('test2', fn);
test.only('test3', fn); // 再次运行测试,便只会对该单列进行测试
})
When you need to view the found Dom element in the terminal, use prettyDOM to include the element, and you can browse the results close to the HTML structure. When null is passed, prettyDOM returns the rendering result of the entire document.
const div = container.querySelector('div');
console.log(prettyDOM(div));
DemoDemo
Repository: React Testing Library
Project preparation
npm i create-react-app -g // 全局安装脚手架
create-react-app React-Testing-Library // 创建项目
yarn add @types/react @types/react-dom --dev // 基于 TypeScript
yarn add axios // 使用 axios 处理异步请求
Projects created using create-react-app
scaffolding already use the Testing Library as the test solution by default, but the scaffolding hides some configurations of the tool by default. If you want to pop up the configuration and configure it manually, run it npm run eject
. After the engineering configuration pops up, you only need to pay attention jest
to bable
the two configurations. First add these two files jest.config.js
and in the root directory, and then migrate the configuration of the corresponding location in . (jest.config.js is redefined according to the configuration instructions, questions? )bable.config.js
package.json
// jest.config.js
const Config = {
bail: 1,
clearMocks: true,
coverageProvider: 'v8',
moduleFileExtensions: ['js', 'jsx', 'ts', 'tsx', 'json'],
rootDir: './',
setupFilesAfterEnv: ['<rootDir>/src/tests/setupTests.js'],
testEnvironment: 'jsdom',
testMatch: ['<rootDir>/src/**/__tests__/**/*.[jt]s?(x)'],
testPathIgnorePatterns: [
'\\\\node_modules\\\\',
],
moduleNameMapper: {
"\\.(css|less|scss|sss|styl)$": "identity-obj-proxy"
}
};
module.exports = Config;
Create tests
the folder, add setupTests.js
the files, and then jest.config.js
add the setupFilesAfterEnv configuration to override it.
// setupTest.js
// 导入 extend-expect 目的是 rtl 的一些断言需要建立在这个库上,如 toBeInTheDocument
import '@testing-library/jest-dom/extend-expect';
// jest.config.js
setupFilesAfterEnv: ['<rootDir>/src/tests/setupTests.js']
Create a new components
folder and add Header
two List
components.
Header
: Enter toDo content in the input box and press Enter to add it to List
the component.
List
: A list showing all ToDo items, which can be edited or deleted with one click.
(The specific component functional structures are not listed here one by one)
Conduct testing
demand analysis
-
Header component
input
The initial input box is emptyinput
Can input contentinput
Enter to submit contentinput
The input box is left blank after submission
-
List component
-
The list is empty and the counter value in the upper right corner is 0
-
The list is not empty. The counter in the upper right corner exists and the value is the length of the list. The delete button for the list item exists. Click to delete it.
-
The list is not empty. Click the content of the list item to modify it. Press Enter to save the modified content.
-
test writing
Add a directory under the corresponding component __tests__
and create *.test.[jt]s?(x)
a test case in the format.
// header.test.tsx
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import Header from '../index';
let inputNode: HTMLInputElement;
const addUndoItem = jest.fn();
const inputEvent = {
target: {
value: 'new todo'
}
}
beforeEach(() => {
render(<Header addUndoItem={addUndoItem} />);
inputNode = screen.getByRole('textbox') as HTMLInputElement;
});
describe('测试 Header 组件', () => {
it('初始渲染时输入框为空', () => {
expect(inputNode).toBeInTheDocument();
expect(inputNode.value).toEqual('');
})
it('输入框能输入内容', () => {
fireEvent.change(inputNode, inputEvent);
expect(inputNode.value).toEqual('new todo');
})
it('输入框回车提交内容并将输入框置空', () => {
fireEvent.change(inputNode, inputEvent);
const enterEvent = {
keyCode: 13
}
fireEvent.keyUp(inputNode, enterEvent);
expect(addUndoItem).toHaveBeenCalledTimes(1);
expect(inputNode.value).toEqual('');
})
})
// list.test.tsx
import React from 'react';
import { render, screen, fireEvent, within } from '@testing-library/react';
import List, { IListProps } from '../index';
const props: IListProps = {
list: [{
value: 'list item'
}],
deleteItem: jest.fn(),
valueChange: jest.fn(),
handleFinish: jest.fn()
}
let Counter: HTMLElement;
let listItemNode: HTMLElement[];
beforeEach(() => {
render(<List {...props}/>);
listItemNode = screen.queryAllByRole('listitem') as HTMLElement[];
});
describe('测试 List 组件', () => {
it('初始渲染', () => {
// 设置空白数据
let {
deleteItem,
valueChange,
handleFinish
} = props;
let blank = {
list: [],
deleteItem,
valueChange,
handleFinish
}
// 此处重新渲染从中取出 container 容器
const { container } = render(<List {...blank}/>);
const li = container.querySelector('li');
// 初始渲染时列表项内容为空,计数器值为 0
expect(li).toBeNull();
Counter = screen.getByText(/0/i);
expect(Counter).toBeInTheDocument();
})
it('删除列表项', () => {
// 列表项不为空时,右上角计数器存在且值为列表长度
expect(listItemNode).toHaveLength(1);
Counter = screen.getByText(/1/i);
expect(Counter).toBeInTheDocument();
// 列表项删除按钮存在,点击将其删除
let deleteBtn = listItemNode[0].querySelector('div') as HTMLElement;
expect(deleteBtn).not.toBeNull();
fireEvent.click(deleteBtn);
expect(props.deleteItem).toHaveBeenCalledTimes(1);
})
it('编辑列表项', () => {
// 点击列表项后可将其内容修改
fireEvent.click(listItemNode[0]);
let editInput = within(listItemNode[0]).getByRole('textbox') as HTMLInputElement;
const editValue = {
target: {
value: 'edit todo'
}
}
// 检查修改回调调用次数
fireEvent.change(editInput, editValue);
expect(props.valueChange).toHaveBeenCalledTimes(1);
const keyUp = {
keyCode: 13
}
// handleFinish 在输入框失去焦点和回车确认时触发事件
fireEvent.keyUp(editInput, keyUp);
// 注意,toHaveBeenCalledTimes 断言是记录历史调用次数,
// 所有在这里想要判断 valueChange 不被调用,参数应该为 1 而不是 0
expect(props.valueChange).toHaveBeenCalledTimes(1);
// change 直接修改 input 的 value 值,不触发失焦事件
expect(props.handleFinish).toHaveBeenCalledTimes(1);
})
})
What needs to be noted when writing tests is that if the update action of the component is handled externally, you cannot expect to get the same feedback as in the business after performing certain operations. You should verify its function by testing the response of the callback function. feasibility.
Other scenes
Asynchronous operations
For the response event or that the test needs to wait for Promise
, use await
or then
to process.
// 场景一
// 点击按钮后异步更新其 textContent 文本,可以借助 findBy 异步查询 API 查找需要等待更新的元素
const button = screen.getByRole('button', {name: 'Click Me'})
fireEvent.click(button)
await screen.findByText('Clicked once')
fireEvent.click(button)
await screen.findByText('Clicked twice')
// 场景二
// 需要等待回调函数的结果,使用 waitFor 对断言进行判断
await waitFor(() => expect(mockAPI).toHaveBeenCalledTimes(1))
// 场景三
// 需要等待从 DOM 中删除元素
waitForElementToBeRemoved(document.querySelector('div.getOuttaHere')).then(() =>
console.log('Element no longer in DOM'),
)
Rudux test
For particularly complex ones redux
, you can choose to use basic unit tests for reducer
and . effect
In more scenarios, redux connect
use the components of 集成测试
. It can be passed explicitly mock store
or wrapped using Redux Provider
a component.
Another test solution is to test the components separately , import the components that redux
are not connected separately , and use the method to simulate their test responsiveness.connect
Mock
dispatch
Testing Redux-connected components
// 建立带有 redux 测试的自定义 render, 后续测试集成组件便可以使用该自定义 render
// test-utils.jsx
import React from 'react'
import {
render as rtlRender } from '@testing-library/react'
import {
createStore } from 'redux'
import {
Provider } from 'react-redux'
// Import your own reducer
import reducer from '../reducer'
function render(
ui,
{
initialState,
store = createStore(reducer, initialState),
...renderOptions
} = {
}
) {
function Wrapper({
children }) {
return <Provider store={
store}>{
children}</Provider>
}
return rtlRender(ui, {
wrapper: Wrapper, ...renderOptions })
}
// re-export everything
export * from '@testing-library/react'
// override render method
export {
render }
Question collection
-
formatMessage not initialized yet, you should use it after react app mounted #2156.
-
Could not find required
intl
object. needs to exist in the component ancestry. Using default message as fallback
References
Testing Library official website documentation