2

前端要不要写单测

  • 不要问,问就是写

思考

  • 测试的颗粒度?

    • 找核心逻辑,不要试图一个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 问题

5ff72c01-4c3d-4474-a3f1-76d99553003c.jpeg

image.png

异步报警

image.png

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...

测试覆盖率计算


D_Q_
483 声望12 粉丝

前端萌萌哒