🫠「测试」了解下单元测试

207 阅读21分钟

测试:检测我们的业务是否满足期望。

测试都分为哪些

image.png 单元测试:其中的测试用例是针对代码的各个功能单元,例如我们一个函数、类就是一个功能单元,一个 component 组件也可以是一个功能单元。使用【功能单元】决定代码的细粒度。因为单元测试的定义,所以只靠单元测试是无法将整个功能串联起来。

我们把我们的代码单测类比生活中生产薯条的流水线,需要【清洗】【削皮】【切片】【包装】等步骤,这些步骤都是完全独立的功能单元。

image.png

可能会疑惑,这几个单元都是有依赖关系,为啥说完全独立的呢?

我们在进行【削皮】功能的时候,需要直接给到一个【清洗好了的土豆】。所以单元测试时候,有一个非常重要的点就是【对当前测试单元的外部依赖进行模拟】,不会真正去调用前后的功能单元。

组件测试:通常是QA(测试同学)执行的。组件测试需要一个测试策略和测试计划,我们分别考虑软件的每个部分。我们为每个组件定义了一个测试场景,它进一步分为高级测试用例、带有先决条件的低级详细测试用例。

组件测试和单元测试之间的主要区别

两种测试之间的关键区别在于测试工程师执行组件测试,而开发人员执行单元测试。

让我们了解组件测试和单元测试之间的其他一些关键区别:

  • 组件测试是一种黑盒测试,而单元测试白盒测试的一部分。
  • 组件测试中,相关软件的所有模块/组件都单独检查,无论是否与系统的其他对象或模块隔离。另一方面,如果根据特定要求执行单独的程序或代码,则测试单元测试。
  • 组件测试中,通过验证用例和测试需求来执行测试,而在单元测试中,我们将测试与设计文档相矛盾的应用程序。

集成测试:可以是功能测试,也可以是白盒测试,更多的关注程序模块之间的关系和正确性,关注多个模块集成起来是不是还可以正常工作,模块间的数据会不会丢失等等。

E2E 测试(端到端测试) :更多的是从用户角度来衡量产品质量,它可以是用户接受度测试,黑盒测试。例如要验证你的登录注册的功能,常用的 E2E 框架有 Puppeteer、Cypress、Playwright、Selenium 等。

为什么需要单测

对于 to c 场景下的复杂且安全性较高的业务。

一味的相信单元测试的结果并不是一件好的事情(单测写的有问题 + 业务代码根据符合单测预期并不符合业务的预期)。

一味的相信测试人员,可能又因为对业务的不熟悉覆盖不了全部的测试场景怎么办?

所以,在现有的人肉测试的基础上辅助加以单元测试能够更好的保证我们的业务测试 case 的覆盖率,单测能够快速有效的测试到复杂度更高,case 更多的场景,但是仍然需要测试和开发人员进行把关。

我们可以看下下面这个图,这是我们加上单测之后的一个项目开发工作流。我们在一个项目或者需求的开发提测,验收,乃至正式上线之后都会有 bug 的产生。

image.png

我们单独拉出来,如果我们在开发工作流中越早发现问题,不仅可以省时省力,也能降低方法修改出现的风险跟时间成本,从而减少损失。 越是到后面流程才抛出的 Bug,程序员就越是要投入比开发阶段更大的时间和业务,而且所承受的风险也是最高的。

image.png

也有人可能会说,不就是人力测试的时候,产品验收的时候出个 bug 吗? 我重新改一下不就好了吗。但是在测试阶段或者上线后,你可能已经在另外一个需求评审了,解决 bug 势必会占用你额外的时间,同时解决 bug 的同时同样的可能会引起其他意想不到的 bug 出现。此时你的心里会有很大的压力。

优化流程,提高业务认知度

对于存量业务增加单测,是依据开发对业务本身的理解来增添测试用例的。假如你对业务一知半解,单纯看业务代码进行补充单测,即使能够写出几个单测用例,仍然不能够完全覆盖全面,那么你写的单测就变得很鸡肋,覆盖不了全场景的单测就很无效

