前端单元测试:jest 与 react 使用,学习

为什么要学习单元测试,如何测试 react的组件
更新于: 2022-05-03 23:35:54

视频列表

为什么写测试

  • 让代码更加的健壮
  • 让代码重构得更好
  • 不会写测试的原因:自己的代码不能测试
    • 这个时候,就需要先重构你的代码
    • 让自己的代码可以测试
  • 形成良好的代码书写习惯
-单元测试/集成测试/e2e测试

几种测试的关系

  • 单元测试: 应该是最多的,也是最简单的
  • 集成测试: 组合单元测试
  • E2E测试:成本最高,但很重要

#00 熟悉工具箱jest 指令

  • jest: 启动测试
  • jest --watch: 各种指令
  • 测试单个文件:npx jest ./src/2022-05/01-jest-watch/01.spec.js

几个官方参考文档

 › Press a to run all tests.
 › Press f to run only failed tests.
 › Press p to filter by a filename regex pattern.
 › Press t to filter by a test name regex pattern.
 › Press q to quit watch mode.
 › Press Enter to trigger a test run.

前端單元測試 #01 第一個測試、快照

  • 搭建一个简单的 react 项目
  • 添加 jest 以及 其它 测试套件
  • 参考 这里
  • cra 的 test套件,参考这里

小插曲,直接运行 npm run test有报错

githube有人讨论: https://github.com/facebook/create-react-app/issues/11792

解决方案:npm i -D jest-watch-typeahead@0.6.5

Error: Failed to initialize watch plugin "node_modules/jest-watch-typeahead/filename.js":

  ● Test suite failed to run

    file:///Users/aric.zheng/aric-notes/jest-notes/src/2022-05/02-react-cra/node_modules/jest-watch-typeahead/build/file_name_plugin/prompt.js:4
    import { PatternPrompt, printPatternCaret, printRestoredPatternCaret } from 'jest-watcher';
                            ^^^^^^^^^^^^^^^^^
    SyntaxError: Named export 'printPatternCaret' not found. The requested module 'jest-watcher' is a CommonJS module, which may not support all module.exports as named exports.
    CommonJS modules can always be imported via the default export, for example using:

    import pkg from 'jest-watcher';
    const { PatternPrompt, printPatternCaret, printRestoredPatternCaret } = pkg;

      at async requireOrImportModule (node_modules/jest-util/build/requireOrImportModule.js:65:32)

error Command failed with exit code 1.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.

开始测试之前,还要添加如下包<React CRA官方已经带上了>

touch ./src/setupTests.js

# 添加包
npm i -D @testing-library/react @testing-library/jest-dom
# 准备setupTests.js 文件
import '@testing-library/jest-dom';

第一个 jest + react

  • screen.getByXX
  • toHave/toBe
import { render, screen } from '@testing-library/react';
import { Btn } from './btn';

test('btn should have hi text', () => {
  render(<Btn />);
  // screen.debug();
  expect(screen.getByText('hi')).toBeInTheDocument();
  expect(screen.getByText('hi')).toBeVisible();
  expect(screen.getByRole('button')).toHaveTextContent('hi');
});

snapshot 测试

  • 对于少互动的情况,可以直接用 snapshot
  • 测试的时候,按 u 更新快照
  • 两个关键方法
    • toMatchSnapshot
    • toMatchInlineSnapshot<这个有点问题>

前端單元測試 #02 測試模板、props(AAA的思路)

  • 准备属性 Arrange-准备,安排一些数据
  • 执行 render Act:执行一些操作
  • 验证结果 Assert:验证结果
  • AAA模板如下
import { render, screen, fireEvent } from '@testing-library/react';
import { Button } from './04';
import { act } from 'react-dom/test-utils';

test('btn should have hi text', () => {
  // 准备 Arrange

  // 执行 Act

  // 断言 Assert
});
import { render, screen } from '@testing-library/react';
import { BtnProps } from './btn-props';

test('btn should have hi text', () => {
  // 准备 content
  const content = 'ABC';
  // 执行 render
  render(<BtnProps content={content} />);
  // 验证
  expect(screen.getByText(content)).toBeInTheDocument();
});

