单元测试是什么?在前端领域我们应该怎么写单元测试?

前言

本文将通过配套的思维导图为基础,为你介绍单元测试的各种知识点,并且以 Jest 为例,逐步带你深入学习单元测试。 至于 BDD(Behavior-Driven Development)TDD(Test-Driven Development) 两大思想流派,这里先不做过多讨论。

当你尝试问 ChartGPT,单元测试是什么,它会回答你这么一大坨:

简而言之

  1. 单元测试是为了测试你写的代码,包括各种函数、组件、模块等。
  2. 前端现在越来越复杂了,屎山堆积速度惊人,所以在前端我们得把单元测试也整起来了,它不只是后端的活。
  3. 一个好的单元测试可以让你对自己的代码充满信心 ,连 QA 都要夸你一句 泰裤辣

本文的 思维导图 链接,建议先查看思维导图,对本文有个大概的了解。

常用的前端单元测试框架和工具概述

我们可以把编写单元测试的依赖库分为三部分

  • Framework(基础框架)
  • Assertion Library(断言库)
  • Runner(运行器)

Framework

Framework 提供了包括通用型测试函数 describeit ,以及用于运行测试的主函数和各种功能函数。

可以通过下面这段简化过的 Jest 测试代码来简单认识下。

test.ts 复制代码
 import xxx;

 describe("test env is match", () => {
     beforeEach(() => {
        // do something 
     })
     
     afterEach(() => {
        // do something 
     })
 
     it('envirment is dev', () => {
         // do something test
     })

     it('envirment is prod', () => {
        // do something test
     })
 });
  1. 首先外面的 describe 方法定义了测试组,传入的第一个参数代表这组测试的名字,第二个参数是一个箭头函数,里面就是这组测试具体要执行的代码。
  2. it则被用来定义测试组里的测试块,一个测试组可以定义N多个测试块。
  3. beforeEachafterEach 类似于生命周期的钩子函数,它们可以做一些前置环境准备等等事情,我们后续在实践的章节里会讲到,这里先挖个坑。

当然Framework提供的功能远不止这些,它还有很多对测试提供帮助的功能函数,这里就不列举了,可以参考对应库的文档介绍。我们下面来简单介绍下几个比较知名的 test 库

Jest

Jest 是 Facebook 开发的一体式单元测试工具。你不需要关心什么是测试运行环境,什么是测试框架,什么是断言库,什么是Runner,Jest 就是你所需要的全部。它只需要非常少的配置,几乎做到了开箱即用。就像它自我介绍的那样,这是一个令人愉快的测试库。

Jasmine

Jasmine跟Jest类似,该有的断言和Mock都有,也基本做到了开箱即用。它属于老牌测试框架了,Angular官方就默认选择了使用Jasmine + Karma做单元测试。

Mocha

Mocha 是一个灵活的库,提供给开发者的只有一个基础测试结构。然后其它功能像 assertions,spies,mocks等等,需要添加其它库/插件来完成。

它的优点在于社区比较成熟,用的人比较多,测试各种东西社区都有示例,缺点是对比其他库的开箱即用,需要较多配置。

AVA

AVA 可以看成是mocha的替代者,专为编写异步测试而设计(异步测试)。

  • 轻量和高效
  • 简单的测试语法
  • 并发运行测试
  • 强制编写原子测试 一旦开始,就一直运行到结束,中间不会切换到另一个测试
  • 没有隐藏的全局变量
  • 为每个测试文件提供环境隔离
  • 用 ES2015 编写测试------不需安装依赖babel 支持

至于在项目中具体选哪个,可以根据团队情况和项目情况去选型。

Assertion Library

断言库(Assertion Library)其实就是 expect() 函数的提供者,用来验证你测试结果是否通过的库。

举个栗子:FuncA 是一个拥有一个入参的函数,我要对它做单测,当它被调用且入参为1时,我可以对 FuncA 做如下断言:

  • 断言 FuncA 的 result 一定不等于2
  • 断言 FuncA 的 result 一定等于1

当 FuncA 被其他 FuncB 调用时,我可以在FuncB 的测试做如下断言:

  • 断言 FuncA 被调用了,且只被调用了1次
  • 断言 FuncA 被调用时的入参一定为1

断言库就是做这些事的,我们通过对测试目标进行断言,来确定它符合我们的预期。

断言库有很多语法函数,像是 .be(xx), .not.be(xx), .equal(xx), .contain等等。

可以分为两种风格:

  • BDD风格 expect(foo).to.be("aa")foo.should.be("aa");
  • Assert风格 assert(foo == "aa");

Assert

Node.js中内置的库

js 复制代码
assert("foo" == "aa");

Should

BDD风格

js 复制代码
foo.should.equal('aa'); 

Expect

BDD风格,Jest和Jasmine都是该风格。

js 复制代码
expect(foo).to.be("aa")

这里有一点要提到的是,在遇到被测对象为 undefined 时,should 会直接失效报错,而 expect 依然可以给出信息。

js 复制代码
// foo === undefined
foo.should.equal('aa'); 

此时会直接报错,错误信息为: Cannot read property 'should' of undefined,而使用 expect

js 复制代码
// foo === undefined
expect(foo).to.equal('aa');

输出的信息为:expected undefined to equal 'foo'。所以expect要比should友好一点。

Chai

Chai直接全都要!它可以与任何 Unit Test库集成,同时支持上面三种风格的写法。

js 复制代码
assert("foo" == "aa");

foo.should.be("aa");

expect(foo).to.be("aa");

Runner

测试运行工具(Test Runner)是负责解析和调用单元测试代码并加载测试运行环境(Test Runtime)的工具。

目前Runner分为两派,JSDom和Karma。

JSDom

jsdom 是许多 Web 标准的纯 JavaScript 实现,特别是 WHATWG DOM 和 HTML 标准,可以和 Node.js 一起使用。 一般来说,该项目的目标是模拟足够多的 Web 浏览器子集,以用于测试和 scraping real-world(不知道怎么翻译) Web 应用程序。

看代码应该比较好理解一些。

js 复制代码
const dom = new JSDOM(`<body>
  <script>document.body.appendChild(document.createElement("hr"));</script>
</body>`);

// The script will not be executed, by default:
dom.window.document.body.children.length === 1;

JSDom 顾名思义就是通过纯 JS 实现的 DOM 对象, 但它不能理解 DOM 的布局、尺寸、样式、以及浏览器的高级 API,例如 ResizeObserver,matchMedia(),只能通过mock去模拟。

Karma

Karma是一个由Google开发的前端测试运行器(test runner),旨在简化前端单元测试和集成测试的执行过程。它可以与多种测试框架(如Mocha、Jasmine、QUnit等)和断言库(如Chai、Expect.js等)配合使用,提供一个可配置的测试环境。

Karma的主要作用是启动浏览器实例,并在这些实例中执行你编写的测试代码。它可以自动监测文件的更改,并重新执行相关的测试,使你能够实时获取测试结果和反馈。Karma还提供了丰富的功能,如测试覆盖率报告、并发测试执行、持续集成等,使你能够更全面地了解和监控你的前端代码的质量和性能。

简而言之 Karma 让你的测试代码可以运行在各种真实浏览器环境,可以直接访问浏览器端 API,而不是 mock, 这是 JSDom 做不到的。

单元测试的目标和原则

目标

单元测试的目标应该是实现软件项目的可持续增长。

这里的可持续是关键,我们在开始一个新项目时,没有任何祖传代码需要担心,此时我们对业务模块的手到拈来,开发的飞快。随着时间的推移,代码变得越来越多并且难以理解,你必须投入越来越多的成本才能保持与开始时相同的进度。

原则