补充存量单测的过程中,需要反复同产品,测试,师兄确认逻辑的正确性,做到业务单测的全覆盖。通过补充单测的过程中能够极大的深入业务逻辑,提升业务的自信心。

单测先行,减少 bug 率

对于新业务,或者新功能代码之前,我们可以先编写测试用例代码。在测试用例代码的基础上,再补充业务代码。其实这是敏捷开发的一项核心实践和技术,是一种设计方法论「测试驱动开发 TDD

image.png

举个例子,我们需求评审+交互视觉评审,技术评审完成之后,最终确定了一个逻辑。

提示文案会根据输入金额展示不同逻辑

1 当我们输入的金额小于最小金额,展示【xxx元及以上】

2 当我们输入的金额大于最大金额,展示【最高xxx元】

3 当我们输入金额为空时候,展示【最高xxx元】

4 当xxx的时候,展示【xxx不支持修改】

...

此时我们在项目的开发之前,可以单测先行,根据我们设计好的各种 case ,把单测先写好。

describe('测试输入金额下方提示文案逻辑', () => {
  it('1 当我们输入的金额小于最小金额,展示【xxx元及以上】', () => {
    const amount = '200.00';
    const store = {
      minInstalAmount: '300.00',
    };
    expect(getInputTip(store, amount)).toBe('200.00元');
  });
  it('2 当我们输入的金额大于最大金额,展示【最高xxx元】', () => {
    const amount = '3200.00';
    const store = {
      maxInstalAmount: '3000.00',
    };
    expect(getInputTip(store, amount)).toBe('最高3000.00元');
  });
  it('3 当我们输入金额为空时候,展示【最高xxx元】', () => {
    const amount = '';
    const store = {
      maxInstalAmount: '3000.00',
    };
    expect(getInputTip(store, amount)).toBe('最高3000.00元');
  });
  it('4 当是xxx的时候,展示【xxx不支持修改】', () => {
    const amount = '';
    const store = {
      maxInstalAmount: '3000.00',
      bankMark: 'GDB'
    };
     expect(getInputTip(store, amount)).toBe('xxx不支持修改');
  });
});

根据单测用例的再把业务代码逻辑完善。

const getInputTip = () => {
  ...
}

当然,在写单测之前,一定要和 PD 以及其他开发、测试人员确定好业务逻辑,否则,变动业务逻辑的同时,单测也需要改动,原本很简单事情,写了单测又得重复修改,多做功。

单测提供的功能

如下是个简单的例子。

// production code
const computeSumFromObject = (a, b) => {
  return a.value + b.value
}

// testing code
describe('test', () => {
  it('should return 5 when adding object a with value 2 and b with value 3', () => {
    // given - 准备数据
    const a = { value: 2 }
    const b = { value: 3 }
  
    // when - 调用被测函数
    const result = computeSumFromObject(a, b)
  
    // then - 断言结果
    expect(result).toBe(5)
  })
})  

首先你要有一个测试的对象,这个对象可能是一个纯函数,当然也有可能是一个组件。一个单测用例主要包括【准备数据】【调用被测对象】【断言结果】三个部分。任何单元测试都可以遵循这样一个骨架, 它是我们常说的 given - when - then 三段式

如上是最基本的单测骨架,一个好用且功能完善的测试框架通常也就包含这么几个功能。

用例收集

我们上面的 it ,test 就是一个用例,使用 describe 进行包裹进行用例的收集。

断言

负责判断当前的结果是否符合预期

有些框架自带断言库,有点本身是不带的

常见断言库有

  • expect:应用很广泛,提供链式的结构。
  • should:应用很广泛,提供链式的结构。
  • assert:不支持链式调用
  • chai:可兼容任何测试框架,BDD/TDD 断言库,提供 should、expect、assert 等方式,可支持 node 环境,浏览器环境断言。文档也很齐全:www.chaijs.com/

匹配器

一般和断言配合使用,匹配断言的结果是否符合预期。