前端單元測試 #03 mock function、fireEvent

  • 准备一个mock的方法:jest.fn()
  • fireEvent:执行事件(userEvent也是同样的效果)
    • userEvent 会更从 user 的角度(mousedown代替也会成功) – more friendly
    • fireEvent 如果用 mouseDown代替了onClick 就会触发失败
  • toHaveBeenCalledTimes(1): 验证执行次数
import { render, screen, fireEvent } from '@testing-library/react';
import { Btn } from './03';

test('btn should have hi text', () => {
  // 准备 Arrange
  const content = 'ABC';
  const handleClick = jest.fn();
  
  // 执行 Act
  render(<Btn content={content} onClick={handleClick} />);
  fireEvent.click(screen.getByText('ABC'));
  
  // 验证 Assert
  expect(handleClick).toHaveBeenCalledTimes(1);
});

前端單元測試 #04 mock 時間、act

  • 时间: jest.runAllTimers()、jest.advanceTimersByTime(3000);
  • act:针对有副作用 sideEffect 的场景
import { render, screen, fireEvent } from '@testing-library/react';
import { Button } from './04';
import { act } from 'react-dom/test-utils';

test('btn should have hi text', () => {
  // 准备 Arrange
  jest.useFakeTimers();

  // 执行 Act
  render(<Button />);
  fireEvent.click(screen.getByText('你好'));

  // 验证 Assert
  expect(screen.getByText('你好')).toBeDisabled();
  act(() => {
    // 等所有的 timer 运行完
    // jest.runAllTimers();
    // 精确运行3s
    jest.advanceTimersByTime(3000);
  });
  expect(screen.getByText('你好')).toBeEnabled();
});

前端單元測試 #05 styled components、debug

  • snapshot的时候,如果遇到 styled-component 这种情况,需要用 jest-styled-componet
import { render } from '@testing-library/react';
import { Button } from '../components/05';

test('btn should have hi text', () => {
  // 准备 Arrange

  // 执行 Act
  const { container } = render(<Button />);

  // 断言 Assert
  expect(container).toMatchSnapshot();
});
styled生成的组件 ,稍作修改无法经过测试

先安装这个包

// 1. 安装包
npm i -D jest-styled-components
npm i -S styled-components
// 2. 添加进入 setup.jest.js
添加 jest-styled-components
添加 jest-styled-components 运行测试效果如图

 

前端單元測試 #06 subtest、test each

  • test.each:特别适合,不同的参数,场景类似;
    • 可以数组传参
    • 可以对象传参
  • describe/test: 这个是手动分组
  • 可以用3种形式数据源
    • array
    • object
    • markdown
// 用 test.each 来批量测试
describe.each([
  ['A', 'eb-bg-green-500'],
  ['B', 'eb-bg-blue-500'],
  ['C', 'eb-bg-red-500'],
  ['D', 'other'],
])('btn should have diff style when diff type', (type, className) => {
  // 准备 Arrange
  const t = type;
  const c = className;

  // 执行 Act

  // 断言 Assert
  test(`t will get style: ${c}`, () => {
    render(<Button type={t}>{t}</Button>);
    expect(screen.getByText('Hi')).toHaveClass(c);
  });
});
// test.each use object as data source
describe.each([
  { type: 'A', className: 'eb-bg-green-500' },
  { type: 'B', className: 'eb-bg-blue-500' },
  { type: 'C', className: 'eb-bg-red-500' },
  { type: 'D', className: 'other' },
])('btn should have diff style when diff type', ({ type, className }) => {
  // 准备 Arrange
  const t = type;
  const c = className;

  // 执行 Act

  // 断言 Assert
  test(`t will get style: ${c}`, () => {
    render(<Button type={t}>{t}</Button>);
    expect(screen.getByText('Hi')).toHaveClass(c);
  });
});
// test.each use markdown string as data source
describe.each`
  type | className
  ${'A'} | ${'eb-bg-green-500'}
  ${'B'} | ${'eb-bg-blue-500'}
  ${'C'} | ${'eb-bg-red-500'}
  ${'D'} | ${'other'}
`('markdown as ds: btn should have diff style when diff type', ({ type, className }) => {
  // 准备 Arrange
  const t = type;
  const c = className;

  // 执行 Act

  // 断言 Assert
  test(`t will get style: ${c}`, () => {
    render(<Button type={t}>{t}</Button>);
    expect(screen.getByText('Hi')).toHaveClass(c);
  });
});

 