单元测试虽然有助于保持项目增长,但是仅仅编写测试是不够的,写得不好的测试仍然导致增长变缓。在编写单元测试时,应该尽量遵循以下原则:

  • 针对代码中最核心的部分进行测试(不应该过度关注代码的实现过程,而应关注目标和结果)
  • 可维护性强 ( 以最低的维护成本提供最大的价值,不要为了写单元测试而写单元测试 )
  • 产出的单元测试能提高对目标代码的信心

还有一条算是执行过程中的原则,我们应该确保在每次修改原有代码逻辑后,原有单元测试是通过的(如果是业务逻辑发生变化,那么请同步修改你的单元测试)。

Jest

上面简单介绍了单元测试相关的前置知识和基础概念,后面的实战我们选择使用React + Jest。个人觉得 Jest 的文档相比其他几个库更友好一点,比较详细而且部分文档有中文翻译可以看(虽然大概率是机翻的),再加上有大厂背书,社区活跃程度和后续维护都不用担心。

Jest 是 Facebook 开发的一体式单元测试工具。你不需要关心什么是测试运行环境,什么是测试框架,什么是断言库。Jest 就是你所需要的全部。它只需要非常少的配置,几乎做到了开箱即用。

  • 支持并行运行测试以及在隔离环境下测试。
  • Jest 的运行环境是 JSDOM + Node,一个伪 DOM 引擎。
  • Jest 基于Jasmine进行开发,添加了更多的特性
  • Jest 支持vue、ng、react等各种项目,但更多用于react。

本章节将会接详细介绍 Jest 的用法。

Basic 基础知识

Simple usage

篇幅有限,建议看 官方文档,Jest 使用比较简单,没什么复杂的配置。 先了解一下 describe 基础写法。

Repeating Setup

如果有一些要为每次测试重复设置的工作,就可以使用下面介绍的几种方法进行设置。

beforeEach(fn, timeout)

每个测试开始前执行的钩子函数,使用 beforeEach 方便在每个测试开始前设置一些全局状态。

如果传入的回调函数返回值是 promise 或者 generator,Jest 会等待 promise resolve 再继续执行测试。

第二个参数是可选的 timeout(毫秒), 指定函数执行的超时时间,默认是5秒。

js 复制代码
const globalDatabase = makeGlobalDatabase();  
  
beforeEach(() => {  
    // Clears the database and adds some testing data.  
    // Jest 会等待 promise resolve 再去执行测试  
    return globalDatabase.clear().then(() => {  
         return globalDatabase.insert({testData: 'foo'});  
    });  
});  

test('can find things', () => {  
    return globalDatabase.find('thing', {}, results => {  
        expect(results.length).toBeGreaterThan(0);  
    });  
});  

test('can insert a thing', () => {  
    return globalDatabase.insert('thing', makeThing(), response => {  
        expect(response.success).toBeTruthy();
    });
}); 

afterEach(fn, timeout)

文件内每个测试完成后执行的钩子函数,使用 afterEach 方便清理一些在每个测试中创建的临时状态。

用法跟 beforeEach 差不多。

beforeAll(fn, timeout)

文件内所有测试开始前执行的钩子函数,方便设置一些在每个测试用例之间共享的全局状态。

他跟beforeEach的区别就是一个针对文件内每个测试用例,一个针对文件内所有测试用例。

用法跟 beforeEach 差不多。

afterAll(fn, timeout)

文件内所有测试完成后执行的钩子函数,方便清理一些在测试用例之间共享的全局状态。

用法跟 beforeEach 差不多。

一些建议

如果你有一个测试作为一个更大用例中的一部分时,经常运行失败,但是单独运行它又是好的, 最好检查下其他测试对这个测试的影响。可以通过修改 beforeEach 来清除一些共享的状态来尝试修复这种问题。

如果不能确定哪些分享的状态被修改了,可以尝试在 beforeEach 打印出来看看。

Async Test

假设你有一个异步方法 fetchData,需要测试返回的结果是否符合预期

  1. 直接返回一个Promise即可,Jest会等待Promise的resove状态 如果 Promise 的状态变为 rejected, 测试将会失败。
js 复制代码
test('the data is peanut butter', () => {
    return fetchData().then(data => {
        expect(data).toBe('peanut butter');
    });
});
  1. 或者可以在测试中使用 asyncawait
js 复制代码
test('the data is peanut butter', async () => {
     const data = await fetchData();
     expect(data).toBe('peanut butter');
});

test('the fetch fails with an error', async () => {
     expect.assertions(1);
     try {
        await fetchData();
     } catch (e) {
        expect(e).toMatch('error');
     }
 });

也可以将 async await.resolves or .rejects一起使用

js 复制代码
test('the data is peanut butter', async () => {
    await expect(fetchData()).resolves.toBe('peanut butter');
})

test('the fetch fails with an error', async () => {
    await expect(fetchData()).rejects.toMatch('error');
});

Test Timer

假如现在有如下代码需要测试。

js 复制代码
function timerGame(callback) {
  console.log('Ready....go!');
  setTimeout(() => {
    console.log("Time's up -- stop!");
    callback && callback();
  }, 3000);
}

module.exports = timerGame;

如果不Mock Timer的话,我们需要写如下测试代码

js 复制代码
describe("timerGame", () => {
  it("waits 3 second before ending the game", (done) => {
    timerGame(() => {
      expect("???");
      done();
    });
  });
});

但是这就面临一个问题,我们不得不等待3秒才能跑完这个 Case ,这不合理。 因此Jest提供了 jest.runAllTimers() 用来快进时间。

js 复制代码
jest.useFakeTimers();
test('calls the callback after 3 second', () => {
  const timerGame = require('../timerGame');
  const callback = jest.fn();

  timerGame(callback);

  // At this point in time, the callback should not have been called yet
  expect(callback).not.toBeCalled();

  // Fast-forward until all timers have been executed
  jest.runAllTimers();

  // Now our callback should have been called!
  expect(callback).toBeCalled();
  expect(callback).toHaveBeenCalledTimes(1);
});

除此之外还有 jest. advancertimersbytime (1000) 来快进指定的时间,单位是毫秒。

在遇到有嵌套的定时任务时,也就是一个定时器里面创建另一个定时器的场景,此时使用jest.runAllTimers()会导致无限循环报错。因此 jest 提供了 jest.runOnlyPendingTimers() 来解决这个问题。

Snap Test

Snap test可以帮我们测试UI是否有意外的改变。

做法是在渲染了UI组件之后,保存一个快照文件,检测他是否与保存在单元测试里的快照文件相匹配。 若两个快照不匹配,测试失败,失败有两种情况: 1.有可能做了意外的更改。 2.或者UI组件已经更新到了新版本(当组件确实要更新到新版本时,也可以执行命令更新快照。)。

快照测试一般是针对代码相对稳定,或针对不方便做断言的场景。

可以通过一个例子来了解快照测试,假设现在要针对一个Link组件做快照测试。

link.test.tsx 复制代码
import renderer from 'react-test-renderer';
import Link from '../Link';

it('renders correctly', () => {
  const tree = renderer
    .create(<Link page="http://www.facebook.com">Facebook</Link>)
    .toJSON();
  expect(tree).toMatchSnapshot();
});

第一次跑这个测试的时候,会在当前测试文件所在的同级目录下创建一个 link.test.tsx.snap文件