toBe:基本数据类型的匹配, ===,不能用于浮点型匹配,expect(0.1+0.2).toBe(0.3),如果需要这样测试,可以使用toBeCloseTo

toEqual:引用数据类型的匹配

toBeNull:null

toBeUndefined:undefined

not:链式同其他匹配器进行使用,取反的结果

toHaveBeenCalled:函数是否执行

toHaveBeenCalledWith:函数传入值是否匹配

toHaveBeenCalledTimes:函数调用次数是否匹配

。。。

参考:juejin.cn/post/684490…

模拟功能

模拟函数的实现,模拟某个组件某个功能的实现。当然大多数我们都是只需要mock依赖函数的返回值即可。

有这些插件

sinonjs: 有如下几个功能

spy: 可以提供函数调用的信息,但不会改变函数的行为,它会记录下函数调用的参数、返回值、this的值以及抛出的异常。

Stub: 提供函数的调用信息,并且可以像示例代码中一样,让被 stubbed 的函数返回任何我们需要的行为。stub 也能匿名,也能去封装和监听已有函数,但当 stub 封装了一个已有函数后,原函数不会再被调用。

Mock: 通过组合 spies 和 stubs,使替换一个完整对象更容易,和 stub 很像,stub 是对对象中单个函数的监听和拦截,而m ock 是对多个。

it('测试 mock 返回值', async() => {
  const myMock = jest.fn();
  myMock.mockReturnValueOnce(10).mockReturnValueOnce('x').mockReturnValue(true);
  console.log(myMock(), myMock(), myMock(), myMock());
  // 10 x true true
})
import video from '../video.ts';

it("play video", () => {
	const spy = jest.spyOn(video, 'play');
  const isPlaying = video.play();
  expect(spy).toHaveBeenCalled();
  expect(isPlaying).toBe(true);
})

测试覆盖率

image.png

image.png

测试覆盖率解读。

  • 语句覆盖率(statement coverage):是否每个语句都执行了
  • 分支覆盖率(branch coverage):是否每个if代码块都执行了
  • 函数覆盖率(function coverage):是否每个函数都调用了
  • 行覆盖率(line coverage):是否每一行都执行了

Istanbul:现在更名为 nyc。其实现原理主要是:将源码转换为AST,将其遍历包装,记录调用频次与实际值,最后再还原JS代码。地址:github.com/istanbuljs/…

C8 :github.com/bcoe/c8,专注于覆盖率报告生成。

单测的技术选型

单测框架:目前推荐使用 jest

并配合使用的插件包:DOM 测试 RTL(@testing-library/react)进行组件单元级别测试

几个单测框架

mocha

mochajs.org/#wallabyjs

可在 node 和 浏览器中运行

支持异步

可以按需安装断言、mock、测试覆盖率套件

jest

jestjs.io/zh-Hans/doc…

Jest是一个Javascript测试框架,由Facebook开源,致力于简化测试,降低前端测试成本,已被create-react-app、@vue/cli等脚手架工具默认集成。Jest主打开箱即用、快照功能、独立并行测试以及良好的文档和Api。

框架功能齐全,内置断言库Expect、内置 mock 模块,自带 snapshot 功能,同时内置 Istanbul 查看覆盖率,不需要开发者额外配置,对React组件支持度非常友好。

  • 易用性:基于Jasmine,内置提供断言库,支持多种测试风格
  • 适应性:Jest是模块化、可扩展和可配置的
  • 沙箱和快照:Jest内置了JSDOM,能够模拟浏览器环境,并且并行执行
  • 快照测试:Jest能够对React组件树进行序列化,生成对应的字符串快照,通过比较字符串提供高性能的UI检测
  • Mock系统:Jest实现了一个强大的Mock系统,并支持自动和手动mock
  • 支持异步代码测试:支持Promise和async/await
  • 自动生成静态分析结果:内置Istanbul/nyc,测试代码覆盖率,并生成对应的报告

vitest

cn.vitest.dev/

image.png

提供了 UI 界面,测试的详情可以在页面详细看到

image.png

node:test

