前端要不要写单测

  • 不要问,问就是写

思考

  • 测试的颗粒度?

    • 找外围逻辑,不要试图一个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...