前端要不要写单测
- 不要问,问就是写
思考
测试的颗粒度?
- 找核心逻辑,不要试图一个test测试一个大而复杂的模块
- 合理利用 mock,擦除一些不必要的逻辑(不要局限于三方依赖)
哪些类型的测试?
- utils
- hooks
- ui
- 组件状态(表单组件)实质上是组件交互的后状态(表单值)的改变是否符合预期
测试 - 可用组合插件
- 核心Jest
- @testing-library/react
- @testing-library/react-hooks
- React-test-renderer
Jest 几个常用配置
// 配置webpack alias
moduleNameMapper: {
'@/(.*)$': '<rootDir>/$1',
},
// 用于测试的测试环境。
testEnvironment: 'jsdom',
Mock:
全局模块mock
// 在配置中rootDir下创建 __mocks__ 文件
rootDir: path.join(__dirname, 'src'),
- 例子 Mock umi 的多语言模块
export const useIntl = () => {
return {
formatMessage: (params: { id: string }) => params.id,
};
};
export const setLocale = (str: string) => str;
export const FormattedMessage = ({ id }: { id: string }) => {
return id;
};
在当前mock某模块
jest.mock('src/utils/ad-plan/creative', () => ({
getValidatedFormResult: () => ({
errorFormIdxs: 0,
errorFormsInfo: [],
successFormsValuesArr: [],
}),
}));
在当前mock某模块 jest.spyOn,动态设置返回
let validateRes = {
errorFormIdxs: [] as number[],
errorFormsInfo: [] as ValidateErrorEntity[],
successFormsValuesArr: [] as ICreativeFormValues[],
};
jest
.spyOn(utils, 'getValidatedFormResult')
.mockImplementation(() => Promise.resolve(validateRes));
Mock callback
模拟函数 · Jest
const mockFn = jest.fn();
// 断言mockFn被调用
expect(mockFn).toBeCalled();
// 断言mockFn被调用了一次
expect(mockFn).toBeCalledTimes(1);
// 断言mockFn调用次数中某一次含有传入的参数为1, 2, 3
expect(mockFn).toHaveBeenCalledWith(1, 2, 3);
测试样例
纯函数测试
- 纯函数是指不依赖于 且 不改变 它作用域之外的变量状态的函数。
纯函数测试,做好入参和断言即可
import { getObjectFromArray, getParamsString, replaceTime } from './index';
describe('utils', () => {
test('getObjectFromArray', () => {
expect(
getObjectFromArray([
{
label: '1',
value: '1',
},
{
label: '2',
value: '2',
},
]),
).toEqual({
'1': {
label: '1',
value: '1',
},
'2': {
label: '2',
value: '2',
},
});
expect(
getObjectFromArray(
[
{
label: 'l1',
value: 'v1',
},
{
label: 'l2',
value: 'v2',
},
],
'label',
),
).toEqual({
l1: {
label: 'l1',
value: 'v1',
},
l2: {
label: 'l2',
value: 'v2',
},
});
});
test('getParamsString', () => {
const data = {
test1: '1',
test2: 2,
test3: {
a: 1,
b: 2,
},
test4: [1, 2, 3, 4, 5],
};
expect(getParamsString(data)).toEqual({
test1: '1',
test2: 2,
test3: JSON.stringify(data.test3),
test4: JSON.stringify(data.test4),
});
});
test('replaceTime', () => {
expect(replaceTime('2021-15-00 Etc/GMT+1')).toBe('2021-15-00 UTC-1');
expect(replaceTime('2021-15-00 Etc/GMT-1')).toBe('2021-15-00 UTC+1');
expect(replaceTime('')).toBe('-');
});
});
hooks测试
- 核心Api renderHook、act
- Demo - 多语言的切换
import { useState, useEffect, useCallback } from 'react';
import Cookies from 'js-cookie';
import { setLocale } from 'umi';
import {
LANG_MAP,
UMI_LANG_MAP,
II18nLang,
defaultLocale,
defaultUmiLang,
IUmiI18nLang,
} from '@/utils/i18n';
const initLang =
LANG_MAP[Cookies.get('lang_type') as II18nLang] || defaultUmiLang;
setLocale(initLang, false); // 先更新下多语言,避免 localStorage 存储了一个不符合预期的兜底
export function useLanguage() {
const [curLanguage, setCurLanguage] = useState<IUmiI18nLang>(initLang);
const setLanguage = useCallback((lang: II18nLang) => {
const nextCurLang = LANG_MAP[lang] || defaultUmiLang;
setCurLanguage(nextCurLang);
setLocale(nextCurLang, false);
Cookies.set('lang_type', UMI_LANG_MAP[nextCurLang] || defaultLocale);
}, []);
useEffect(() => {
window.setCurLanguage = setLanguage;
}, []);
return {
curLanguage,
setCurLanguage: setLanguage,
};
}
- 测试用例
import { renderHook, act } from '@testing-library/react-hooks';
import { LANG_MAP } from '@/utils/i18n';
import { useLanguage } from './useLanguage';
describe('useLanguage test', () => {
test('useLanguage set language', () => {
const { result } = renderHook(() => useLanguage());
expect(result.current.curLanguage).toBe(LANG_MAP.en);
act(() => result.current.setCurLanguage('xx' as any));
expect(result.current.curLanguage).toBe(LANG_MAP.en);
act(() => result.current.setCurLanguage('ja'));
expect(result.current.curLanguage).toBe(LANG_MAP.ja);
});
});
组件测试
- 查找节点
// About Queries | Testing Library
<div data-testid="jest-cascade-panel">......</div>
screen.getByTestId('jest-cascade-panel')
fireEvent 事件触发
Firing Events | Testing Library fireEvent[eventName](node: HTMLElement, eventProperties: Object) fireEvent.click(screen.getByTestId('jest-cascade-panel'));
异步渲染等待
Async Methods | Testing Library function waitFor<T>( callback: () => T | Promise<T>, options?: { container?: HTMLElement timeout?: number interval?: number onTimeout?: (error: Error) => Error mutationObserverOptions?: MutationObserverInit } ): Promise<T> waitFor(() => screen.getByTestId('jest-cascade-panel'));
context (官网demo copy)
import React from 'react'
import { render, screen } from '@testing-library/react'
import '@testing-library/jest-dom/extend-expect'
import { NameContext, NameProvider, NameConsumer } from '../react-context'
/**
* Test default values by rendering a context consumer without a
* matching provider
*/
test('NameConsumer shows default value', () => {
render(<NameConsumer />)
expect(screen.getByText(/^My Name Is:/)).toHaveTextContent(
'My Name Is: Unknown'
)
})
/**
* A custom render to setup providers. Extends regular
* render options with `providerProps` to allow injecting
* different scenarios to test with.
*
* @see https://testing-library.com/docs/react-testing-library/setup#custom-render
*/
const customRender = (ui, { providerProps, ...renderOptions }) => {
return render(
<NameContext.Provider {...providerProps}>{ui}</NameContext.Provider>,
renderOptions
)
}
test('NameConsumer shows value from provider', () => {
const providerProps = {
value: 'C3PO',
}
customRender(<NameConsumer />, { providerProps })
expect(screen.getByText(/^My Name Is:/)).toHaveTextContent('My Name Is: C3P0')
})
/**
* To test a component that provides a context value, render a matching
* consumer as the child
*/
test('NameProvider composes full name from first, last', () => {
const providerProps = {
first: 'Boba',
last: 'Fett',
}
customRender(
<NameContext.Consumer>
{(value) => <span>Received: {value}</span>}
</NameContext.Consumer>,
{ providerProps }
)
expect(screen.getByText(/^Received:/).textContent).toBe('Received: Boba Fett')
})
/**
* A tree containing both a providers and consumer can be rendered normally
*/
test('NameProvider/Consumer shows name of character', () => {
const wrapper = ({ children }) => (
<NameProvider first="Leia" last="Organa">
{children}
</NameProvider>
)
render(<NameConsumer />, { wrapper })
expect(screen.getByText(/^My Name Is:/).textContent).toBe(
'My Name Is: Leia Organa'
)
})
快照测试
- 当你想要确保你的UI不会有意外的改变,快照测试是非常有用的工具。
- 对固定配置项-防止意外修改
测试原理
- 快照测试第一次运行的时候会将 React 组件在不同情况下的渲染结果(挂载前)保存一份快照文件。后面每次再运行快照测试时,都会和第一次的比较,使用 jest - u 命令重新生成快照文件。
jest.config 配置
- snapshotResolver
- 配置选项允许您自定义Jest在磁盘上存储快照文件的位置。
- 规范 : resolveTestPath(resolveSnapshotPath(testPathForConsistencyCheck)) = testPathForConsistencyCheck
// jest.config.js 配置
snapshotResolver: path.join(__dirname, 'snapshotResolver.js'),
// snapshotResolver.js
module.exports = {
// resolves from test to snapshot path
resolveSnapshotPath: (testPath, snapshotExtension) => {
console.log(`resolveSnapshotPath => ${testPath}、${snapshotExtension}`);
// const path = testPath.replace(
// /\.test\.([tj]sx?)/,
// `$1.${snapshotExtension}`,
// );
const path = `${testPath}${snapshotExtension}`;
console.log(path);
return path;
},
// resolves from snapshot to test path
resolveTestPath: (snapshotFilePath, snapshotExtension) => {
console.log(`resolveTestPath => ${snapshotFilePath}、${snapshotExtension}`);
return snapshotFilePath.replace(snapshotExtension, '');
},
// Example test path, used for preflight consistency check of the implementation above
testPathForConsistencyCheck: 'some/example.test.js',
};
- snapshotSerializers (序列化快照结果)
- A list of paths to snapshot serializer modules Jest should use for snapshot testing.
Jest has default serializers for built-in JavaScript types, HTML elements (Jest 20.0.0+), ImmutableJS (Jest 20.0.0+) and for React elements. See snapshot test tutorial for more information.
- 个人觉得对组件化测试的意义不大
- 可以用做配置等测试
// jest.config.js 配置
snapshotSerializers: [path.join(__dirname, 'snapshotSerializers.js')],
// snapshotSerializers.js
module.exports = {
serialize(val, config, indentation, depth, refs, printer) {
console.log(val);
return 'Pretty test: ' + printer(val);
},
test(val) {
console.log(val);
return val;
},
};
快照测试 API
toMatchSnapshot 生成快照文件
// Jest Snapshot v1, https://goo.gl/fbAQLP exports[` 1`] = ` <body> <div> <button type="button" > test </button> </div> </body> `;
toMatchInlineSnapshot 在测试文件行内生成快照
expect(dom.baseElement).toMatchInlineSnapshot(`<body> <div> <button type="button" > test </button> </div> </body>`);
快照测试DEMO
LoadingButton 组件,点击后按钮展示一个小loading
import React, { useCallback } from 'react'; import { Button, ButtonType } from '@byte-design/ui'; import { useStateIfMounted } from '@/hooks'; interface IProps { children: React.ReactNode; onClick?: () => Promise<void>; type?: ButtonType; loadingColor?: string; className?: string; disabled?: boolean; disabledHideContent?: boolean; } export const LoadingButton = (props: IProps) => { const { children, onClick, type = 'primary', className = '', disabled = false, disabledHideContent = false, } = props; const { state: loading, setState: setLoading } = useStateIfMounted(false); const handleClick = useCallback(() => { if (loading) return; if (onClick) { setLoading(true); onClick().finally(() => { setLoading(false); }); } }, [loading, onClick, setLoading]); return ( <Button type={type} className={className} onClick={handleClick} loading={loading} disabled={loading || disabled} > {!(loading && disabledHideContent) && children} </Button> ); };
- 测试用例
- react-test-renderer 便于处理event
import React from 'react';
// import { render, screen, fireEvent } from '@testing-library/react';
import renderer from 'react-test-renderer';
import { LoadingButton } from './index';
describe('LoadingButton', () => {
test('LoadingButton snapshot', () => {
const promise = Promise.resolve();
const fn = jest.fn(() => promise);
const dom = renderer.create(
<LoadingButton onClick={fn}>test</LoadingButton>,
);
let snapshot: any = dom.toJSON();
expect(snapshot).toMatchSnapshot();
snapshot.props.onClick();
snapshot = dom.toJSON();
expect(snapshot).toMatchSnapshot();
await act(() => promise);
});
});
@testing-library/react
import React from 'react'; // import renderer from 'react-test-renderer'; import { render, fireEvent } from '@testing-library/react'; import { LoadingButton } from './index'; describe('LoadingButton', () => { const text = 'text'; test('snapshot', () => { const promise = Promise.resolve(); const fn = jest.fn(() => promise); const { asFragment, getByText } = render( <LoadingButton onClick={fn}>{text}</LoadingButton>, ); const dom = asFragment(); expect(dom).toMatchSnapshot(); fireEvent.click(getByText(text)); expect(asFragment()).toMatchSnapshot(); await act(() => promise); }); });
- asFragment:
- render 返回
container: HTMLDivElement
baseElement: HTMLBodyElement {},
debug: [Function: debug],
unmount: [Function: unmount],
rerender: [Function: rerender],
asFragment: [Function: asFragment],
queryAllByLabelText: [Function: bound ],
queryByLabelText: [Function: bound ],
getAllByLabelText: [Function: bound ],
getByLabelText: [Function: bound ],
findAllByLabelText: [Function: bound ],
findByLabelText: [Function: bound ],
queryByPlaceholderText: [Function: bound ],
queryAllByPlaceholderText: [Function: bound ],
getByPlaceholderText: [Function: bound ],
getAllByPlaceholderText: [Function: bound ],
findAllByPlaceholderText: [Function: bound ],
findByPlaceholderText: [Function: bound ],
queryByText: [Function: bound ],
queryAllByText: [Function: bound ],
getByText: [Function: bound ],
getAllByText: [Function: bound ],
findAllByText: [Function: bound ],
findByText: [Function: bound ],
queryByDisplayValue: [Function: bound ],
queryAllByDisplayValue: [Function: bound ],
getByDisplayValue: [Function: bound ],
getAllByDisplayValue: [Function: bound ],
findAllByDisplayValue: [Function: bound ],
findByDisplayValue: [Function: bound ],
queryByAltText: [Function: bound ],
queryAllByAltText: [Function: bound ],
getByAltText: [Function: bound ],
getAllByAltText: [Function: bound ],
findAllByAltText: [Function: bound ],
findByAltText: [Function: bound ],
queryByTitle: [Function: bound ],
queryAllByTitle: [Function: bound ],
getByTitle: [Function: bound ],
getAllByTitle: [Function: bound ],
findAllByTitle: [Function: bound ],
findByTitle: [Function: bound ],
queryByRole: [Function: bound ],
queryAllByRole: [Function: bound ],
getAllByRole: [Function: bound ],
getByRole: [Function: bound ],
findAllByRole: [Function: bound ],
findByRole: [Function: bound ],
queryByTestId: [Function: bound ],
queryAllByTestId: [Function: bound ],
getByTestId: [Function: bound ],
getAllByTestId: [Function: bound ],
findAllByTestId: [Function: bound ],
findByTestId: [Function: bound ]
生成快照
exports[`LoadingButton LoadingButton snapshot 1`] = ` <button className="byted-btn byted-btn-size-md byted-btn-type-primary byted-btn-shape-angle byted-can-input-grouped" disabled={false} onClick={[Function]} style={ Object { "display": undefined, } } type="button" > test </button> `; exports[`LoadingButton LoadingButton snapshot 2`] = ` <button className="byted-btn byted-btn-disabled byted-btn-size-md byted-btn-type-primary byted-btn-shape-angle byted-can-input-grouped byted-btn-loading" disabled={true} onClick={[Function]} style={ Object { "display": undefined, } } type="button" > <span className="byted-btn-loading-icon" > <svg display="block" height={14} preserveAspectRatio="xMidYMid" style={ Object { "background": "0 0", "margin": "auto", } } viewBox="0 0 100 100" width={14} > <rect fill="#333" height={24} rx={4} ry={5.76} width={8} x={46} y={6} /> <rect fill="#333" height={24} rx={4} ry={5.76} transform="rotate(45 50 50)" width={8} x={46} y={6} /> <rect fill="#333" height={24} rx={4} ry={5.76} transform="rotate(90 50 50)" width={8} x={46} y={6} /> <rect fill="#333" height={24} rx={4} ry={5.76} transform="rotate(135 50 50)" width={8} x={46} y={6} /> <rect fill="#333" height={24} rx={4} ry={5.76} transform="rotate(180 50 50)" width={8} x={46} y={6} /> <rect fill="#333" height={24} rx={4} ry={5.76} transform="rotate(225 50 50)" width={8} x={46} y={6} /> <rect fill="#333" height={24} rx={4} ry={5.76} transform="rotate(270 50 50)" width={8} x={46} y={6} /> <rect fill="#333" height={24} rx={4} ry={5.76} transform="rotate(315 50 50)" width={8} x={46} y={6} /> </svg> </span> test </button> `;
常见问题
css module 问题
- 组件测试 - 需要通过 class 获取dom,受 css module 影响无法生成classname. 可以用这个库
https://github.com/keyz/ident...
异步报警
https://kentcdodds.com/blog/f...
Cannot use import statement outside a module
jest 官方文档配置的 babel.config.js 默认只会编译业务代码,
jest 不会对 node modules 内的文件编译,故配置 babel 并不能直接解决该问题。
https://juejin.cn/post/703262...
测试覆盖率计算
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。