nodejs.org/api/test.ht…

node 官方实现,目前属于实验阶段,未来可期

无需安装第三方包,慢慢开始支持 mock 等特性

image.png

对比 github star & Npm 下载量:npmtrends.com/jest-vs-moc…

image.png

综合对比JS单测框架, jest、mocha、vitest,目前推荐使用 jest 进行单测,因为 jest 和 mocha 还有 vitest 相比,无论从 github stars 和 issues 量,还是 npm 下载量上,jest 都有优势。所以这里我们选择 jest 作为我们的单测框架

通过上图比较可以看出,jest 的下载量较大。jest 的关注度更高,社区更加活跃。但 vitest 作为新兴势力也在开辟市场。

框架断言异步Mock代码覆盖率
jest默认支持断言库(expect)友好默认支持支持
mocha不支持(chai/power-asset 支持)友好不支持(sinon 插件进行支持)不支持(istanbul 插件支持)
vitest默认支持友好默认支持默认支持,并且提供 UI 视图报告

因为 mocha 默认不支持断言,所以这里使用的话,我们需要引入 chai 断言库。因为jest 内置了断言库 expect 这样对比,可以发现 jest 开箱即用。

单测的快速使用

我想测纯函数

// production code
const computeSumFromObject = (a, b) => {
  return a.value + b.value
}

// testing code
describe('test', () => {
  it('should return 5 when adding object a with value 2 and b with value 3', () => {
    // given - 准备数据
    const a = { value: 2 }
    const b = { value: 3 }
  
    // when - 调用被测函数
    const result = computeSumFromObject(a, b)
  
    // then - 断言结果
    expect(result).toBe(5)
  })
})  

说一嘴 钩子函数

function sum(a, b) {
	return a + b;
}

beforeAll(() => {
  console.log('全局之前');
});

afterAll(() => {
  console.log('全局之后');
  // 在 describe 执行之后执行该方法
  // 一般在这个钩子函数中执行一些清除副作用的方法
});

beforeEach(() =>{
  console.log('全局之前,每个都会执行');
});

afterEach(() => {
  console.log('全局之后,每个都会执行');
});

describe('求和', () => {
  beforeAll(() => {
    console.log('求和:全局之前');
  });
  
  afterAll(() => {
    console.log('求和:全局之后');
  });
  
  beforeEach(() => {
    console.log('求和:全局之前,每个都会执行');
  });
  
  afterEach(() => {
    console.log('求和:全局之后,每个都会执行');
  });

  it('求和:1 + 2 = 3', () => {
    console.log('求和:1 + 2 = 3');
    expect(sum(1, 2)).toEqual(3);
  });

  it('求和:2 + 5 = 7', () => {
    console.log('求和:2 + 5 = 7');
    expect(sum(2, 5)).toEqual(7);
  });
})

// 全局之前
// 求和:全局之前
// 全局之前,每个都会执行
// 求和:全局之前,每个都会执行
// 求和:1 + 2 = 3
// 求和:全局之后,每个都会执行
// 全局之后,每个都会执行
// 全局之前,每个都会执行
// 求和:全局之前,每个都会执行
// 求和:2 + 5 = 7
// 求和:全局之后,每个都会执行
// 全局之后,每个都会执行
// 求和:全局之后
// 全局之后

我想测异步函数

测异步函数最重要的是mock

重点说说 jest 的 mock 行为

使用 mock 测试返回值。举个例子。API:jestjs.io/zh-Hans/doc…

const mockFn = jest.fn();

it('测试 mock 返回值', async() => {
  mockFn.mockReturnValue('default')
  .mockReturnValueOnce('first call')
  .mockReturnValueOnce('second call');

  mockFn(); // 'first call'
  mockFn(); // 'second call'
  mockFn(); // 'default'
  mockFn(); // 'default'
})

多次 mock 函数返回值,我们想要测试不同环境下的返回值

export const config = {
  getEnv() {
    // 很复杂的逻辑...
    return 'test'
  }
}

使用 jest.spyOn,比较推荐这个写法。