link.test.tsx.snap 复制代码
exports[`renders correctly 1`] = `
<a
  className="normal"
  href="http://www.facebook.com"
  onMouseEnter={[Function]}
  onMouseLeave={[Function]}
>
  Facebook
</a>

后面再跑这个测试的时候,就会拿新生成的snap跟之前的进行对比,如果HTML结构不匹配,当前测试就会失败。

其实快照测试本质上就是以一次稳定版本的HTML结构为标准,后续不断地去对比结构是否发生了意料之外的变化。

另外所有的快照文件都应该与它们所覆盖的模块及其测试一起提交。它们是测试的一部分,类似于 Jest 中其他断言。快照代表源模块在任何给定时间点的状态。 这样当源模块被修改时,可以知道与以前版本相比发生了什么变化。 它还可以在代码 Review 期间提供许多额外的提示。

快照测试在试用例覆盖程度比较高的时候才能发挥功能。因为项目开发初期,UI会大幅度调整,逻辑也会经常变更,还未达到相对稳定状态,这时看快照测试结果意义不大。所以必须需要经过长时间迭代之后,快照才能趋于稳定。

另外,由于它只是基于"快照",并没有像断言测试一样,有明确的正确/错误判断标准。所以它不会提示代码逻辑错误,所以用来标示测试用例覆盖程度也意义不大。

Mock

Mock 的主要作用是模拟一些在应用中不容易构造或者有副作用的对象,从而把测试与测试边界以外的对象隔离开。

假设现在要测试一个删除数据的Func,我们在测试时肯定不能真的向服务端发起请求,删除数据。这时就需要用到Mock,把请求给Mock掉。

除此之外我们的代码可能会依赖于一些浏览器特有的API等等,受限于 Jest 使用的 JSDom方案(上面有介绍过,Node环境并没有这些浏览器特有的API),在执行测试的时候会直接报错,我们也需要把这些 API Mock掉。

Browser API

就像上面说的那样,由于 Node.js 环境并没有 localStorage等一些浏览器特有的API。 我们有两种方案去Mock:

  1. 模拟localStorage实现,然后通过setupFilesAfterEnv在运行unit test之前添加全局Mock, 但是我们不可能把浏览器里所有的 API 都 Mock 一遍,而且不可能做到 100% 还原所有功能,所以不建议使用这种。

额外的知识点:setupFilesAfterEnv 它可以指定一个文件,在每执行一个测试文件前都会跑一遍里面的代码。在这个 setup 文件中, 可以放置全局的 Mock 实现,以及一些初始化操作。

js 复制代码
module.exports = {
  setupFilesAfterEnv: ['./tests/jest-setup.ts'],
};
jest-setup.ts 复制代码
Object.defineProperty(global, xxx , xxx)
  1. jest 提供了 testEnvironment 配置。在jest.config.js中添加
js 复制代码
module.exports = {
   testEnvironment: "jsdom",
}

添加 jsdom 测试环境后,全局会自动拥有完整的浏览器标准 API。这个库用 JS 实现了一套 Node.js 环境下的 Web 标准 API。

Function

官方的解释是

Mock 函数允许你测试代码之间的连接---包括:

  1. 擦除函数的实际实现
  2. 捕获对函数的调用 ( 以及在这些调用中传递的参数)
  3. 在使用 new 实例化时捕获构造函数的实例、允许测试时配置返回值

我理解的实际场景就是当funcA里调用了另一个funcB,在测试funA时不可能断言funcB的行为或者执行结果,因为funcB里面可能还调用了funcC,所以针对这种情况,我们可以mock FuncB,不去考虑它的实现,只断言funcB是否被调用(还可以断言传递的参数是否正确)即可。对于funcBfuncC本身应该编写其单独的测试用例。

直接在测试代码中创建一个 mock 函数

假设要测试foreach的实现

foreach.js 复制代码
export function forEach(items, callback) {
  for (let index = 0; index < items.length; index++) {
    callback(items[index]);
  }
}

我们可以直接 mock 一个函数

forEach.test.js 复制代码
const forEach = require('./forEach');
const mockCallback = jest.fn(x => 42 + x);

test('forEach mock function', () => {
  forEach([0, 1], mockCallback);

  // The mock function was called twice
  expect(mockCallback.mock.calls).toHaveLength(2);

 // The first argument of the first call to the function was 0
  expect(mockCallback.mock.calls[0][0]).toBe(0);

 // The first argument of thesecond call to the function was 1
  expect(mockCallback.mock.calls[1][0]).toBe(1);

 // The return value of the first call to the function was 42
  expect(mockCallback.mock.results[0].value).toBe(42);
});

还可以控制 mock 函数的返回值

scss 复制代码
const myMock = jest.fn();
console.log(myMock());
// > undefined

myMock.mockReturnValueOnce(10)
.mockReturnValueOnce('x')
.mockReturnValue(true);

console.log(myMock(), myMock(), myMock(), myMock());
// > 10, 'x', true, true

所有的 mock 函数都有一个特殊的 .mock 属性,它保存了关于此函数如何被调用、调用时的返回值的信息。 .mock 属性还追踪每次调用时 this 的值,所以我们同样可以也检查 this.

sql 复制代码
// The function was called exactly once
expect(someMockFunction.mock.calls).toHaveLength(1);

// The first arg of the first call to the function was 'first arg'
expect(someMockFunction.mock.calls[0][0]).toBe('first arg');

// The second arg of the first call to the function was 'second arg'
expect(someMockFunction.mock.calls[0][1]).toBe('second arg');

// The return value of the first call to the function was 'return value'
expect(someMockFunction.mock.results[0].value).toBe('return value');

// The function was called with a certain `this` context: the `element` object.
expect(someMockFunction.mock.contexts[0]).toBe(element);

// This function was instantiated exactly twice
expect(someMockFunction.mock.instances.length).toBe(2);

// The object returned by the first instantiation of this function
// had a `name` property whose value was set to 'test'
expect(someMockFunction.mock.instances[0].name).toBe('test');

// The first argument of the last call to the function was 'test'
expect(someMockFunction.mock.lastCall[0]).toBe('test');

除了 jest.fn 外,还可以使用 mockImplementation 来 mock 函数的实现。

scss 复制代码
const foo = require('../foo');

// foo is a mock function
foo.mockImplementation(() => 42);
foo();
// > 42

如果想要每次返回不同的调用结果,可以用 mockImplementationOnce

javascript 复制代码
const myMockFn = jest
  .fn()
  .mockImplementationOnce(cb => cb(null, true))
  .mockImplementationOnce(cb => cb(null, false));

myMockFn((err, val) => console.log(val));
// > true

myMockFn((err, val) => console.log(val));
// > false

当你想在测试的 output 中快速找到你 mock 的 fn, 你还可以 mock name,它相当于给你 mock 的 fn 起了个别名。

ini 复制代码
const myMockFn = jest
  .fn()
  .mockReturnValue('default')
  .mockImplementation(scalar => 42 + scalar)
  .mockName('add42');

Class

跟Function类似,当我们需要测试依赖的Class时,可以Mock这个Class

假如有如下代码需要测试

sound-player.js 复制代码
    export default class SoundPlayer {
      constructor() {
        this.foo = 'bar';
      }

      playSoundFile(fileName) {
        console.log('Playing sound file ' + fileName);
      }
    }
sound-player-consumer.js 复制代码
    import SoundPlayer from './sound-player';

    export default class SoundPlayerConsumer {
      constructor() {
        this.soundPlayer = new SoundPlayer();
      }

      playSomethingCool() {
        const coolSoundFileName = 'song.mp3';
        this.soundPlayer.playSoundFile(coolSoundFileName);
      }
    }

Automatic Mock

通过jest.mock('./sound-payer') 会得到一个Automatic Mock, 可以监听这个模拟上 constructor 的调用以及它所有方法的调用。 它将会使用一个模拟的 constructor 替换原先的 ES6 类,以及使用返回 undefined 的模拟函数替换掉这个类上所有的方法。这些方法的调用会保存在 theAutomaticMock.mock.instances[index].methodName.mock.calls

上面是官方文档对Automatic Mock的介绍,看起来有点难以理解,我们简单拆分一下,其实就是做了三件事

  1. 通过对class的引用,可以获取里面的func等等,然后mock这个类。
  2. 重写源类中的func(删除func内所有代码),全部return undefined
  3. 调用这些func的时候,调用记录和调用时传递的参数都记录到mock.instance里面。

这样我们就可以通过断言去测试这些func是不是被调用,参数是不是跟预期的一致。这里还有一些要注意就是箭头函数不会被mock,官方文档里说的很清楚

If you use arrow functions in your classes, they will not be part of the mock. 这样做的原因是箭头函数不能代表一个对象的原型,它们仅仅是是一个函数的引用。

下面来看具体的测试用例

scss 复制代码
    import SoundPlayer from './sound-player';
    import SoundPlayerConsumer from './sound-player-consumer';
    jest.mock('./sound-player'); // SoundPlayer is now a mock constructor

    beforeEach(() => {
      // Clear all instances and calls to constructor and all methods:
      SoundPlayer.mockClear();
    });

    it('We can check if the consumer called the class constructor',  
    () => {
      const soundPlayerConsumer = new SoundPlayerConsumer();
      expect(SoundPlayer).toHaveBeenCalledTimes(1);
    });
    it('We can check if the consumer called a method on the class in stance', () => {
      // Show that mockClear() is working:
      expect(SoundPlayer).not.toHaveBeenCalled();

      const soundPlayerConsumer = new SoundPlayerConsumer();
      // Constructor should have been called again:
      expect(SoundPlayer).toHaveBeenCalledTimes(1);

      const coolSoundFileName = 'song.mp3';
      soundPlayerConsumer.playSomethingCool();

      // mock.instances is available with automatic mocks:
      const mockSoundPlayerInstance = SoundPlayer.mock.instances[0];
      const mockPlaySoundFile = mockSoundPlayerInstance.playSoundFile;
      expect(mockPlaySoundFile.mock.calls[0][0]).toBe(coolSoundFileName);
      // Equivalent to above check:
      expect(mockPlaySoundFile).toHaveBeenCalledWith(coolSoundFileName);
      expect(mockPlaySoundFile).toHaveBeenCalledTimes(1);
    });

如果不需要替换class实现的话,Automatic Mock是最简单的方式。

Manual Mock

在_mocks_文件夹下,可以创建一个手动模拟,指定class实现,这个实现将被test文件使用,取代真实的class。

做法就是在项目根目录新建一个 名为_mock_的文件夹,假如你需要 Mock Class A,A 的文件名是 a.js, 那就在 _mock_文件夹里新建一个文件名为 a.js的文件,在你的单元测试文件内正常 import a.js原有的路径即可。

在运行单元测试时,jest会查找 _mock_ 文件夹内有没有同名文件,有就进行替换。 这里要注意_mock_是一个命名约定,不能随意更换。而且下面的文件名需要与你 mock 的类同名。

mocks/sound-player.js

ini 复制代码
export const mockPlaySoundFile = jest.fn();
const mock = jest.fn().mockImplementation(() => {
  return {playSoundFile: mockPlaySoundFile};
});

export default mock;

在引用时使用原来的模块地址 import 就可以了,不需要包含 mocks。

sound-player-consumer.test.js

scss 复制代码
import SoundPlayer, {mockPlaySoundFile} from './sound-player';
import SoundPlayerConsumer from './sound-player-consumer';
jest.mock('./sound-player'); // SoundPlayer is now a mock constructor

beforeEach(() => {
  // Clear all instances and calls to constructor and all methods:
  SoundPlayer.mockClear();
  mockPlaySoundFile.mockClear();
});

it('We can check if the consumer called the class constructor', () => {
  const soundPlayerConsumer = new SoundPlayerConsumer();
  expect(SoundPlayer).toHaveBeenCalledTimes(1);
});

it('We can check if the consumer called a method on the class instance', () => {
  const soundPlayerConsumer = new SoundPlayerConsumer();
  const coolSoundFileName = 'song.mp3';
  soundPlayerConsumer.playSomethingCool();
  expect(mockPlaySoundFile).toHaveBeenCalledWith(coolSoundFileName);
});

ModuleFactory

jest.mock(path, moduleFactory) 能接收 模块工厂 参数。 模块工厂是一个函数,这个函数会返回 mock。

javascript 复制代码
import SoundPlayer from './sound-player';
const mockPlaySoundFile = jest.fn();
jest.mock('./sound-player', () => {
  return jest.fn().mockImplementation(() => {
    return {playSoundFile: mockPlaySoundFile};
  });
});

这里要注意的一点是 const mockPlaySoundFile = jest.fn(); 命名必须以mock开头。 由于我们之前说过的jest.mock存在提升问题,会被提升到文件顶部执行,真实的执行顺序其实是下面这样的。

javascript 复制代码
jest.mock('./sound-player', () => {
  return jest.fn().mockImplementation(() => {
    return {playSoundFile: mockPlaySoundFile};
  });
});
import SoundPlayer from './sound-player';
const mockPlaySoundFile = jest.fn();

正常情况下此时 mockPlaySoundFile 访问不到,会直接报错,但 jest 约定了会跳过 mock 开头的变量检查。

除此之外我们还可以在beforeAll()里面为不同的用例,mock不同的实现。

javascript 复制代码
import SoundPlayer from './sound-player';
import SoundPlayerConsumer from './sound-player-consumer';

jest.mock('./sound-player');

describe('When SoundPlayer throws an error', () => {
  beforeAll(() => {
    SoundPlayer.mockImplementation(() => {
      return {
        playSoundFile: () => {
          throw new Error('Test error');
        },
      };
    });
  });

  it('Should throw an error when calling playSomethingCool', () => {
    const soundPlayerConsumer = new SoundPlayerConsumer();
    expect(() => soundPlayerConsumer.playSomethingCool()).toThrow();
  });
});

除了 mockImplementation() 外还有 mockImplementationOnce() 可选,感兴趣的可以去看下 官方文档

javascript 复制代码
const mockFn = jest
  .fn()
  .mockImplementationOnce(cb => cb(null, true))
  .mockImplementationOnce(cb => cb(null, false));

mockFn((err, val) => console.log(val)); // true
mockFn((err, val) => console.log(val)); // false

对于非 default export 的类,这个时候需要返回一个对象,这个对象上有一个 key 和这个类的名字要一样。

javascript 复制代码
import {SoundPlayer} from './sound-player';
jest.mock('./sound-player', () => {
  return {
    SoundPlayer: jest.fn().mockImplementation(() => {
      return {playSoundFile: () => {}};
    }),
  };
});

SpyOn

比较推荐的做法是通过spyOn来直接替换某个方法的实现。

javascript 复制代码
import SoundPlayer from './sound-player';
import SoundPlayerConsumer from './sound-player-consumer';

const playSoundFileMock = jest
  .spyOn(SoundPlayer.prototype, 'playSoundFile')
  .mockImplementation(() => {
    console.log('mocked function');
  }); // comment this line if just want to "spy"

it('player consumer plays music', () => {
  const player = new SoundPlayerConsumer();
  player.playSomethingCool();
  expect(playSoundFileMock).toHaveBeenCalled();
});

对于 static or getter and setter methods

javascript 复制代码
export default class SoundPlayer {
  constructor() {
    this.foo = 'bar';
  }

  playSoundFile(fileName) {
    console.log('Playing sound file ' + fileName);
  }

  get foo() {
    return 'bar';
  }

  static brand() {
    return 'player-brand';
  }
}
test.js 复制代码
import SoundPlayer from './sound-player';
import SoundPlayerConsumer from './sound-player-consumer';

const staticMethodMock = jest
  .spyOn(SoundPlayer, 'brand')
  .mockImplementation(() => 'some-mocked-brand');

const getterMethodMock = jest
  .spyOn(SoundPlayer.prototype, 'foo', 'get')
  .mockImplementation(() => 'some-mocked-result');

it('custom methods are called', () => {
  const player = new SoundPlayer();
  const foo = player.foo;
  const brand = SoundPlayer.brand();

  expect(staticMethodMock).toHaveBeenCalled();
  expect(getterMethodMock).toHaveBeenCalled();
});

Module

Axios

对于模块的 mock,这里我们以 axios 为例子。 假定有个从 API 获取用户数据的类。 该类用 axios 向服务端发起请求,然后返回 data,其中包含所有用户的属性:

users.js 复制代码
import axios from 'axios';

class Users {
  static all() {
    return axios.get('/users.json').then(resp => resp.data);
  }
}

export default Users;

在单元测试中我们肯定要避免真的发起请求,这时候可以 mock axios,返回假数据用于测试

users.test.js 复制代码
import axios from 'axios';
import Users from './users';

jest.mock('axios');

test('should fetch users', () => {
  const users = [{name: 'Bob'}];
  const resp = {data: users};
  // TS环境下会报类型错误
  axios.get.mockResolvedValue(resp);

  // 你也可以使用下面这样的方式:
  axios.get.mockImplementation(() => Promise.resolve(resp))

  return Users.all().then(data => expect(data).toEqual(users));
});

如果请求方式都一致,可通过 url 再细分,返回不同的 response

javascript 复制代码
 axios.get.mockImplementation(url => {
    switch (url) {
        case '/url-1':
            return Promise.resolve(response1)
        case '/url-2':
            return Promise.resolve(response2)
        ...
    }
})

但是上面 axios.get.mockResolvedValue(resp);这种写法在使用了ts环境下会报错,因为axios.get并没有mockResolvedValue这个方法,所以在ts环境下,可以用

scss 复制代码
jest.spyOn(axios, 'get').mockResolvedValue(resp);

或者

ini 复制代码
// 含有 jest 的类型提示
const mockedGet = jest.mocked(axios.get);
mockedGet.mockResolvedValue(resp); 

来替代。

Partical Module

有时候我们 module export 不止一个,但像 axios 那个例子里面是一整个模块都被 mock 了。

foo-bar-baz.js 复制代码
    export const foo = 'foo';
    export const bar = () => 'bar';
    export default () => 'baz';

因此我们可以通过 mock module 实现,然后return 一个具体对象的方式,从而做到部分 mock。

test.js 复制代码
    import defaultExport, {bar, foo} from '../foo-bar-baz';

    jest.mock('../foo-bar-baz', () => {
  
    const originalModule = jest.requireActual('../foo-bar-baz');

      //Mock the default export and named export 'foo'
      return {
        __esModule: true,
        ...originalModule,
        default: jest.fn(() => 'mocked baz'),
        foo: 'mocked foo',
      };
    });

    test('should do a partial mock', () => {
      const defaultExportResult = defaultExport();
      expect(defaultExportResult).toBe('mocked baz');
      expect(defaultExport).toHaveBeenCalled();

      expect(foo).toBe('mocked foo');
      expect(bar()).toBe('bar');
});

HTTP Request

就像上面 Mock Module中提到的,项目中不可避免的会涉及到Http请求,我们在针对包含 Http 请求的代码进行测试时,必须要把相关的请求给 Mock 掉。这里我们针对不同的情况进行介绍。

Axios

针对 axios 的 mock 上面已经介绍过了。

Mock Request Func

假设现在有个请求,需要获取用户角色信息

apis/user.ts 复制代码
export const getUserRole = async () => {
  return axios.get<GetUserRoleRes>("https://mysite.com/api/role");
};

我们可以简单粗暴的通过 spyOn 直接 Mock apis/user.ts 里的 getUserRole 方法。

php 复制代码
import * as userUtils from "apis/user";

jest.spyOn(userUtils, "getUserRole").mockResolvedValueOnce({
  data: { userType: "admin" },
} as AxiosResponse);

Mock Http

假设现在有个请求,需要获取用户角色信息

apis/user.ts 复制代码
export const getUserRole = async () => {
  return axios.get<GetUserRoleRes>("https://mysite.com/api/role");
};

我们可以不 Mock 任何函数实现,只对 Http 请求进行 Mock。

但是需要安装 mswmsw (Mock Service Worker) 可以拦截指定的 Http 请求,有点类似 Mock.js,是做测试时一个非常强大好用的 Http Mock 工具。

  1. 安装 msw 后,新建一个mockServer文件夹,在下面添加 handlers.ts 文件,该文件默认 export 所有 Http 请求的 Mock Handler
dart 复制代码
import { rest } from "msw";

const handlers = [
  rest.get("https://mysite.com/api/role", async (req, res, ctx) =>  
  {
    return res(
      ctx.status(200),
      ctx.json({
        userType: "user",
      })
    );
  }),
];

export default handlers;
  1. 新建 mockServer/server.ts,使用上面导出的handles
javascript 复制代码
import { setupServer } from "msw/node";
import handlers from "./handlers";

const server = setupServer(...handlers);

export default server;
  1. tests/jest-setup.ts里使用 mockServer
scss 复制代码
import server from "./mockServer/server";

beforeAll(() => {
  server.listen();
});

afterEach(() => {
  server.resetHandlers();
});

afterAll(() => {
  server.close();
});
  1. 然后在 jest.config.js 里添加 setupFilesAfterEnv 配置:
ini 复制代码
module.exports = {
  setupFilesAfterEnv: ['./tests/jest-setup.ts'],
};

setupFileAfterEnv 配置的文件会在jest被引入之后被执行。可以用来在测试开始前,mock一些全局通用的类或方法等,与它相似的还有一个 setupFiles 是在引入了测试环境之后执行。

  1. 准备测试环境
  2. setupFiles
  3. 引入测试框架(Jest)
  4. setupFileAfterEnv
  5. 执行xxx.test文件

这样一来,在所有测试用例中都能获得 handlers.ts 里的 Mock 返回了。如果你想在某个测试文件中想单独指定某个接口的 Mock 返回, 可以使用 server.use(mockHandler) 来实现。

声明一个 setup 函数,用于在每个用例前初始化 Http 请求的 Mock 返回。通过传不同值给 setup 就可以灵活模拟测试场景了。

dart 复制代码
import server from "./mockServer/server";

const setup = (userType) => {
  server.use(
    rest.get("https://mysite.com/api/role", async (req, res, ctx)    
 => {
      return res(ctx.status(200), ctx.json({ userType }));
    })
  );
};

it("user role is correct", async () => {
    setup("admin");
    // do request and expect something
});

这是最推荐的一种mock方式。

Mock扩展知识

针对对象中的属性,可以通过Object.definePropertity来mock

ini 复制代码
export const env = 'test';

test.ts

javascript 复制代码
import * as envUtils from 'utils/env';

const originEnv = envUtils.env;

describe("env", () => {
  afterEach(() => {
    Object.defineProperty(envUtils, 'env', {
      value: originEnv,
      writable: true,
    })
  })

  it('开发环境', () => {
    Object.defineProperty(envUtils, 'env', {
      value: 'dev',
      writable: true,
    })

    expect(envUtils.env).toEqual('dev');
  })

  it('正式环境', () => {
    Object.defineProperty(envUtils, 'env', {
      value: 'prod',
      writable: true,
    })

    expect(envUtils.env).toEqual('prod');
  })
});

要注意的是,使用Object.defineProperty,需要在最开始记录 env 的值,然后加一个 afterEach 在执行每个用例后又赋值回去,否则会造成用例之间的污染。

Mock提升

jest.mockjest.unmock 是一对非常特殊的 API,它们会被提升到所有 import 前。也就是说,测试代码看起是先 import 再 mock,而真实情况是,先 mock 了,再 import。

由于jest.mock是会被提升的,也就是一次性mock,但在实际代码中,可能会遇到需要多次 mock 一个 func 或 class 的情况。

举个例子

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

假如我们想测试一下不同环境下的一些行为:

javascript 复制代码
describe('环境', () => {
  it('开发环境', () => {
    // Mock config.getEnv => 'dev'
    // ...
  })

it('正式环境', () => {
    // Mock config.getEnv => 'prod'
    // ...
  })
})

jest 提供了另一个 API jest.doMock,它也会执行 Mock 操作,但是不会被提升。利用这个特性再加上内联 require 就可以实现多次 Mock 的效果了。

javascript 复制代码
    describe("doMock config", () => {
      beforeEach(() => {
        // 这里一共引用了两次 utils/env,因此要用 jest.resetModules 来重置前一次引入的模块内容。
        jest.resetModules();
      })

      it('开发环境', () => {
        jest.doMock('utils/env', () => ({
           __esModule: true,
           config: {
             getEnv: () => 'dev'
           }
      }));

      const { config } = require('utils/env');

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

    it('正式环境', () => {
        jest.doMock('utils/env', () => ({
          __esModule: true,
          config: {
            getEnv: () => 'prod'
          }
        }));

        const { config } = require('utils/env');

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

上面的doMock写起来比较复杂,还有一种方式是通过我们前面提到过的jest.spyOn

javascript 复制代码
export const config = {
  getEnv() {
    // 很复杂的逻辑...
    return 'test'
  }
}
test.ts 复制代码
import { config } from "utils/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');
  })
});

还可以用 spyOn 来监听函数以及对象属性的 getter 来修改返回值

jest.spyOn(envUtils, 'env', 'get').mockReturnValue('dev')

对于直接导出的函数 export const getEnv = () => 'test', 直接转成对象就可以继续用spyOn了

test.ts 复制代码
import * as envUtils from 'utils/env';

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

    expect(envUtils.getEnv()).toEqual('dev')
  })

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

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

Matcher

介绍几个常用的断言匹配器, 更多Matcher可以参考 官方文档

toBe

expect(can.name).toBe('pamplemousse');

toContain

expect(getAllFlavors()).toContain('lime');

toContainEqual

验证数组值是否一致,会递归地检查每个值是否一致,而不只是对比引用

ini 复制代码
describe('my beverage', () => {  
    test('is delicious and not sour', () => {  
        const myBeverage = {delicious: true, sour: false};  
        expect(myBeverages()).toContainEqual(myBeverage);  
    });  
});

toHaveBeenCalled

至少被调用一次

expect(mockFunc).toHaveBeenCalled();

toBeGreaterThan(number)

至少被调用 x 次

expect(mockFunc.mock.calls.length).toBeGreaterThan(2);

toHaveBeenCalledWith

使用指定的参数至少调用了一次该函数

expect(mockFunc).toHaveBeenCalledWith(arg1, arg2);

toHaveBeenCalledTimes(number)

被调用了确切的次数

expect(callback).toHaveBeenCalledTimes(1);

React

你的测试越接近软件的使用方式,这些测试越能给你信心 --- Kent C. Dodds

Dom/Component

要测试 Dom 或者 Component,首先需要能获取到对应的dom内容,在 jest 中,我们可以使用相关的库来支持这些功能, @testing-library/reactenzyme这两个库它们都提供了在 jsDOM 中渲染组件的方法,可以让我们对渲染结果进行断言。

  1. @testing-library/react是一个非常轻量级的React组件测试库,你可以用它提供的api,通过 text 或者 id 等等查找到页面具体的元素,然后模拟用户跟这个元素去交互(包括输入文本,点击等等),模拟真实用户使用应用程序从而进行测试。

添加 @testing-library/react

yarn add --dev @testing-library/react

下面是官方文档给出的例子

CheckboxWithLabel.jsx 复制代码
import {useState} from 'react';

export default function CheckboxWithLabel({labelOn, labelOff}) {
  const [isChecked, setIsChecked] = useState(false);

  const onChange = () => {
    setIsChecked(!isChecked);
  };

  return (
    <label>
      <input type="checkbox" checked={isChecked} onChange={onChange} />
      {isChecked ? labelOn : labelOff}
    </label>
  );
}
CheckboxWithLabel-test.js 复制代码
import {cleanup, fireEvent, render} from '@testing-library/react';
import CheckboxWithLabel from '../CheckboxWithLabel';

it('CheckboxWithLabel changes the text after click', () => {
      const {queryByLabelText, getByLabelText} = render(
        <CheckboxWithLabel labelOn="On" labelOff="Off" />,
      );

      expect(queryByLabelText(/off/i)).toBeTruthy();
      fireEvent.click(getByLabelText(/off/i));
      expect(queryByLabelText(/on/i)).toBeTruthy();
});
  1. enzyme React官方并不推荐使用这个库,好像是因为这个库对React新特性的支持并不好,而且现存很多问题。这里不做过多介绍,简单放一个例子。

添加 enzyme yarn add --dev enzyme

CheckboxWithLabel-test.js 复制代码
import Enzyme, {shallow} from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
import CheckboxWithLabel from '../CheckboxWithLabel';

Enzyme.configure({adapter: new Adapter()});

it('CheckboxWithLabel changes the text after click', () => {
      // Render a checkbox with label in the document
      const checkbox = shallow(<CheckboxWithLabel labelOn="On" labelOff="Off" />);

      expect(checkbox.text()).toBe('Off');

      checkbox.find('input').simulate('change');

      expect(checkbox.text()).toBe('On');
});

Redux

对于Redux的测试, 官方是这么说的:

我们建议通过集成测试来测试使用 Redux 的 React 组件,这些测试包括断言页面的展示和交互是否正常,旨在验证当用户以给定方式与应用程序交互时,应用程序的行为符合预期。

官方针对使用 Redux 的应用编写测试的建议是:

倾向于编写整体性的集成测试。对于使用 Redux 的 React 应用,使用一个真实的 store 实例包裹被测试的组件来渲染一个 Provider。把Redux中的 API 调用 mock 掉,然后按照真实的用户行为进行测试(比如click事件后触发某个reducer),这样你的应用代码就不需要改变,只需要断言 UI 是否被正确更新。

如果需要,可以对纯函数(例如复杂的 reducer 或 selector)进行基本的单元测试。不过更多情况下,这些实现细节已经被集成测试覆盖了。

不要尝试 mock selector 函数或 react-redux 的钩子函数!因为太多了!而且会让你对测试的正确性丧失信心

集成测试的关键点有 2 个:

  • 像真实用户那样去和组件交互
  • Mock Http 请求(外部依赖)

假设现在有一个展示用户信息的组件 <UserDisplay /> ,这个组件里用到了Redux,在reducer里存在一个获取用户信息的api。

首先添加用于测试的自定义 render 函数, 自定义 render 的作用就是: 创建一个使用 redux 的环境,用 <Wrapper /> 包裹传入的业务组件,并且可以让我们决定当前 redux 的初始状态。 然后在测试时,我们就可以使用自定义的 render 来渲染组件。

utils/test-utils.tsx 复制代码
    import React, { PropsWithChildren } from 'react'
    import { render } from '@testing-library/react'
    import type { RenderOptions } from '@testing-library/react'
    import { configureStore } from '@reduxjs/toolkit'
    import type { PreloadedState } from '@reduxjs/toolkit'
    import { Provider } from 'react-redux'

    import type { AppStore, RootState } from '../app/store'

    // 这个 interface 扩展了 RTL(react test library) 的默认 render 选项,同时允许用户指定其他选项,例如 initialState 和 store
    interface ExtendedRenderOptions extends Omit<RenderOptions, 'queries'> {
      preloadedState?: PreloadedState<RootState>
      store?: AppStore
    }

    export function renderWithProviders(
      ui: React.ReactElement,
      {
        preloadedState = {},
        // 自动创建一个 store 实例,如果没有传入 store
        store = configureStore({ reducer, preloadedState }),
        ...renderOptions
      }: ExtendedRenderOptions = {}
    ) {
      function Wrapper({ children }: PropsWithChildren<{}>): JSX.Element {
        return <Provider store={store}>{children}</Provider>
      }

      // 返回一个对象,其中包含 store 和所有的 RTL 查询函数
      return { store, ...render(ui, { wrapper: Wrapper, ...renderOptions }) }
    }

使用渲染函数进行测试

features/users/tests/UserDisplay.test.tsx 复制代码
    import React from 'react'
    import { rest } from 'msw'
    import { setupServer } from 'msw/node'
    import { fireEvent, screen } from '@testing-library/react'
    // 我们使用自定义的 render 函数,而不是 RTL 的 render
    import { renderWithProviders } from '../../../utils/test-utils'
    import UserDisplay from '../UserDisplay'

    // 我们使用 msw 来拦截测试期间的网络请求,并在 150ms 后返回响应 'John Smith'
    // 当收到对 `/api/user` 端点的 get 请求时
    export const handlers = [
      rest.get('/api/user', (req, res, ctx) => {
        return res(ctx.json('John Smith'), ctx.delay(150))
      })
    ]

    const server = setupServer(...handlers)

    // 在所有测试开始之前启用 API mock
    beforeAll(() => server.listen())

    // 在每个测试之后关闭 API mock
    afterEach(() => server.resetHandlers())

    // 所有测试结束时关闭 API mock
    afterAll(() => server.close())

    test('fetches & receives a user after clicking the fetch user button', async () => {
      renderWithProviders(<UserDisplay />)

      // 初始时,应该展示 'No user',并且不在获取用户信息
      expect(screen.getByText(/no user/i)).toBeInTheDocument()
      expect(screen.queryByText(/Fetching user\.\.\./i)).not.toBeInTheDocument()

      // 点击 'Fetch user' 按钮后,应该展示 'Fetching user...'
      fireEvent.click(screen.getByRole('button', { name: /Fetch user/i }))
      expect(screen.getByText(/no user/i)).toBeInTheDocument()

      // 在一段时间后,应该收到用户信息
      expect(await screen.findByText(/John Smith/i)).toBeInTheDocument()
      expect(screen.queryByText(/no user/i)).not.toBeInTheDocument()
      expect(screen.queryByText(/Fetching user\.\.\./i)).not.toBeInTheDocument()
    })

这个集成测试主要做了这么几件事:

  • 使用 msw Mock Http 请求
  • 渲染 <UserDisplay /> 组件
  • fireEvent模拟用户操作,点击按钮获取用户信息
  • 断言页面展示是否正常

Hook

本章节内容来自 某大佬的文章

src/hooks/useCounter.ts 复制代码
    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;

这个 Hook 很简单,就是经典的计数器,拥有增加、减少、设置和重置 4 个操作。

有些同学会觉得 hook 不就是纯函数么?为什么不能直接像纯函数那样去测呢?

这是由于hook里用到了 useState,而 React 规定 只有在组件中才能使用这些 Hooks,所以这样测试的结果就是会直接报错。

当然我们可以通过 mock 来处理掉 useState,但 hook 太多了,我们不可能 mock 每一个 React 提供的 API。

测试的初衷是为了带我们带来强大的代码信心,我们在编写测试时,需要时刻避免过度关注测试代码的细节

因此可以把 hook 放在组件里进行测试,来模仿真实使用hook的场景。

比较常见的做法是,我们可以编写一个组件,在组件里放一些Button,通过把要测试的hook绑定到 click 事件上,然后模拟触发 Click 事件,来测试这些hook。

javascript 复制代码
    import useCounter from "hooks/useCounter";
    import { render, screen } from "@testing-library/react";
    import userEvent from "@testing-library/user-event";
    import React from "react";

    // 测试组件
    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();
      });
    });

上面这种做法还是有些麻烦,需要添加 button 还要绑定事件,我们还可以直接操作 inc, dec, set 和 reset 这几个函数。

我们可以创建一个 setup 函数,在里面生成组件,然后把 useCounter 的结果返回出来就可以了:

下面代码中的act 方法 是为了确保回调里的异步逻辑走完再执行后续代码

ini 复制代码
    import useCounter from "hooks/useCounter";
    import { act, render } from "@testing-library/react";
    import React from "react";

    const setup = (initialNumber: number) => {
      const returnVal = {};

      const UseCounterTest = () => {
        const [counter, utils] = useCounter(initialNumber);

        Object.assign(returnVal, {
          counter,
          utils,
        });

        return null;
      };

      render(<UseCounterTest />);

      return returnVal;
    };

    describe("useCounter", () => {
      it("可以做加法", async () => {
        const useCounterData: any = setup(0);
        // 为了确保回调里的异步逻辑走完再执行后续代码
        act(() => {
          useCounterData.utils.inc(1);
        });

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

      it("可以做减法", async () => {
        const useCounterData: any = setup(0);

        act(() => {
          useCounterData.utils.dec(1);
        });

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

      it("可以设置值", async () => {
        const useCounterData: any = setup(0);

        act(() => {
          useCounterData.utils.set(10);
        });

        expect(useCounterData.counter).toEqual(10);
      });

      it("可以重置值", async () => {
        const useCounterData: any = setup(0);

        act(() => {
          useCounterData.utils.inc(1);
          useCounterData.utils.reset();
        });

        expect(useCounterData.counter).toEqual(0);
      });
    });

上面这种做法,其实 @testing-library 已经把上面的步骤封装成了一个公共函数 renderHook

tests/hooks/useCounter/renderHook.test.ts 复制代码
   import { renderHook } from "@testing-library/react-hooks";
   import useCounter from "hooks/useCounter";
   import { act } from "@testing-library/react";

   describe("useCounter", () => {
      it("可以做加法", () => {
        const { result } = renderHook(() => useCounter(0));
       const [value, hooks] = result.current;
        act(() => {
          hooks.inc(1);
        });

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

      it("可以做减法", () => {
        const { result } = renderHook(() => useCounter(0));

        act(() => {
          result.current[1].dec(1);
        });

        expect(result.current[0]).toEqual(-1);
      });

      it("可以设置值", () => {
        const { result } = renderHook(() => useCounter(0));

        act(() => {
          result.current[1].inc(10);
        });

        expect(result.current[0]).toEqual(10);
      });

      it("可以重置值", () => {
        const { result } = renderHook(() => useCounter(0));

        act(() => {
          result.current[1].inc(1);
          result.current[1].reset();
        });

        expect(result.current[0]).toEqual(0);
      });
   });

总结一下 React Hook 的测试方法:

  1. 声明 setup,在里面通过渲染测试组件为 React Hook 提供 React 组件环境
  2. 把 React Hook 的返回结果返回给每个用例
  3. 每个用例从 setup 返回结果中拿到 React Hook 的返回值,并对其进行测试

Test coverage

当你 run test后,正常情况在控制台里会得到如下输出

  • %stmts 语句覆盖率(statement coverage):是不是每个语句都执行了
  • %Branch 分支覆盖率(branch coverage):是不是每个if条件分支代码块都执行了
  • %Funcs 函数覆盖率(function coverage):是不是每个函数都调用了
  • %Lines 代码行覆盖率(line coverage):是不是每一行都执行了

这里有必要解释下 stmts和lines的区别,我当初看到这里是一脸问号,每个语句和每一行没啥区别啊,Google后我看到一个回答

大致意思就是说,一行可以有多个可执行语句

如果觉得控制台的覆盖率看着不舒服,可以在 test 的 script 脚本里 加上一句 --coverage,这样当你 run test 之后,项目根目录下会生成一个叫 coverage 文件夹。

里面有一个 index.html文件 ,可以直接用浏览器打开,它提供了一个更友好的覆盖率展示页面。

Trouble Shooting

异常调试有两种方案,第一个是按照 官方文档 的方法。

在项目下的.vscode文件夹下面,新建launch.json文件,把文档中给出的配置json 贴进去。然后正常打断点,然后在vscode中按F5进行调试即可。

第二种是添加VsCode的插件,打完断点,通过插件的功能直接run debugger。

  • Jest
  • Jest Runner

Run single test

我们可以通过配置 test match 参数来实现 run 某个模块某个用例的单侧,可以参考 官方文档, 不过最简单的办法是通过添加 VsCode 插件来实现。

  • Jest
  • Jest Runner

插件可以帮我们省去复杂的配置步骤。

Performance Optimization

本章节内容转自 某大佬的文章,可以点击直达原文查看。

要解决 Jest 的性能问题,我们得了解一下 Jest 是怎么运行的。之前偶然在 YouTube (opens new window)上看到 Jest 作者非常详细地讲述整个 Jest 执行流程,在这里我只做了一下简单地搬运。英语比较好的同学可以直接看视频进行了解。 从上图可以看到,最影响 Jest 性能的有 3 个地方:

  1. 使用 jest-haste-map 生成虚拟文件系统
  2. 多线程执行测试任务
  3. 转译 JavaScript 代码

虚拟文件系统

如果要在热更新时修改文件,脚手架都要遍历一次项目文件,非常损耗性能。特别在一些文件特别多的 应用中,电脑分分钟就卡得动不了。

为了解决这个问题,Facebook 团队就想到了一个方法 ------ 虚拟文件系统 。原理很简单:在第一次启动时遍历整个项目,把文件存储成 Map 的形式, 之后文件做了改动,那么只需增量地修改这个 Map 就可以了。 他们把这个工具命名为 Haste Map,中文翻译可以理解为快速生成 Map 的东西)。

这种思路不仅可以用于热更新场景,还能应用在所有监听文件改动的场景,其中一种就是 npx jest --watch 这个场景。

因此,上面图中刚开始时,Jest 就用 jest-haste-map 生成了一次虚拟文件系统,这样后续的过滤、搜索文件就非常快速了。这也是为什么执行第一个测试用例时速度比较慢的原因。 这一步的性能我们无法优化。

多线程

Jest 还有一个非常强大的功能,利用 Node.js 的 Worker 开启多个线程来执行测试用例。对于一些大型项目(几千个测试用例)来说,这能提升不少效率。

但线程不是越多越好,每开一个线程都需要额外的开销。如果不做任何配置,那么 Jest 默认最大的 Worker 数是 CPU 数 - 1。其中的 1 用于运行 jest-cli, 剩下的都拿来执行测试用例。由于之前我们一直没有对 maxWorkers 进行配置,所以默认会用最多的 Worker,执行这么几十个简单的测试会非常慢。

通常来说,单个测试用例速度应该要做到非常快的,尽量不写一些耗时的操作,比如不要加 setTimeoutnfor 循环等。 所以,理论上,测试数量不多的情况下单线程就足够了。这里我们可以把 jest.config.js 配置改为用单线程:

jest.config.js 复制代码
module.exports = {
  maxWorkers: 1
}

在流水线中,Jest 也推荐使用单线程来跑单测和集成测试:jest --runInBand,其中 runInBandmaxWorkers: 1 效果是一样的。

WARNING

我试了一下在以前的 Intel Mac 里单线程的速度比多线程快了一倍,而 M1 的 Mac 上则是相反,多线程比单线程快。所以,还是要自己的机器的情况来决定使用多少个 Worker。

M1 Macbook Pro,单线程:

M1 Macbook Pro,多线程:

文件转译

最后一个性能优化点就是转译速度(图中第 11 步)。需要注意的是 Jest 是会边执行测试用例边转译 JavaScript。

有的同学会问了:既然 Jest 刚开始遍历项目来生成虚拟文件系统,为什么不顺便把转译的工作做了呢?当然是因为慢了。 首先,对于很多业务项目来说,测试并不会很多。可能就测几个 utils 下的函数,那如果把项目的文件都转译一次,会把很多没用到测试的业务代码也转译。

这些同学还不甘心:那可以在拿到测试文件后,分析出这个文件的依赖,再来做转译(在第 7,8 步)了,然后再执行测试呀?理论上是可以的。但是, JavaScript 引入模块的方式实在是太多了 ,先不说 amd, es6, umd, cmd, abcd 这么多的引入方式了,单单这个就很难处理:

typescript 复制代码
// ├── index.ts
// └── instances
//     ├── api1.ts
//     ├── api2.ts
//     ├── api3.ts
//     └── api4.ts

// index.ts
const services = (require as any).context('./instances', false, /.*/)

console.log(services); // api1, api2, api3, api4

所以说,通过文件找依赖的方式不是很可靠,有太多不确定因素,最终 Jest 还是选择 "执行到那个文件再做转译" 的方法。

原理说完了,下面来看看怎么提高转译效率。在前面的章节里,我们说到当今 JavaScript 的转译器有很多种,不仅可以用 tscbabel 来转, 还能用别的语言写的转译器 swc 以及 esbuild 来转。

如果想用 esbuild 做转译,可以看 esbuild-jest (opens new window)这个库。这里我用 @swc/jest (opens new window) 做例子, 先安装依赖:

kotlin 复制代码
npm i -D @swc/core@1.2.165 @swc/jest@0.2.20

然后在 jest.config.js 里添加:

java 复制代码
module.exports = {
  // 不用 ts-jest
  // preset: "ts-jest", 

  transform: {
    // 使用 swc 转译 JavaScript 和 TypeScrit
    "^.+\.(t|j)sx?$": ["@swc/jest"],
    // 静态资源 stub 转译
    ".+\.(css|styl|less|sass|scss|png|jpg|ttf|woff|woff2)$":
      "jest-transform-stub",
  },
}

配置非常简单,我们来看看使用 ts-jest 以及 @swc/jest 两者的对比。

ts-jest:

@swc/jest:

总结一下Jest 运行过程中 有 3 个地方比较耗性能:

  1. 生成虚拟文件系统。 在执行第一个测试会很慢
  2. 多线程。 生成新线程耗费的资源,不过,不同机器的效果会不一致
  3. 文件转译。 Jest 会在执行到该文件再对它进行转译

解决的方法有:

  1. 无解,有条件的话拆解项目吧
  2. 具体情况具体分析,要看机器的执行情况,多线程快就用多线程,单线程快就用单线程
  3. 使用 esbuild-jest@swc/jest 等其它高效的转译工具来做转译

Jest实践

在整理这篇文章的过程中,我发现社区似乎很少有关于单元测试实践的文章,大多数都是介绍下用法,所以后面我会尝试出一篇Jest在实际项目中的实践(可能也不是很规范,因为我也是单侧萌新),从CRUD这种我们最常见的业务场景起步。

相关推荐
666和7776 小时前
C#的单元测试
开发语言·单元测试·c#
明月看潮生9 小时前
青少年编程与数学 02-004 Go语言Web编程 20课题、单元测试
开发语言·青少年编程·单元测试·编程与数学·goweb
程序猿000001号2 天前
探索Python的pytest库:简化单元测试的艺术
python·单元测试·pytest
星蓝_starblue3 天前
单元测试(C++)——gmock通用测试模版(个人总结)
c++·单元测试·log4j
whynogome3 天前
单元测试使用记录
单元测试
字节程序员3 天前
使用JUnit进行集成测试
jmeter·junit·单元测试·集成测试·压力测试
love静思冥想4 天前
Java 单元测试中 JSON 相关的测试案例
java·单元测试·json
撒币使我快乐5 天前
写Jest时必须setTimeout的情况
react.js·jest
乐闻x6 天前
如何使用 TypeScript 和 Jest 编写高质量单元测试
javascript·typescript·单元测试·jest
Cachel wood6 天前
Vue.js前端框架教程4:Vue响应式变量和指令(Directives)
前端·vue.js·windows·python·单元测试·django·前端框架