# 单元测试入门
# 测试四部曲
准备数据
调用测试功能(函数)
验证功能输出
拆卸(避免影响后续测试)
第一个测试案例:
import { test, expect } from "vitest";
import { reset, useTodoStore } from "./todo";
test("add item", () => {
// 准备数据
const todoStore = useTodoStore();
const todo = { title: "吃饭" };
// 调用
todoStore.addTodo(todo);
// 验证
expect(todoStore.todos[0].title).toBe(title);
// 拆卸
reset();
});
# 准备数据的方式
# 内联建立
直接把数据放在测试实例中
案例:
test("add item", () => {
// 准备数据
const todo = { title: "吃饭" };
});
优缺点
- 编写方便, 适合刚刚创建的测试
- 代码重复, 每个测试实例都要写一遍, 当数据结构发生变化将造成大面积报错 例如 addItem 的参数除了 title, 需要增加一个 value
- 当准备数据的逻辑变复杂时, 影响测试可读性
# 委托建立
委托一个函数对数据进行创建
案例:
function createTodo(title: string) {
return {
title,
};
}
test("add item", () => {
// 准备数据
const todo = createTodo("吃饭");
});
优缺点:
- 解决重复代码
- 通过函数命名提高可读性
# 隐式建立
通过测试框架 API 创建数据
案例:
function createTodo(title: string) {
return {
title,
};
}
describe("todo", () => {
beforeEach(() => {
// 准备数据
const todo = createTodo("吃饭");
});
test("add item", () => {});
});
优缺点:
- 代码精简, 适合多个测试实例通用的的数据
- 容易堆积代码不需要的数据
- 测试实例逻辑被切割, 可读性变差
充分使用测试层级, 对测试进行模块细分, 针对性使用隐式数据将是最优解
# 后门建立
也就是调用非公开的 API 方式来准备测试数据
比如需要测试删除数据, 但是没有增加数据的函数, 只能从后门操作数据, 案例:
test("remove item", () => {
// 准备数据
const todoStore = useTodoStore();
const todo = { title: "吃饭" };
todoStore.todos.push(todo);
// 调用
todoStore.removeTodo(todo);
// 验证
expect(todoStore.todos.length).toBe(0);
});
优缺点:
- 优先使用前三种方式, 后门建立数据的测试往往是脆弱的测试
- 在功能完善前可以先借助后门测试, 后面功能完善再移除后门数据
# 最小准备数据原则
准备数据应该和当前测试功能贴合, 去掉不需要的数据保持代码可读性
案例:
describe("todo", () => {
// 不合理的测试
test("buy book", () => {
const user = new User("罗健文", 12, "ljw@tys.com", "广州");
const product = new Product("Book", 1, "顺丰空运");
const result = user.buy(product);
expect(result).toBe("User 罗健文 bought Book");
});
// 方法一, 使用参数默认值
test("v1", () => {
const user = new User("罗健文");
const product = new Product("Book");
const result = user.buy(product);
expect(result).toBe("User 罗健文 bought Book");
});
// 方法二, 使用委托建立数据, 隐藏不需要的属性
test("v2", () => {
const user = new createUser("罗健文");
const product = new createProduct("Book");
const result = user.buy(product);
expect(result).toBe("User 罗健文 bought Book");
});
// 方法三, 创建虚拟对象
test("v3", () => {
const user = new User("罗健文");
const product = { name: "Book" } as Product;
const result = user.buy(product);
expect(result).toBe("User 罗健文 bought Book");
});
});
function createUser(name: string) {
return new User(name, 12, "ljw@tys.com", "广州");
}
function createProduct(name: string) {
return new Product(name, 1, "顺丰空运");
}
单元测试需要非常注意可读性和可维护性, 如果测试代码维护成本太高, 将会导致测试代码成为负担影响开发
# 程序的间接输入
上面的测试案例都是直接输入, 数据创建完成后直接提供到调用阶段进行使用
当数据是由函数调用其他模块生成的数据, 不需要测试实例创建的就是间接数据, 根据依赖类型的不同, 提供以下案例
# 依赖变量
- index.ts
name 和 gold 分别是 string 和 number 类型的 const 常量, 被应用到了函数 tellName
import { gold, name } from "./config";
export function tellName() {
return `${name} have ${gold} gold`;
}
- case.spec.ts
通过importOriginal可以拿到全部原始数据, 比如 gold 不需要 mock 但是需要被测试函数使用, 就需要importOriginal获取到原始值
import { describe, test, expect, vi } from "vitest";
import { tellName } from "./index";
// mock数据, 对config.ts导出的数据做定制
vi.mock("./config", async (importOriginal) => {
const data: any = await importOriginal();
return {
...data,
name: "roman",
};
});
describe("变量形式", () => {
test("属性", async () => {
// 调用
const r = tellName();
// 验证
expect(r).toBe("roman have 3 gold");
});
});
# 依赖对象
对象的值可以直接在准备数据阶段进行赋值
- index.ts
import config from "./config";
export function tellAge() {
if (config.allow) {
return 2;
}
return "不知道";
}
export function tellByFn() {
return config.getAge() === "18" ? "yes" : "no";
}
- case.spec.ts
import { describe, test, expect, vi } from "vitest";
import { config } from "./config";
import { tellAge, tellByFn } from ".";
describe("对象形式", () => {
test("属性", async () => {
// 准备数据
config.allow = false;
// 调用
const r = await tellAge();
// 验证
expect(r).toBe("不知道");
});
test("方法", async () => {
// 准备数据
config.getAge = () => "18";
// 调用
const r = await tellByFn();
// 验证
expect(r).toBe("yes");
});
});
# 依赖函数
- index.ts
import { userAge } from "./user";
export function doubleUserAge(): number {
return userAge() * 2;
}
- case.spec.ts
import { doubleUserAge } from "./index";
import { describe, test, expect, vi } from "vitest";
// mock数据, 对user.ts导出的数据做定制
vi.mock("./user", () => {
return {
userAge: () => 2,
};
});
describe("间接input", () => {
test("frist", () => {
// 调用
const r = doubleUserAge();
// 验证
expect(r).toBe(4);
});
});
# 第三方库
以 axios 库为例
- index.ts
import axios from "axios";
interface User {
name: string;
age: number;
}
export async function doubleUserAge() {
const user: User = await axios.get("/user/1");
return user.age * 2;
}
- case.spec.ts
import { doubleUserAge } from "./index";
import { describe, test, expect, vi } from "vitest";
import axios from "axios";
// 对第三方库做mock
vi.mock("axios");
describe("第三方库", () => {
test("frist", async () => {
// 准备数据
vi.mocked(axios.get).mockResolvedValue({ age: 2 });
// 调用
const r = await doubleUserAge();
// 验证
expect(r).toBe(4);
});
});
# 环境变量
- index.ts
export function doubleUserAge(): number {
return Number(process.env.USER_AGE) * 2;
}
- case.spec.ts
使用vi.stubEnv设置环境变量, 并且使用afterEach对每个测试实例进行 reset 数据
vi.stubEnv适用于 webpack 和 vite 的环境变量
import { doubleUserAge } from "./index";
import { describe, test, expect, vi, afterEach } from "vitest";
afterEach(() => {
// 移除数据
vi.unstubAllEnvs();
});
describe("环境变量", () => {
test("frist", () => {
// 准备数据
vi.stubEnv("USER_AGE", "2");
// 调用
const r = doubleUserAge();
// 验证
expect(r).toBe(4);
});
});
# 全局变量
和环境变量用法相似, 使用vi.stubGlobal
# mock 注意事项
- vi.mock 将对整个测试文件生效
- vi.mock 编译阶段会提高到顶部优先处理
- 可以使用 vi.mocked 对每个测试实例 mock 值
- 一个测试文件里测试内容应该相关, 所以 vi.mock 能适应大部分场景
- 当逻辑代码和间接输入数据融合的时候, 不好进行 mock 处理, 可以加一个间接层如上面案例中的 config.ts 和 user.ts, 将依赖数据拆分.
# 依赖注入
先了解两个概念
- 依赖倒置原则
高层模块不应该依赖低层模块, 两者应该通过抽象接口联系, 降低模块间的耦合.
A(高层) --依赖--> B(低层)
修改成
A(高层) --依赖--> C(接口) <--实现-- B(低层)
- 程序接缝
这个 C(接口) 就是程序的接缝, 通过创建接缝可以轻松改变/替换 B(低层) 模块的实现, 不影响其他代码
# 分析函数
import { readFileSync } from "fs";
export function readAndProcessFile(filePath: string): string {
const content = readFileSync(filePath);
return `${content} -> test unit`;
}
fs 模块就是函数强依赖的低层, 如果要测试就必须对 fs 模块做测试替身处理
# 函数的依赖注入
对函数进行改造, 将强依赖模块以参数形式抽离
export interface FileReader {
read(filePath: string): string;
}
export function readAndProcessFile(
filePath: string,
fileReader: FileReader
): string {
const content = fileReader.read(filePath);
return `${content} -> test unit`;
}
- case.spec.ts
import { describe, test, expect } from "vitest";
import { readAndProcessFile, FileReader } from "./index";
describe("依赖注入", () => {
test("fn", () => {
class TexFileReader implements FileReader {
read(filePath: string) {
return "ljw";
}
}
const result = readAndProcessFile("test", new TexFileReader());
expect(result).toBe("ljw -> test unit");
});
});
# 分析类
跟函数一样, 我们也可以对类进行分析改造
import { readFileSync } from "fs";
export class ReadAndProcessFile {
run(filePath: string) {
const content = readFileSync(filePath);
return `${content} -> test unit`;
}
}
# 构造函数(必备参数)
export interface FileReader {
read(filePath: string): string;
}
export class ReadAndProcessFile {
private _fileReader: FileReader;
constructor(fileReader: FileReader) {
this._fileReader = fileReader;
}
run(filePath: string) {
const content = this._fileReader.read(filePath);
return `${content} -> test unit`;
}
}
import { describe, test, expect } from "vitest";
import { ReadAndProcessFile, FileReader } from "./class";
test("构造函数", () => {
class TexFileReader implements FileReader {
read(filePath: string) {
return "ljw";
}
}
const result = new ReadAndProcessFile(new TexFileReader()).run("test");
expect(result).toBe("ljw -> test unit");
});
# 属性
export class ReadAndProcessFile {
private _fileReader: FileReader;
constructor() {}
run(filePath: string) {
const content = this._fileReader.read(filePath);
return `${content} -> test unit`;
}
set fileReader(fileReader: FileReader) {
this._fileReader = fileReader;
}
}
import { describe, test, expect } from "vitest";
import { ReadAndProcessFile, FileReader } from "./class";
test("属性", () => {
class TexFileReader implements FileReader {
read(filePath: string) {
return "ljw";
}
}
const readAndProcessFile = new ReadAndProcessFile();
readAndProcessFile.fileReader = new TexFileReader();
const result = readAndProcessFile.run("test");
expect(result).toBe("ljw -> test unit");
});
# 方法
export class ReadAndProcessFile {
private _fileReader: FileReader;
constructor() {}
run(filePath: string) {
const content = this._fileReader.read(filePath);
return `${content} -> test unit`;
}
setFileReader(fileReader: FileReader) {
this._fileReader = fileReader;
}
}
import { describe, test, expect } from "vitest";
import { ReadAndProcessFile, FileReader } from "./class";
test("方法", () => {
class TexFileReader implements FileReader {
read(filePath: string) {
return "ljw";
}
}
const readAndProcessFile = new ReadAndProcessFile();
readAndProcessFile.setFileReader(new TexFileReader());
const result = readAndProcessFile.run("test");
expect(result).toBe("ljw -> test unit");
});
# 验证的方式
# 状态验证
状态指的是属性和数据结构
状态验证也就是不管实现细节, 我们只需要确定与系统交互之后, 最后系统输出的状态符合我们的预期, 也就是黑盒测试, 其好处是我们可以随意重构实现细节. 上面的例子都是状态验证
# 行为验证
验证对象之间的交互是否按预期进行, 比如函数是否被调用, 其调用参数是什么等. 属于白盒测试
- 缺点
破坏封装性, 需要暴露内部细节, 当代码需要修改重构时需要同步修改(如改变函数名称), 维护成本高
丧失测试有效性, 即使行为是正确的, 如果内部细节不正确, 得到的结果也会不符合预期
# 使用时机
优先使用状态验证
无法获取状态的时候, 如调用后端接口并且接口没有返回信息
时间成本, 行为测试因为不用验证数据, 检测费时短
登录案例:
- index.ts
import { Login } from "login";
const state = {
tip: "",
};
export function login(name: string, password: string) {
const res = Login(name, password);
if (res) {
state.tip = "success";
}
}
export function getTip() {
return state.tip;
}
- case.spec.ts
import { describe, test, expect, vi } from "vitest";
import { getTip, login } from "./index";
import { Login } from "login";
vi.mock("login", () => {
return {
Login: vi.fn().mockReturnValue(true),
};
});
describe("行为验证", () => {
test("frist", () => {
login("ljw", "pass");
// 行为测试
expect(Login).toBeCalled();
expect(Login).toBeCalledWith("ljw", "pass");
expect(Login).toBeCalledTimes(1);
// 状态测试
expect(getTip()).toBe("success");
});
});
# 测试的其他场景
# 保持可预测性
当一个功能的返回值是确定的可预测的, 我们才能对功能进行测试
# 外部依赖
如 api, 第三方服务, 数据库数据等可以直接对外部依赖进行测试替身, 确定功能的输入
# 随机数
export function generateRandomString(length: number): string {
let result = "";
const characters = "abcdefghijkLmnopqrstuvwxyz";
for (let i = 0; i < length; i++) {
const randomIndex = Math.floor(Math.random() * characters.length); //生成字母
result += characters.charAt(randomIndex); //将指定位置上的字符添加到结果字符串中
}
return result;
}
import { describe, test, expect, vi } from "vitest";
import { checkFriday, generateRandomString } from "./index";
describe("可预测性", () => {
test("随机数", () => {
// 确定随机数返回值
vi.spyOn(Math, "random").mockImplementationOnce(() => 0.1);
vi.spyOn(Math, "random").mockImplementationOnce(() => 0.2);
const result = generateRandomString(2);
expect(result).toBe("fc");
});
});
# 日期
export function checkFriday(): string {
const today = new Date();
if (today.getDay() === 5) {
return "happy";
} else {
return "sad";
}
}
import { describe, test, expect, vi } from "vitest";
import { checkFriday, generateRandomString } from "./index";
describe("可预测性", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
test("日期", () => {
let result = "";
// 设置日期
vi.setSystemTime(new Date(2023, 6, 6));
result = checkFriday();
expect(result).toBe("sad");
vi.setSystemTime(new Date(2023, 6, 7));
result = checkFriday();
expect(result).toBe("happy");
});
});
# 快速反馈
# 外部依赖
如 api, 第三方服务, 数据库数据等可以直接对外部依赖进行测试替身处理, 做出快速响应
# time
export class User {
id: string;
constructor(id: string) {
this.id = id;
}
fetchData(callback: (data: string) => void, delay: number): void {
setTimeout(() => {
const data = `Data for user with id: ${this.id}`;
callback(data);
}, delay);
}
}
export function sayHi() {
setInterval(() => {
console.log("hi");
}, 100);
}
import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
import { User, delay, fetchUserData, sayHi } from "./index";
describe("快速反馈", () => {
test("setTimeout", () => {
const user = new User("1");
const cb = vi.fn();
const wait = 1000;
user.fetchData(cb, wait);
// 时间快进
vi.advanceTimersByTime(wait);
// 直接跳到下一个异步结束时间
// vi.advanceTimersToNextTimer();
// 跳完全部异步
// vi.runAllTimers()
expect(cb).toBeCalledWith("Data for user with id: 1");
});
test("setInterval", () => {
vi.spyOn(console, "log");
sayHi();
vi.advanceTimersToNextTimer();
expect(console.log).toBeCalled();
});
});
# promise
export function fetchUserData() {
return new Promise((resolve, reject) => {
resolve("1");
});
}
export function delay(time: number) {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve("1");
}, 1000);
});
}
import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
import { User, delay, fetchUserData, sayHi } from "./index";
describe("快速反馈", () => {
test("promise", async () => {
const result = await fetchUserData();
expect(result).toBe("1");
});
test("delay", async () => {
const result = delay(1000);
vi.advanceTimersToNextTimer();
expect(result).resolves.toBe("1");
});
});
# 测试替身的要点
测试替身核心能力就是将被测功能与其所依赖的模块进行隔离.
# 加速执行测试
Promise / setTimeout / setInterval / 复杂计算等需要程序等待或长时间运算的情况, 都可以抽离成单独模块通过测试替身进行处理.
# 使执行变得确定
对随机数和日期等无法确定的数据, 可以通过测试替身返回确定值使功能具有可预测性.
# 模拟特殊情况
例如模拟抛出错误
# 检测函数使用情况
前面介绍的行为测试
← 初识单元测试 OpenLayers 基础 →