import { config } from "../env";

describe("spyOn config", () => {
  it('开发环境', () => {
    jest.spyOn(config, 'getEnv').mockReturnValue('dev')

    expect(config.getEnv()).toEqual('dev');
  })

  it('正式环境', () => {
    jest.spyOn(config, 'getEnv').mockReturnValue('prod')

    expect(config.getEnv()).toEqual('prod');
  })
});

mock axios 返回值

import axios, { AxiosRequestConfig } from "axios";

const instance = axios.create({
    timeout: 3000,
});

export const request = (options: AxiosRequestConfig): Promise<any> => {
    // do something wrap
    return instance.request(options).then(res => res.data);
};
import { request } from "./axios";

export const counter = (id: number, number: number): Promise<{ result: number; msg: string }> => {
    const operate = number > 0 ? 1 : -1;
    return request({
        url: "https://www.example.com/api/setCounter",
        method: "POST",
        data: { id, operate },
    })
    .then(res => {
        return res;
    })
    .catch(err => {
        return { result: -999, msg: "fail" };
    });
};
import { counter } from '..';
import { request } from '../axios';

jest.mock("../axios")

describe('mock axios', () =>{
  // 第一种写法
  it('测试 mock axios 返回值', async() => {
    const mockResponse = {
      name: '后裔'
    }
    request.mockResolvedValue({ data: mockResponse});
    const data = await counter()
    console.log(data);
    expect(data).toEqual({ data: mockResponse})
  })

  // 第二种写法
  it('测试 mock axios 返回值', async() => {
    const mockResponse = {
      name: '刘备'
    }
    jest.spyOn(Axios, 'get').mockResolvedValue(mockResponse)
    const data = await getUserInfo()
    console.log(data);
    expect(data).toEqual(mockResponse)
  })
})

我想测 react 组件

说一嘴快照测试

说起 react 组件,可能大家第一时间会想起 快照。快照能够帮助我们记录下我们测试组件的 dom 结构。

import React from 'react'

export default function index() {
  return (
    <div>
      <div>这是一个基础的组件,我什么都没有</div>
      <div>learn testing</div>
    </div>
  )
}
import { render } from '@testing-library/react';
import Link from '..';
import React from 'react';

it('renders correctly', () => {
  const { baseElement } = render(<Link/>)
  // 下面是重点
  expect(baseElement).toMatchSnapshot();
});

在同级生成一个__snapshots__文件夹,同时在该文件夹下生成一个 xxx.snap 的文件

exports[`renders correctly 1`] = `
<a
  className="normal"
  href="http://www.facebook.com"
  onMouseEnter={[Function]}
  onMouseLeave={[Function]}
>
  Facebook
</a>
`;

除了 toMatchSnapshot 方法,还有 toMatchInlineSnapshot 行内生成,不再生成一个文件了,更加直观。

expect(todos).toMatchInlineSnapshot(`
    Array [
      Object {
        "isCompleted": true,
        "text": "Learn about React",
      },
      Object {
        "isCompleted": false,
        "text": "Meet friend for lunch",
      },
      Object {
        "isCompleted": false,
        "text": "new todo",
      },
    ]
  `);

快照测试失败

  • 业务代码变更后导致输出结果和以前记录的 .snap 不一致,说明业务代码有问题,要排查 Bug
  • 业务代码有更新导致输出结果和以前记录的 .snap 不一致,新增功能改变了原有的 DOM 结构,要用 npx jest --updateSnapshot 更新当前快照

假如我们输入mock的字段值不一样,同样会导致测试快照失败。这其实是一种假错误


快照测试不适用,迭代更新较快,不稳定的业务。

重点说说 RTL

testing-library.com/docs/react-…

对于 React 的单测方案,可能很多人下意识的会选择 Jest + Enzyme 的方案,Jest 是 Facebook 开源的测试框架,而 Enzyme 是 Airbnb 开源的React的测试工具库。他们带有大厂的光环,一度也是React组件首选的测试方案。

