React|React 测试

前端要不要写单测

  • 不要问,问就是写
思考
  • 测试的颗粒度?
    • 找核心逻辑,不要试图一个test测试一个大而复杂的模块
    • 合理利用 mock,擦除一些不必要的逻辑(不要局限于三方依赖)
  • 哪些类型的测试?
    • utils
    • hooks
    • ui
    • 组件状态(表单组件)实质上是组件交互的后状态(表单值)的改变是否符合预期
测试 - 可用组合插件
  • 核心Jest
  • @testing-library/react
  • @testing-library/react-hooks
  • React-test-renderer
Jest 几个常用配置
// 配置webpack alias moduleNameMapper: { '@/(.*)$': '/$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
模拟函数 · 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 = https://www.it610.com/article/{ 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(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 ......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( callback: () => T | Promise, options?: { container?: HTMLElement timeout?: number interval?: number onTimeout?: (error: Error) => Error mutationObserverOptions?: MutationObserverInit } ): PromisewaitFor(() => 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() 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( {ui}, renderOptions ) }test('NameConsumer shows value from provider', () => { const providerProps = { value: 'C3PO', } customRender(, { 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( {(value) => Received: {value}} , { 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 }) => ( {children} )render(, { 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/fbAQLPexports[` 1`] = ` `;

    • toMatchInlineSnapshot 在测试文件行内生成快照
      expect(dom.baseElement).toMatchInlineSnapshot(``);

  • 快照测试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; 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 ( ); };

  • 测试用例
  • 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( test, ); 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( {text}, ); 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`] = ` `; exports[`LoadingButton LoadingButton snapshot 2`] = ` `;

常见问题:
https://kentcdodds.com/blog/f...
【React|React 测试】测试覆盖率计算
  • https://juejin.cn/post/684490...

    推荐阅读