前端單元測試 #07 mock api、waitFor

  • msw: 拦截api,可以用测试、开发
  • await findByText === (getByText + waitFor)

小知识 msw(一种可以用于开发/测试的库)

  • Mock Server Worker
  • 拦截网络请求用的
  • https://mswjs.io/
  • jest 会利用 msw node 的server 来完成
// src/setupTests.js
// eslint-disable-next-line jest/no-mocks-import
import { server } from '../__mocks__/server.js';
import { Button } from '../components/07';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { rest } from 'msw';

// Establish API mocking before all tests.
beforeAll(() => server.listen());

// Reset any request handlers that we may add during the tests,
// so they don't affect other tests.
afterEach(() => server.resetHandlers());

// Clean up after the tests are finished.
afterAll(() => server.close());


test('api sucess, will get item1/2/3', async () => {
  //  https://api.uomg.com/api
  render(<Button />);
  userEvent.click(screen.getByText('請按'));
  expect(screen.getByText('Loading........')).toBeInTheDocument();
  await waitFor(() => expect(screen.getByText('item1')).toBeInTheDocument());
  await waitFor(() => expect(screen.getByText('item2')).toBeInTheDocument());
  await waitFor(() => expect(screen.getByText('item3')).toBeInTheDocument());
});

test.only('api failed', async () => {
  // mock server error
  server.use(
    rest.get('http://my-backend/fake-data', (req, res, ctx) => {
      return res(ctx.status(500), ctx.json('ERROR_MSG'));
    }),
  );
  render(<Button />);
  userEvent.click(screen.getByText('請按'));
  // await waitFor(() => expect(screen.getByText('ERROR_MSG')).toBeInTheDocument());
  //   screen.debug();
  expect(await screen.findByText('ERROR_MSG')).toBeInTheDocument();
});

前端單元測試 #08 react hook

  • 解决的问题:useHook 不用先用一个组件来测试
  • 直接用 renderHook 来完成
  • 重点是这一句:  const { result } = renderHook(() => useCounter());
import { render, screen, fireEvent, renderHook } from '@testing-library/react';
import DemoCounter, { useCounter } from '../components/08';
import { act } from 'react-dom/test-utils';

test('counter hook testing', () => {
  // 准备 Arrange

  // 执行 Act
  const { result } = renderHook(() => useCounter());

  act(() => {
    result.current.increment();
  });

  // 断言 Assert
  expect(result.current.count).toBe(1);
  act(() => {
    result.current.increment();
    result.current.increment();
  });
  expect(result.current.count).toBe(3);
});

前端單元測試 #09 mock component、整合測試

  • mock 一个组件 jest.mock
  • mock Math.random 函数 jest.spyOn(global.Math, 'random').mockReturnValue(0.1);
  • jest.spy 针对特定的方法,进行mock
jest.mock('../components/09/Trade', () => () => 'FakeTradeComponent123');
import { screen, render } from '@testing-library/react';
import Trade from '../components/09/Trade';


test('<Trade />', () => {
  // Arrange
  // randomMock = jest.spyOn(global.Math, "random");
  jest.spyOn(global.Math, 'random').mockReturnValue(0.1);
  // jest.spyOn(global.Math, 'floor')

  // Act
  render(<Trade wood={50} />);

  // Assert
  // 😱
  // expect(screen.getByText(/賣完,得到 \$ 7000/i)).toBeInTheDocument();
  expect(screen.getByTestId('sell').textContent).toBe('賣完,得到 $7000');
});

jest 与 react 冲突问题

参考