但是 Airbnb 已经退出 Enzyme,现在它已经变成个人项目。并且 Enzyme 使用了很多 React 的内部函数,对于hooks 的支持也不是很友好,并且 issues 的解决速度也越来越慢,所以 Enzyme 现在已经不是测试 React 的首选工具库了。即使目前市面上很多主流库都是使用 jest + enzyme。例如(nextui)(ant design)。

最近几年,RTL(React Testing Library)受到越来越多的关注,并且被越来越多的公司和开源项目采纳,CRA已经内置了RTL。

RTL更多的关注使用者怎么去使用一个React组件,以及组件在页面中具体的DOM表现。对于一个React组件的测试,开发者不用再去关心React的内部实现,只要根据组件的真实使用场景去书写对应的单测就可以了,大大减少了单测用例的书写难度。

它没有像Enzyme提供的过多API和复杂的概念,它封装了常用的DOM操作,以及一些常用的断言方法,可以和Jest进行很好的结合。

除此之外,react 官网推荐的测试工具也是 jest + testing-library:传送门

RTL 提供的功能有很多,这里列一些常用到的。

  • 渲染一个组件(render,rerender等)

  • 选择其中的元素,screen.getByText

  • fire event,模拟点击事件,fireEvent vs userEvent

对于组件测试,我们想到了是 dom,所以,RTL 类似库(enzyme) 正式提供了操作 dom 的功能。获取某个 dom 的 text 值,是否符合预期。

import React from 'react';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom'
 
import App from '..';
 
describe('App', () => {
  test('renders App component', () => {
    const { rerender } = render(<App title='这是第一个title'/>);
    rerender(<App title='这是第二个title'/>)
    expect(screen.getByTestId('instance-id')).toHaveTextContent('这是第二个title')
  });
});

我想测 hooks

hooks 函数不就是一个纯函数嘛,那直接安装测试函数的方式进行测试不就得了,难道还有其他坑?

举个例子

import { useState } from "react";

export interface Options {
  min?: number;
  max?: number;
}

export type ValueParam = number | ((c: number) => number);

function getTargetValue(val: number, options: Options = {}) {
  const { min, max } = options;
  let target = val;
  if (typeof max === "number") {
    target = Math.min(max, target);
  }
  if (typeof min === "number") {
    target = Math.max(min, target);
  }
  return target;
}

function useCounter(initialValue = 0, options: Options = {}) {
  const { min, max } = options;

  const [current, setCurrent] = useState(() => {
    return getTargetValue(initialValue, {
      min,
      max,
    });
  });

  const setValue = (value: ValueParam) => {
    setCurrent((c) => {
      const target = typeof value === "number" ? value : value(c);
      return getTargetValue(target, {
        max,
        min,
      });
    });
  };

  const inc = (delta = 1) => {
    setValue((c) => c + delta);
  };

  const dec = (delta = 1) => {
    setValue((c) => c - delta);
  };

  const set = (value: ValueParam) => {
    setValue(value);
  };

  const reset = () => {
    setValue(initialValue);
  };

  return [
    current,
    {
      inc,
      dec,
      set,
      reset,
    },
  ] as const;
}

export default useCounter;

测试如下

describe("useCounter", () => {
  it("可以加 1", () => {
    const [counter, utils] = useCounter(0);

    expect(counter).toEqual(0);

    utils.inc(1);

    expect(counter).toEqual(1);
  });
});

好家伙,直接报错

image.png

把 useState , useEffect 都 mock 掉如何?都 mock 掉的话会疯掉的,请看 react api 列表。

当然可以使用其他方法进行测试,我们换一种思路,通过测组件的方式测它。

需要使用 @testing-library/user-event包(模拟用户点击),安装它。

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import React from "react";
import useCounter from "../useCounter";
import "@testing-library/jest-dom";

// 测试组件
const UseCounterTest = () => {
  const [counter, { inc, set, dec, reset }] = useCounter(0);
  return (
    <section>
      <div>Counter: {counter}</div>
      <button onClick={() => inc(1)}>inc(1)</button>
      <button onClick={() => dec(1)}>dec(1)</button>
      <button onClick={() => set(10)}>set(10)</button>
      <button onClick={reset}>reset()</button>
    </section>
  );
};

