# 2024年,重新探索前端单元测试之路,从入门到精通01-Vitest
十一、实现一个自己的 mini-test-runner
1、目标
- 通过自己实现一个
mini-test-runner
来理解测试框架的api
使用
2、实现
- 首先模拟
vitest
的方法,例如test/it
,describe
,expect
,生命周期函数等
// 1.spec.js
import { test, run,describe } from "./core.js"
describe("", () => {
test("first test case", () => {
console.log("1. first test case")
})
test.only("second test case", () => {
console.log("2. second test case")
})
})
run()
// core.js
let testCallbacks = []
let onlyCallbacks = []
let describeCallbacks = []
let beforeAllCallbacks = []
let beforeEachCallbacks = []
let afterEachCallbacks = []
let afterAllCallbacks = []
export function describe(name, callback) {
describeCallbacks.push({ name, callback })
callback()
}
export function test(name, callback) {
testCallbacks.push({ name, callback })
}
test.only = function (name, callback) {
onlyCallbacks.push({ name, callback })
}
export const it = test
export function expect(pre) {
return {
toBe: cur => {
if (pre === cur) {
console.log("通过")
} else {
throw new Error(`不通过,pre: ${pre} 不等于 cur: ${cur}`)
}
},
toEqual: cur => {
if (typeof cur === "object") {
throw new Error(`不通过,类型只能为object`)
}
if (pre === cur) {
console.log("通过")
} else {
throw new Error(`不通过,pre: ${pre} 不等于 cur: ${cur}`)
}
},
}
}
export function beforeAll(fn){
beforeAllCallbacks.push(fn)
}
export function beforeEach(fn){
beforeEachCallbacks.push(fn)
}
export function AfterEach(fn){
afterEachCallbacks.push(fn)
}
export function AfterAll(fn){
afterAllCallbacks.push(fn)
}
export function run() {
const tests = onlyCallbacks.length > 0 ? onlyCallbacks : testCallbacks
for (const { name, callback } of tests) {
try {
callback()
console.log(`ok: ${name}`)
} catch (error) {
console.log(`error: ${name}`)
}
}
}
主要的执行逻辑在run
方法,通过收集test
等内部的回调去执行,并且在执行test.only
的时候需要去判断一下
const tests = onlyCallbacks.length > 0 ? onlyCallbacks : testCallbacks
这样就可以只执行only
的回调了!!!
接下来我们继续补充生命周期的执行方法,其实就是改造run
方法
export function run() {
// 执行总的beforeAll回调
beforeAllCallbacks.forEach(fn => fn())
const tests = onlyCallbacks.length > 0 ? onlyCallbacks : testCallbacks
for (const { name, callback } of tests) {
// 执行beforeEach的回调
beforeEachCallbacks.forEach(fn => fn())
try {
callback()
console.log(`ok: ${name}`)
} catch (error) {
console.log(`error: ${name}`)
}
// 执行afterEach的回调
afterEachCallbacks.forEach(fn => fn())
}
// 执行总的afterAll回调
afterAllCallbacks.forEach(fn => fn())
}
如此一来,我们对于基本的vitest
的api
的功能就已经完成,接下来我们继续完善,我们写一个插件,让它能够自动运行测试代码,而不是我们手动调用run
方法
3、实现自动运行测试代码
import fs from "fs/promises"
import { glob } from "glob" // 一个匹配文件名的插件
// 查找所有以 `.spec.js` 结尾的测试文件
const testFiles = glob.sync("**/*.spec.js", { ignore: "node_modules/**" })
console.log("testFiles", testFiles)
运行代码返回结果如下
使用fs
模块对文件进行处理
import fs from "fs/promises"
import { glob } from "glob"
// 查找所有以 `.spec.js` 结尾的测试文件
const testFiles = glob.sync("**/*.spec.js", { ignore: "node_modules/**" })
// 运行所有测试文件
for (const testFile of testFiles) {
const fileContent = await fs.readFile(testFile, "utf-8")
console.log('',fileContent);
}
运行代码返回结果如下
接下来我们需要自动去运行代码,我们把run
方法去掉,得需要程序自动去运行
并且我们不只有一个测试文件,所以需要对所有测试文件的内容进行打包合并到一个文件内去运行,这里我使用edbulid
import fs from "fs/promises"
import { glob } from "glob"
import { build } from "esbuild"
// 查找所有以 `.spec.js` 结尾的测试文件
const testFiles = glob.sync("**/*.spec.js", { ignore: "node_modules/**" })
// 运行所有测试文件
for (const testFile of testFiles) {
const fileContent = await fs.readFile(testFile, "utf-8")
await runModule(fileContent)
}
async function runModule(fileContent) {
try {
const result = await build({
stdin: {
contents: fileContent,
resolveDir: process.cwd(),
},
write: false,
bundle: true,
target: "esnext",
})
const transformedCode = result.outputFiles[0].text
console.log("result", transformedCode)
} catch (error) {
console.error("Error executing module:", error)
}
}
运行后看见确实合并在一起了
4、最终代码
import fs from "fs/promises"
import { glob } from "glob"
import { build } from "esbuild"
// 查找所有以 `.spec.js` 结尾的测试文件
const testFiles = glob.sync("**/*.spec.js", { ignore: "node_modules/**" })
// 运行所有测试文件
for (const testFile of testFiles) {
const fileContent = await fs.readFile(testFile, "utf-8")
await runModule(fileContent + "import { run } from './core.js'; run()")
}
async function runModule(fileContent) {
try {
// 转换代码为 CommonJS 格式并捆绑依赖
const result = await build({
stdin: {
contents: fileContent,
resolveDir: process.cwd(),
},
write: false,
bundle: true,
target: "esnext",
})
// 获取转换后的代码
const transformedCode = result.outputFiles[0].text
// 执行转换后的代码
const runCode = new Function(transformedCode)
runCode()
} catch (error) {
console.error("Error executing module:", error)
}
}
最后我们使用暴力的方式,去运行测试代码
所有文件都测试完毕
十二、准备测试数据的三种方式
1、内联数据
it('should add a todo', () => {
// 低层次的代码
const todo: Todo = {
title: "吃饭",
content: "今天要和小明去吃饭",
}
addTodo(todo)
expect(todos[0]).equal(todo)
})
缺点:
- 重复代码
- 当逻辑复杂的时候 就会导致单元测试可读性变差
2、隐式的方式
describe("隐式", () => {
let todoA = {}
let todoB = {}
let todoC = {}
beforeEach(() => {
todoA
todoB
todoC
})
it('(should )', () => {
});
});
3、工厂函数
需要考虑的两个问题
- 代码重复的问题
- 可读性的问题
// 创建的工厂函数可以导出使用,更加简洁方便
export function createTodo(title: string, content: string = "这是一个 todo 的内容") {
return {
title,
content,
state: State.active,
};
}
it("normal addTodo", () => {
// given
// 中高层次的代码
// const todo = createTodo("吃饭");
// todo.content = "nihaoya";
// todo.state = State.removed
const todo = createRemovedTodo();
// when
addTodo(todo);
// then
expect(todos[0]).toEqual(todo);
});
it(" addTodo with top command", () => {
// given
const todo = createTodo("吃饭", "dddd");
// when
addTodo(todo);
// then
expect(todos[0].title).toEqual("吃饭");
});
it(" addTodo with reverse command", () => {
// given
const todo = createTodo("吃饭");
// when
addTodo(todo);
// then
expect(todos[0].title).toEqual("饭吃");
});
4、最小准备测试数据原则
注意:尽量准备数据的时候,不要准备与程序无关的
describe("User", () => {
it("should buy a product", () => {
// 准备测试数据 - 包含无关的信息
const user = new User("Alice", 25, "alice@example.com", "123 Main St");
const product = new Product("Book", 15, "A great book on software testing");
// 测试购买功能
const result = user.buy(product);
const expectedResult = "User Alice bought Book";
expect(result).toBe(expectedResult);
});
it("should buy a product", () => {
const user = new User("oldTimer", 18, "cuixiaorui@heihei.com", "beijing");
const product = new Product("Book", 15, "a great book on frontEnd testing");
const result = user.buy(product);
expect(result).toBe("User oldTimer bought Book");
});
it("v1.0 修改业务代码本身的逻辑 ", () => {
// 测试也是业务代码的用户之一
// 测试可以驱动我们程序的设计
const user = new User("oldTimer");
const product = new Product("Book");
const result = user.buy(product);
expect(result).toBe("User oldTimer bought Book");
});
it("v2.0 委托 工厂函数 来隐藏不需要关心的属性", () => {
// 委托 来去隐藏不需要关心的属性
const user = createUser("oldTimer");
const product = createProduct("Book");
const result = user.buy(product);
expect(result).toBe("User oldTimer bought Book");
});
it("v3.0 虚拟对象的方式", () => {
// 虚拟对象的方式
const user = new User("oldTimer");
const product = { name: "Book" } as Product;
const result = user.buy(product);
expect(result).toBe("User oldTimer bought Book");
});
});
function createUser(name: string) {
return new User(name, 18, "cuixiaorui@heihei.com", "beijing");
}
function createProduct(name: string) {
return new Product(name, 15, "a great book on frontEnd testing");
}
十三、程序的间接输入
1、依赖函数调用-stub的应用
- 调用其他模块获取数据、也有可能是通过API获取的
import { fetchUserAge, userAge } from "./user";
// 直接 input
function add(a: number, b: number) {
return a + b;
}
// 间接的 input
export function doubleUserAge(): number {
return userAge() * 2;
}
export async function fetchDoubleUserAge(): Promise<number> {
const userAge = await fetchUserAge();
return userAge * 2;
}
export function userAge() {
// api
// return user.age
return 4;
}
// api.js
export function fetchUserAge(): Promise<number> {
return new Promise((resolve, reject) => {
setTimeout(() => {
return resolve(18);
}, 0);
});
}
-
解决:使用
vi.mock
1、通过去控制间接输入的值
(推荐)
import { beforeEach, vi, it, expect, describe } from 'vitest';
// 替换掉真实的逻辑实现
vi.mock("./user", () => {
return {
fetchUserAge: () => Promise.resolve(2),
};
});
describe("间接input", () => {
it("first", async () => {
const r = await fetchDoubleUserAge();
expect(r).toBe(4);
});
});
注意:vi.mock
会被提到顶部执行
2、在内部改写
import { beforeEach, vi, it, expect, describe } from 'vitest';
vi.mock("./user");
describe("间接input", () => {
it("first", async () => {
vi.mocked(userAge).mockReturnValue(2);
expect(userAge()).toBe(2);
});
});
3、 vi.doMock
的方式
describe("间接input", () => {
beforeEach(() => {
vi.doMock("./user", () => {
return {
userAge: () => 2,
};
});
});
it("first", async () => {
const { doubleUserAge } = await import("./index");
const r = await fetchDoubleUserAge();
expect(r).toBe(4);
});
});
2、第三方库、对象、class、常量
1、第三方模式的处理 如 axios
比如我们需要通过axios
去获取数据
// third-party-modules.ts
import axios from "axios";
interface User {
name: string;
age: number;
}
export async function doubleUserAge() {
// 调用了第三方模块
// const user: User = await axios("/user/1");
// 对象 让你直接调用对象上的方法
const user: User = await axios.get("/user/1");
return user.age * 2;
}
我们可以使用vi.mock
去模拟get
请求的返回值,使用mockResolvedValue
方法
import { test, vi, expect } from "vitest";
import { doubleUserAge } from "./third-party-modules";
import axios from "axios";
vi.mock("axios");
test("第三方模式的处理 axios", async () => {
// vi.mocked(axios).mockResolvedValue({ name: "oldTimer", age: 18 });
vi.mocked(axios.get).mockResolvedValue({ name: "oldTimer", age: 18 });
const r = await doubleUserAge();
expect(r).toBe(36);
});
2、使用class的形式
例如我们使用class的方式去获取返回值
// User.ts
export class User {
age: number = 18;
name:string = ""
getAge(){
return this.age
}
}
// use-class.ts
import { User } from "./User";
export function doubleUserAge(): number {
const user = new User();
console.log(user)
// return user.getAge() * 2;
return user.age * 2
}
我们可以使用vi.mock
去模拟class
,或者直接修改类的prototype
import { it, expect, describe, vi } from "vitest";
import { doubleUserAge } from "./use-class";
// 二 是使用vi.mock
vi.mock("./User", async (importOriginal) => {
return {
User: class {
age = 2
},
};
});
describe("使用class的形式", () => {
it("user age", () => {
// given
// 一个是修改方法
// User.prototype.getAge = () => 2;
// when
const age = doubleUserAge();
// then
expect(age).toBe(4);
});
});
3、使用对象的形式
// use-object.ts
import { config } from "./config";
export function tellAge() {
if (config.allowTellAge) {
return 18;
}
return "就不告诉你";
}
这个比较简单,我们直接设置对象的值为true
即可
import { it, expect, describe, vi } from "vitest";
import { tellAge } from "./use-object";
describe("使用对象的形式", () => {
it("allow ", () => {
config.allowTellAge = true;
const age = tellAge();
expect(age).toBe(18);
});
});
4、使用变量的方式
// config.ts
export const name = "oldTimer"
export const gold = 3
// use-variable.ts
import { name } from "./config";
export function tellName() {
return name + "-heiheihei";
}
这里我们使用vi.mock
给我们提供的importOriginal
参数字段来重新赋值这个文件
import { it, expect, describe, vi } from "vitest";
import { tellName } from "./use-variable";
import { name, gold } from "./config";
vi.mock("./config", async (importOriginal) => {
return { ...await importOriginal() as any, name: "xiaohong" };
});
describe("使用变量的形式", () => {
it("tell name ", () => {
console.log(gold);
// when
const name = tellName();
// then
expect(name).toBe("xiaohong-heiheihei");
});
});
3、环境变量-全局global
1、环境变量
// env.ts
export function doubleUserAge() {
return process.env.USER_AGE;
}
使用vi.stubEnv
去设置env
的数据,注意:需要在初始的时候清空使用vi.unstubAllEnvs
或者使用后清空
import { beforeEach, it, expect, vi, describe } from "vitest";
import { doubleUserAge } from "./env";
beforeEach(() => {
vi.unstubAllEnvs();
});
it("process", () => {
vi.stubEnv("USER_AGE", "99");
// import.meta.env vite webpack
// process.env.USER_AGE = "15";
const r = doubleUserAge();
console.log(r);
vi.unstubAllEnvs();
});
it("second", () => {
const r = doubleUserAge();
console.log(r);
});
这里还有import
导入,这里我们可以使用vi.stubEnv
或者使用vi.mock
import { vi, it, expect } from "vitest";
import { doubleUserAge, doubleUserAgeNew } from "./user";
import { userAge } from "./env";
vi.mock("./env");
it("doubleUserAge", () => {
vi.stubEnv("VITE_USER_AGE", "99");
const r = doubleUserAge();
expect(r).toBe(198);
vi.unstubAllEnvs();
});
it("doubleUserAgeNew", () => {
vi.mocked(userAge).mockReturnValue(2);
const r = doubleUserAgeNew();
expect(r).toBe(4);
});
2、全局变量
很简单,通过process.env
去获取就好
// user.ts
export function doubleUserAge() {
const userAge = localStorage.getItem("userAge");
return Number(userAge) * 2;
}
export function doubleInnerWidth() {
return innerWidth * 2;
}
这里使用vi.stubGlobal
去设置全局变量
import { vi, it, expect } from "vitest";
import { doubleInnerWidth, doubleUserAge } from "./user";
it("doubleUserAge", () => {
const r = doubleUserAge();
expect(r).toBe(36);
});
it("double innerWidth", () => {
// window
vi.stubGlobal("innerWidth", 200);
const r = doubleInnerWidth();
expect(r).toBe(400);
});
4、依赖注入
首先我们看看两个原则:
依赖倒置原则(Dependency Inversion Principle,简称DIP)是面向对象设计中的一个原则,它是SOLID原则中的一部分。DIP的核心思想是高层模块不应该依赖于低层模块,而是应该依赖于抽象接口。
程序接缝(Seams)是指在软件系统中可以插入变化的地方,也可以理解为在代码中可以进行修改的位置。程序接缝是敏捷开发中的一个重要概念,它有助于提高代码的可测试性、可扩展性和可维护性。
接下来看下面的例子:
DLL-function
// readAndProcessFile.ts
export interface FileReader {
read(filePath: string): string;
}
export function readAndProcessFile(
filePath: string,
fileReader: FileReader
): string {
const content: string = fileReader.read(filePath);
// 在实际的场景下可能 process 的过程会更复杂一点
return content + "-> test unit";
}
// index.ts
import { readAndProcessFile, FileReader } from "./readAndProcessFile";
import { readFileSync } from "fs";
class TextFileReader implements FileReader {
read(filePath: string) {
return readFileSync(filePath, { encoding: "utf-8" });
}
}
const result = readAndProcessFile("example.txt", new TextFileReader());
console.log(result);
测试方案
import { it, expect, describe } from "vitest";
import { readAndProcessFile, FileReader } from "./readAndProcessFile";
describe("di function", () => {
it("read and process file", () => {
class StubFileReader implements FileReader {
read() {
return "oldTimer";
}
}
const result = readAndProcessFile("./test", new StubFileReader());
expect(result).toBe("oldTimer-> test unit");
});
});
DLL-class
:这里分为三种,构造器、属性、以及方法
构造器:
export interface FileReader {
read(filePath: string): string;
}
// 构造器
export class ReadAndProcessFile {
private fileReader: FileReader;
constructor(fileReader: FileReader) {
// fileReader 是个必选项
this.fileReader = fileReader;
}
run(filePath: string) {
// const content = readFileSync(filePath, { encoding: "utf-8" });
const content = this.fileReader.read(filePath);
return content + "->unit test";
}
}
import { it, expect, describe } from "vitest";
import { FileReader, ReadAndProcessFile } from "./ReadAndProcessFile";
describe("di - class", () => {
it("构造器", () => {
class StubFileReader implements FileReader {
read(filePath: string): string {
return "oldTimer";
}
}
const readAndProcessFile = new ReadAndProcessFile(new StubFileReader());
expect(readAndProcessFile.run("./test")).toBe("oldTimer->unit test");
});
});
属性
export class ReadAndProcessFile {
run(filePath: string) {
const content = this.fileReader.read(filePath);
return content + "->unit test";
}
private _fileReader: FileReader;
get fileReader(): FileReader {
return this._fileReader;
}
set fileReader(fileReader: FileReader) {
this._fileReader = fileReader;
}
}
it("属性", () => {
class StubFileReader implements FileReader {
read(filePath: string): string {
return "oldTimer";
}
}
const readAndProcessFile = new ReadAndProcessFile();
readAndProcessFile.fileReader = new StubFileReader();
expect(readAndProcessFile.run("./test")).toBe("oldTimer->unit test");
});
方法
export class ReadAndProcessFile {
run(filePath: string) {
const content = this._fileReader.read(filePath);
return content + "->unit test";
}
private _fileReader: FileReader;
setFileReader(fileReader: FileReader) {
this._fileReader = fileReader;
}
}
it("方法", () => {
class StubFileReader implements FileReader {
read(filePath: string): string {
return "oldTimer";
}
}
const readAndProcessFile = new ReadAndProcessFile();
readAndProcessFile.setFileReader(new StubFileReader());
expect(readAndProcessFile.run("./test")).toBe("oldTimer->unit test");
});
以下内容等待更新...