工具 – Vitest 与单元测试
前言
Vitest 是一款配搭 Vite 的前端单元测试工具,可以用于取代 Jasmine 和 Jest。
我先聊一下测试,每当添加新代码或修改旧代码后,我们多少都得测试一下,以确保功能正确才能交付。
这种测试通常只是写几个简单的调用,换换参数,console 看看输出。没有问题也就 ok 了。
大部分情况下并不需要用到工具。但是如果我们经常要添加或修改同一份代码的话(比如维护一个库),这种测试就变得非常繁琐,
每改一次就得重新测一遍,多麻烦啊,这时就需要一些测试工具来帮忙了。
测试工具的工作就是把我们上面原本随意写的调用,换参数,console 变成一种规范,并且用代码写好封存起来。每一次只要一个 command 所有测试都会重跑一遍。
参考
YouTube – Jest Crash Course Jest 基本用法
YouTube – Why Vitest Is Better Than Jest Vitest 取代 Jest
Unit Test vs e2e Test
unit test 单元测试指的是很小单元的测试。比如测试一个方法。
用人工测试的话,它的过程是调用方法,console 看看 result。
e2e (end-to-end) 测试的是 final UI。比如一个 login 过程。
用人工测试的话,它的过程是进入页面,填 form,submit。查看结果。
单元测试是我们在写代码时做的小测试。e2e 则是我们完成了一个小组件/页面后对整体的一个最终测试。
傻傻分不清的测试工具
Jasmine 是最古老的单测代码。
Karma 也很古老,它是运行 Jasmine 的环境 (Angular2 默认就是 Jasmine + Karma 做单元测试,但最近 Karma 已经被淘汰了)
Jest 的单测代码和 Jasmine 雷同,同时它具备 Karma 运行环境的能力。所以单单一个 Jest 就可以完成单元测试了。它是 Facebook 出品,在 React 生态很火的。
Vitest 和 Jest 雷同,单侧代码也和 Jasmine 雷同,也具备运行环境。所以它可以完全替代 Jest。它是 Vue 生态,基于 Vite 的单侧工具。
jsdom 一般上,Jest 和 Vitest 都是用 Node.js 测试 JavaScript。如果涉及到 DOM、BOM 需要搭配 jsdom。它和 Jest 是同一个生态的,但 Vitest 也是可以用它。
Protractor 是 e2e 测试工具。Angular2 默认用它,但目前已经被淘汰了。
Cypress 是 e2e 测试工具,可以用来替代 Protractor。
Mocha 类似 Jest、Vitest。但比较轻,不完善。Cypress 是基于 Mocha 的哦。
Jasmine vs Jest vs Vitest
Jasmine 比较古老一些。以前我写 Angular 时也玩过它。虽然它古老,但也不过时,因为所有的测试工具使用方式和语法都大同小异。你会了一个再学另一个只是分分钟的事情。
题外话:目前 Angular 默认使用 Jasmine + Karma 做测试,v16 后也支持 Jest + jsdom,而随着 Karma 被丢弃了,未来默认会改成 Jasmine + Web Test Runner,想了解更多可以看这两篇。
Angular Testing in 2023 - Past, Present, and Future
Docs – Moving Angular CLI to Jest and Web Test Runner
Jest 是 Facebook 出的,伴随 React,所以它目前是最受欢迎的。但它有一个缺点,那就是对 ES Module 支持还不够完善。Github – Meta: Native support for ES Modules
Vitest 则属于 Vue、Vite 生态。比较新,非常适合搭配 Vite 来使用。
选择
没什么好选的,你用 React 就用 Jest,用 Vue 就用 Vitest,用 Angular 就 Jasmine。语法都差不多的。你看一下 Jest migrate to Vitest 就体会到了。
Get Started
follow Vite 教程 创建一个项目。
安装 Vitest
yarn add vitest --dev
module & test module
创建 core.ts 和 core.test.ts
core.ts 是我们的逻辑代码,core.test.ts 是我们的测试代码。测试文件的命名规范是加一个 '.test' 在中间。
JavaScript 一个 file 等于一个 module,通常我们是 1 个 module file 对应一个 test file。
core.ts
function sum(...numbers: number[]): number { return numbers.reduce((acc, curr) => acc + curr, 0); }
我们写一个简单的 sum 方法,功能是把所有传入的数目累加起来。
core.test.ts
如果没有使用测试工具,我们大概会这样测试。
写一段
const value = sum(1, 2, 3); console.log(value); // 6
然后用游览器或 Node.js 运行一下,看看 log 出来的结果是不是 6,是的话就 ok 了,测试代码可以删了,happy ending。
直到某天发现 sum 的实现有问题 (maybe 性能),我们修改了 sum 的代码,然后呢,我们就得再写一遍测试代码,再运行看看结果。这就是一个重复性的低能工作。
那使用测试工具呢?
core.test.ts 代码长这样
import { describe, it, expect } from "vitest"; import { sum } from "./core"; describe(`test 'sum' function`, () => { it("should return sum of numbers", () => { const value = sum(1, 2, 3); expect(value).toBe(6); }); it("should return zero when no passing any numbers", () => { const value = sum(); expect(value).toBe(0); }); });
我们一个一个看
describe 是我们要测试的单元,这个颗粒度是我们自己决定的,例子中就是测试 sum 这个方法。(注: describe 允许嵌套。所以我们可以任意去 grouping 分组)
it 是我们要测试的 condition。比如同一个方法,我们要测试多种不同的参数调用。那么每一个 it 就表示一种不同的 "情况"
expect 是我们期望的结果。在每一个 it 中,我们可能有多个 expect,上面例子比较简单,所以只有一个 expect,下面会有更复杂的例子。
测试代码写好后,我们运行 command
yarn vitest
效果
绿色勾勾表示测试成功!
假如,sum 的代码写得不对。
运行测试的结果
各种错误信息。
至此,我们成功把测试封装了起来,以后即便修改 sum 的代码,我们也不需要重复写测试咯~
expect
expect 跟随着许多验证的方式
toBe
它是 Object.is
expect("1").toBe("1"); // pass expect("1").toBe(1); // fail expect({}).toBe({}); // fail expect([]).toBe([]); // fail expect(NaN).toBe(NaN); // pass expect(0).toBe(-0); // fail
toEqual
equal 适合用于 array 和 object 的对比,不看指针,看值。
expect([1, 2, 3]).toEqual([1, 2, 3]); // pass expect({ values: [1, 2, 3] }).toEqual({ values: [1, 2, 3] }); // pass expect(1).toEqual("1"); // fail
它只是把引用类型当值类型对比,可没有自动转换类型哦。
toBeTruthy & toBeFalsy
expect(true).toBeTruthy(); // pass expect(1).toBeTruthy(); // pass expect("0").toBeTruthy(); // pass expect([]).toBeTruthy(); // pass expect({}).toBeTruthy(); // pass expect(false).toBeFalsy(); // pass expect(undefined).toBeFalsy(); // pass expect(null).toBeFalsy(); // pass expect("").toBeFalsy(); // pass expect(0).toBeFalsy(); // pass
它会自动转换类型,类似于 if (value)
toBeNull、toBeUndefined、toBeNaN
expect(NaN).toBeNaN(); expect(null).toBeNull(); expect(undefined).toBeUndefined();
它没有 toBeNullOrUndefined 哦。可以这样实现
expect(null == null).toBeTruthy(); expect(undefined == null).toBeTruthy();
only、skip
it 和 describe 都有 .only 和 .skip 方法。
it.only("test1", () => { expect(1).toBe(1); }); it("test2", () => { expect(1).toBe(1); });
这个 file 只有 it.only 会执行测试。
it.skip("test1", () => { expect(1).toBe(1); }); it("test2", () => { expect(1).toBe(1); }); it("test3", () => { expect(1).toBe(1); });
skip 表示不执行这个测试,其它的都执行。
before、after
beforeEach、afterEach 会在每一个 it 执行前触发,我们可以用来做初始化,或者 reset shared variable 等等。
describe(`test 'sum' function`, () => { let sharedValue = 0; beforeEach(() => { sharedValue = 0; }); afterEach(() => { sharedValue = 0; }); it("should return sum of numbers", () => { const value = sum(1, 2, 3); expect(value).toBe(6); }); it("should return zero when no passing any numbers", () => { const value = sum(); expect(value).toBe(0); }); });
beforeAll、afterAll 则只会触发一次。
Mock
mock 是模仿/模拟的意思。模仿什么呢?函数。我们看例子体会。
mock callback function for test caller
我们有一个 forEach 的函数,它接收一个 callback function,在 looping 时把当前 value 和 index 传入到 callback 中
export function forEach<T>( values: T[], callbackfn: (value: T, index: number) => void ): void { for (let index = 0; index < values.length; index++) { const value = values[index]; callbackfn(value, index); } }
我们要测试 callback funcion 是否正确被调用。
describe(`test 'forEach' function`, () => { it("should call callbackfn with correct parameters", () => { var values = ["a", "b", "c"]; const callbackfn = vi.fn(); // mock 一个 callback function,它算是一个 proxy 函数,它除了可以调用之外,还有 tracking 信息 forEach(values, callbackfn); // 执行 forEach,执行完后,我们的 callbackfn 就有了调用的数据 expect(callbackfn).toBeCalledTimes(3); // 查看是否被调用了 3 次 expect(callbackfn).nthCalledWith(1, "a", 0); // 第一次调用时,传入的 parameters 是否是 'a', 0 expect(callbackfn).nthCalledWith(2, "b", 1); expect(callbackfn).nthCalledWith(3, "c", 2); // mock 有许多数据可以用,上面都是一些封装好,常用到的验证,如果我们要更多的验证可以直接访问 mock 内的数据 // 等价于 .toBeCalledTimes(3) expect(callbackfn.mock.calls.length).toBe(3); // 等价于 .nthCalledWith(1, "a", 0) expect(callbackfn.mock.calls[0][0]).toBe("a"); expect(callbackfn.mock.calls[0][1]).toBe(0); }); });
mock function for cut off dependence(斩断依赖关系)
我们想测试函数 A,但函数 A 内部依赖了函数 B。假如函数 B 的代码有问题,那将导致函数 A 也测试失败。这样就很乱。所以要斩断依赖。
单元测试就要确保测试足够 "单元",不可以有依赖,怎么斩断依赖呢?答案是 mock 函数 B。
export function generateNextNumber(random: () => number): number { return random() + 1; }
generateNextNumber 依赖了一个 random 函数。
const random = vi.fn(); // mock random random.mockReturnValue(10); // 配置 random 将返回 number 10 const value = generateNextNumber(random); // 调用 generateNextNumber expect(value).toBe(11); // 最终 10 + 1 = 11
通过 mockReturnValueOnce 我们还可以指定每一次返回值都不一样。
const random = vi.fn(); random.mockReturnValueOnce(10).mockReturnValueOnce(20); expect(random()).toBe(10); expect(random()).toBe(20);
mock module by spyOn
上面 random 函数是通过参数传进去的,如果它不是参数而是 import from another module 我们如何 mock 呢?
这时需要 spyOn
import { it, expect, vi } from "vitest"; import { generateNextNumber } from "./core"; import * as shared from "./shared"; const random = vi.spyOn(shared, "random"); random.mockReturnValue(10); it("test", () => { expect(generateNextNumber()).toBe(11); });
spyOn 可以 mock 一个对象中的方法。
mock inner function
参考: Stack Overflow – Jest mock inner function
在同一个 file 里,函数间有依赖。这个不能直接 spyOn mock。因为 funcA 直接调用 funcB 的。
解决方法有 2 个,第一个是把 funcB 移出去另一个 file。
第二个是不要直接调用 funcB,改成 import self 调用
这样就可以 spyOn mock 了。
mock localStorage
Vitest by default 的环境是 Node.js。如果我们代码有用到 DOM、BOM 的话需要借助 jsdom
yarn add jsdom --dev
vite.config.ts
import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { environment: 'jsdom', }, });
它和 Vite 公用同一个 config file,但 import 改成 vitest/config。.
下面这个函数依赖了 localStorage
export function generateNextNumber(): number { const currentNumber = +localStorage.getItem("currentNumber")!; return currentNumber + 1; }
测试代码
import { it, expect, vi } from "vitest"; import { generateNextNumber } from "./core"; const getItem = vi.spyOn(Storage.prototype, "getItem"); getItem.mockReturnValue("10"); it("test", () => { expect(generateNextNumber()).toBe(11); });
Vitest UI
如果看不惯 command line 的 test message。Vitest 还有 UI 版本哦。
安装
yarn add @vitest/ui --dev
在启动 command 加上 --ui 就可以了
vitest --ui
效果
Debug Mode
在 test file 打断点。
开启 JavaScript Debug Terminal
运行 yarn vitest 就可以了
效果