describe("useCounter", () => {
  it("可以做加法", async () => {
    render(<UseCounterTest />);

    const incBtn = screen.getByText("inc(1)");

    await userEvent.click(incBtn);

    expect(screen.getByText("Counter: 1")).toBeInTheDocument();
  });

  it("可以做减法", async () => {
    render(<UseCounterTest />);

    const decBtn = screen.getByText("dec(1)");

    await userEvent.click(decBtn);

    expect(screen.getByText("Counter: -1")).toBeInTheDocument();
  });

  it("可以设置值", async () => {
    render(<UseCounterTest />);

    const setBtn = screen.getByText("set(10)");

    await userEvent.click(setBtn);

    expect(screen.getByText("Counter: 10")).toBeInTheDocument();
  });

  it("可以重置值", async () => {
    render(<UseCounterTest />);

    const incBtn = screen.getByText("inc(1)");
    const resetBtn = screen.getByText("reset()");

    await userEvent.click(incBtn);
    await userEvent.click(resetBtn);

    expect(screen.getByText("Counter: 0")).toBeInTheDocument();
  });
});

当然,这是一种不错的解法,但是不是完美的。

可以使用 renderHook,可以直接从 @testing-library/react 去取。

在 @testing-library/react@13.1.0 以上的版本已经把 renderHook 内置到里面了,如果你的项目依赖版本很老并且不想升级它,你可以按照 @testing-library/react-hooks 包

import useCounter from "../useCounter";

describe("useCounter", () => {
  it("加1", () => {
    const { result } = renderHook(() => useCounter(3));
    const [counter, {inc}] = result.current
    
    expect(counter).toEqual(3);
  });
}); 

这时候,test 是成功的。

我们调用 inc 方法加一

import useCounter from "../useCounter";

describe("useCounter", () => {
  it("加1", () => {
    const { result } = renderHook(() => useCounter(3));
    const [counter, {inc}] = result.current
    inc(1);
    expect(counter).toEqual(4);
  });
}); 

报如下错误,为啥呢?

image.png

我们使用 act 函数包裹

import useCounter from "../useCounter";
import { act } from "@testing-library/react";

describe("useCounter", () => {
  it("加1", () => {
    const { result } = renderHook(() => useCounter(3));
    const [counter, {inc}] = result.current
    act(() => {
      inc(1);
    });
    expect(counter).toEqual(4);
  });
}); 

passed

在组件状态更新时,组件需要被重新渲染,而这个重渲染是需要 React 调度的,因此是个异步的过程。通过使用 act 函数,我们可以将所有会更新到组件状态的操作封装在它的 callback 里面来保证 act 函数执行完之后我们定义的组件已经完成了重新渲染。所以 act 一般会使用在 ui 组件变更的场景。

我想测 redux

这是一个很简单的 todolist

import { createSlice } from "@reduxjs/toolkit";

const initialState = [
  {
    text: "Learn about React",
    isCompleted: false,
  },
  {
    text: "Meet friend for lunch",
    isCompleted: false,
  },
  {
    text: "Build really cool todo app",
    isCompleted: false,
  },
];

/**
 * Thunk
 */
export const xxxx = createAsyncThunk<any>(
  "xxx",
  async (_) => {}
);

export const todoSlice = createSlice({
  name: "todo",
  initialState,
  reducers: {
    addTodo(state, action) {
      state.push({ text: action.payload, isCompleted: false });
    },
    toggleTodo(state, action) {
      // immer
      const todoToFlip = state[action.payload];
      if (todoToFlip) {
        todoToFlip.isCompleted = !todoToFlip.isCompleted;
      }
    },
    deleteTodo(state, action) {
      state.splice(action.payload, 1);
    },
  },
  
  extraReducers(builder) {
    ...
  }
  
});

export const { deleteTodo, toggleTodo, addTodo } = todoSlice.actions;

