2024年,重新探索前端单元测试之路,从入门到精通02-Vitest

607 阅读10分钟

# 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())
}

如此一来,我们对于基本的vitestapi的功能就已经完成,接下来我们继续完善,我们写一个插件,让它能够自动运行测试代码,而不是我们手动调用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)

运行代码返回结果如下

image-20240312154105534

使用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);
}

运行代码返回结果如下

image-20240312154535385

接下来我们需要自动去运行代码,我们把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)
  }
}

运行后看见确实合并在一起了

image-20240312162642156

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)
  }
}
​

最后我们使用暴力的方式,去运行测试代码

image-20240312163225596

所有文件都测试完毕

十二、准备测试数据的三种方式

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");
  });

以下内容等待更新...