前端要不要写单测
- 不要问,问就是写
思考
测试的颗粒度?
- 找外围逻辑,不要试图一个test测试一个大而简单的模块
- 正当利用 mock,擦除一些不必要的逻辑(不要局限于三方依赖)
哪些类型的测试?
- utils
- hooks
- ui
- 组件状态(表单组件)本质上是组件交互的后状态(表单值)的扭转是否合乎预期
测试 - 可用组合插件
- 外围Jest
- @testing-library/react
- @testing-library/react-hooks
- React-test-renderer
Jest 几个罕用配置
// 配置webpack aliasmoduleNameMapper: { '@/(.*)$': '<rootDir>/$1',},// 用于测试的测试环境。testEnvironment: 'jsdom',
Mock:
- 原理文档:
https://segmentfault.com/a/11...
https://medium.com/@KumaLi198...
全局模块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
模仿函数 · Jestconst mockFn = jest.fn();// 断言mockFn被调用expect(mockFn).toBeCalled();// 断言mockFn被调用了一次expect(mockFn).toBeCalledTimes(1);// 断言mockFn调用次数中某一次含有传入的参数为1, 2, 3expect(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 LibraryfireEvent[eventName](node: HTMLElement, eventProperties: Object)fireEvent.click(screen.getByTestId('jest-cascade-panel'));
异步渲染期待
Async Methods | Testing Libraryfunction 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.jsmodule.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.jsmodule.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/fbAQLPexports[` 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`] = `<buttonclassName="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`] = `<buttonclassName="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>`;
常见问题:
https://kentcdodds.com/blog/f...
测试覆盖率计算
- https://juejin.cn/post/684490...