export selectXXX = xxx.getSelectors(
  (state: RootState) => {
  	// 复杂逻辑
		return state.todo.xxx
	}
);

export default todoSlice.reducer;

reducer 的测试,很简单,就类似测纯函数。

import { store } from "./store";
import { addTodo, deleteTodo, toggleTodo } from "./todo";

test("should add, complete, delete todo", () => {
  let todos = store.getState().todo;
  expect(todos).toHaveLength(3);

  // should add todo
  store.dispatch(addTodo("new todo"));
  todos = store.getState().todo;
  expect(todos).toHaveLength(4);

  // should complete todo
  store.dispatch(toggleTodo(0));
  let firstTodo = store.getState().todo[0];
  expect(firstTodo.isCompleted).toEqual(true);

  // should delete todo
  store.dispatch(deleteTodo(2));
  todos = store.getState().todo;
  expect(todos).toMatchInlineSnapshot(`
    Array [
      Object {
        "isCompleted": true,
        "text": "Learn about React",
      },
      Object {
        "isCompleted": false,
        "text": "Meet friend for lunch",
      },
      Object {
        "isCompleted": false,
        "text": "new todo",
      },
    ]
  `);
});

redux 组件层面测试,可以类比前面讲到的

import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import React from "react";
import App from "./App";
import { renderWithRedux } from "./test-utils/renderWithRedux";

test("should render todo list", () => {
  renderWithRedux(<App />);

  const todos = screen.getAllByTestId("todo-item");

  expect(todos).toHaveLength(3);
});

test("should add todo", () => {
  renderWithRedux(<App />);

  userEvent.type(
    screen.getByPlaceholderText("What's your plan?"),
    "Buy milk{enter}"
  );

  const todos = screen.getAllByTestId("todo-item");
  expect(todos).toHaveLength(4);
});

test("should remove todo", () => {
  renderWithRedux(<App />);

  userEvent.click(
    within(screen.getByText("Learn about React")).getByTestId("remove-todo")
  );
  expect(screen.getAllByTestId("todo-item")).toHaveLength(2);
});

test("should toggle todo completed", () => {
  renderWithRedux(<App />);

  const firstTodo = within(screen.getByText("Learn about React"));
  userEvent.click(firstTodo.getByText("Complete"));
  expect(firstTodo.getByText("Redo")).toBeInTheDocument();
});
import { render } from "@testing-library/react";
import React from "react";
import { Provider } from "react-redux";
import { initStore } from "../store/store";

export function renderWithRedux(
  ui: React.ReactElement,
  { store = initStore(), ...renderOptions } = {}
) {
  jest.spyOn(store, "dispatch");

  const Wrapper: React.FC = ({ children }) => (
    <Provider store={store}>{children}</Provider>
  );
  const result = render(ui, { wrapper: Wrapper, ...renderOptions });
  return { ...result, store };
}
架构层级测试内容测试策略解释
reducer 层是否正确完成计算对于有逻辑的 reducer 需要 100%覆盖率这个层级输入输出明确,又有业务逻辑的计算在内,天然属于单元测试宠爱的对象
selector 层是否正确完成计算对于有较复杂逻辑的 selector 需要 100%覆盖率这个层级输入输出明确,又有业务逻辑的计算在内,天然属于单元测试宠爱的对象
saga(副作用) 层是否获取了正确的参数去调用 API,并使用正确的数据存取回 redux 中对于是否获取了正确参数、是否调用正确的 API、是否使用了正确的返回值保存数据、业务分支逻辑、异常分支 这五个业务点建议 100% 覆盖这个层级也有业务逻辑,对前面所述的 5 大方面进行测试很有重构价值
component(组件接入) 层是否渲染了正确的组件组件的分支渲染逻辑要求 100% 覆盖、交互事件的调用参数一般要求 100% 覆盖、纯 UI 不测、CSS 一般不测这个层级最为复杂,测试策略还是以「代价最低,收益最高」为指导原则进行

我想测小程序

developers.weixin.qq.com/miniprogram…

github.com/wechat-mini…