On this page
使用 Mock 进行隔离测试
本指南基于 Deno 测试基础,重点介绍可帮助你在测试中隔离代码的 Mock 技术。
为了实现高效的单元测试,你经常需要“模拟”(mock)代码所交互的数据。Mock 是一种测试技术,通过用可控的模拟数据替代真实数据来测试代码。当测试与外部服务(比如 API 或数据库)交互的组件时,这尤为有用。
Deno 标准库提供了便捷的 Mock 工具,让你的测试更轻松编写、更可靠且执行更快。
监听 (Spying) Jump to heading
在 Deno 中,你可以使用 spy 监听函数调用情况。Spy 不会改变函数行为,但会记录如函数被调用次数及传入参数等重要信息。
通过使用 Spy,你可以检验代码是否与其依赖正确交互,而无需搭建复杂的基础设施。
下面示例测试了一个名为 saveUser() 的函数,它接受一个用户对象和一个数据库对象,然后调用数据库的 save 方法:
import { assertEquals } from "jsr:@std/assert";
import { assertSpyCalls, spy } from "jsr:@std/testing/mock";
// 定义类型以提升代码质量
interface User {
name: string;
}
interface Database {
save: (user: User) => Promise<User & { id: number }>;
}
// 待测试函数
function saveUser(
user: User,
database: Database,
): Promise<User & { id: number }> {
return database.save(user);
}
// 使用 mock 测试
Deno.test("saveUser 调用了 database.save", async () => {
// 创建一个带有 save 方法的 mock 数据库,save 方法被 spy 包裹
const mockDatabase = {
save: spy((user: User) => Promise.resolve({ id: 1, ...user })),
};
const user: User = { name: "Test User" };
const result = await saveUser(user, mockDatabase);
// 验证 mock 调用情况
assertSpyCalls(mockDatabase.save, 1);
assertEquals(mockDatabase.save.calls[0].args[0], user);
assertEquals(result, { id: 1, name: "Test User" });
});
我们从 Deno 标准库导入必要的函数,用于断言相等和创建及校验 spy 函数。
这个模拟数据库是一个替代真实数据库对象的站位符,带有一个被 spy 包裹的 save 方法。这个 spy 函数会跟踪该方法的调用次数、记录传入的参数,并执行其底层实现(这里返回了包含 user 及 id 的 Promise)。
测试中调用了带有模拟数据的 saveUser(),我们通过断言验证了:
save方法被调用了且仅调用了一次- 调用的第一个参数是我们传入的
user对象 - 返回结果包含原有的用户数据和新增的 ID
我们能够在无需搭建或清理复杂数据库状态的情况下,测试了 saveUser 功能。
清除 Spy Jump to heading
当多个测试都使用 spy 时,重要的是在测试之间重置或清除 spy,以避免相互干扰。Deno 测试库提供了使用 restore() 方法轻松恢复所有 spy 到原始状态。
下面示例演示如何在完成使用 spy 后清理它:
import { assertEquals } from "jsr:@std/assert";
import { assertSpyCalls, spy } from "jsr:@std/testing/mock";
Deno.test("spy 清理示例", () => {
// 创建一个监听函数的 spy
const myFunction = spy((x: number) => x * 2);
// 使用 spy
const result = myFunction(5);
assertEquals(result, 10);
assertSpyCalls(myFunction, 1);
// 测试完成后,恢复 spy
try {
// 使用 spy 的测试代码
// ...
} finally {
// 始终清理 spy
myFunction.restore();
}
});
方法的 spy 是可销毁的,可以用 using 关键字让它们自动恢复。这种做法避免了你必须用 try 代码块包裹断言,确保测试结束前方法能被恢复。
import { assertEquals } from "jsr:@std/assert";
import { assertSpyCalls, spy } from "jsr:@std/testing/mock";
Deno.test("使用可自动恢复的 spies", () => {
const calculator = {
add: (a: number, b: number) => a + b,
multiply: (a: number, b: number) => a * b,
};
// spy 会在超出作用域时自动恢复
using addSpy = spy(calculator, "add");
// 使用 spy
const sum = calculator.add(3, 4);
assertEquals(sum, 7);
assertSpyCalls(addSpy, 1);
assertEquals(addSpy.calls[0].args, [3, 4]);
// 不需要 try/finally 块,spy 会自动恢复
});
Deno.test("同时使用多个可自动恢复的 spies", () => {
const calculator = {
add: (a: number, b: number) => a + b,
multiply: (a: number, b: number) => a * b,
};
// 两个 spy 都会自动恢复
using addSpy = spy(calculator, "add");
using multiplySpy = spy(calculator, "multiply");
calculator.add(5, 3);
calculator.multiply(4, 2);
assertSpyCalls(addSpy, 1);
assertSpyCalls(multiplySpy, 1);
// 不需要清理代码
});
如果你有多个不支持 using 关键字的 spy,可以将它们保存在数组里,一次性调用恢复:
Deno.test("多个 spies 清理", () => {
const spies = [];
// 创建 spy
const functionA = spy((x: number) => x + 1);
spies.push(functionA);
const objectB = {
method: (x: number) => x * 2,
};
const spyB = spy(objectB, "method");
spies.push(spyB);
// 测试中使用 spies
// ...
// 测试结束时清理所有 spies
try {
// 使用 spies 的测试代码
} finally {
// 恢复所有 spies
spies.forEach((spyFn) => spyFn.restore());
}
});
正确清理 spies 能确保每个测试从干净的状态开始,避免测试间侧漏副作用。
Stub(存根) Jump to heading
虽然 spy 用于记录方法调用而不改变行为,但 stub 会完全替换原有实现。 Stub 是 mock 的一种形式,用于临时替换函数或方法实现,可用于模拟特定情况或预设返回值,也常用于重写依赖环境的功能。
在 Deno 中,你可以通过标准测试库的 stub 函数创建 stub:
import { assertEquals } from "jsr:@std/assert";
import { Stub, stub } from "jsr:@std/testing/mock";
// 定义类型以提升代码质量
interface User {
name: string;
role: string;
}
// 原始函数
function getCurrentUser(userId: string): User {
// 可能涉及数据库调用的实现
return { name: "Real User", role: "admin" };
}
// 待测试函数
function hasAdminAccess(userId: string): boolean {
const user = getCurrentUser(userId);
return user.role === "admin";
}
Deno.test("hasAdminAccess 使用 stub 用户", () => {
// 创建替代 getCurrentUser 的 stub
const getUserStub: Stub<typeof getCurrentUser> = stub(
globalThis,
"getCurrentUser",
// 返回非管理员的测试用户
() => ({ name: "Test User", role: "guest" }),
);
try {
// 使用 stub 函数测试
const result = hasAdminAccess("user123");
assertEquals(result, false);
// 测试中也能改变 stub 行为
getUserStub.restore(); // 移除第一个 stub
const adminStub = stub(
globalThis,
"getCurrentUser",
() => ({ name: "Admin User", role: "admin" }),
);
try {
const adminResult = hasAdminAccess("admin456");
assertEquals(adminResult, true);
} finally {
adminStub.restore();
}
} finally {
// 始终还原原始函数,避免影响其他测试
getUserStub.restore();
}
});
这里导入了必要函数,设置了一个可能调用数据库的原始 getCurrentUser 函数。
我们定义了待测试的 hasAdminAccess(),用来判断用户是否为管理员。
接着创建了 hasAdminAccess with a stubbed user 测试,用 stub 替换真实的 getCurrentUser,模拟返回一个非管理员用户。
测试调用这个 stub,会发现返回 false,符合预期。
然后将 stub 修改为返回管理员用户,断言结果为 true。
最后在 finally 中保证还原函数,避免对其他测试造成影响。
环境变量的 Stub Jump to heading
要实现确定性的测试,经常需要控制环境变量。Deno 标准库提供了相关工具:
import { assertEquals } from "jsr:@std/assert";
import { stub } from "jsr:@std/testing/mock";
// 依赖环境变量和时间的函数
function generateReport() {
const environment = Deno.env.get("ENVIRONMENT") || "development";
const timestamp = new Date().toISOString();
return {
environment,
generatedAt: timestamp,
data: {/* 报告数据 */},
};
}
Deno.test("在受控环境下生成报告", () => {
// Stub 环境变量
const originalEnv = Deno.env.get;
const envStub = stub(Deno.env, "get", (key: string) => {
if (key === "ENVIRONMENT") return "production";
return originalEnv.call(Deno.env, key);
});
// Stub 时间
const dateStub = stub(
Date.prototype,
"toISOString",
() => "2023-06-15T12:00:00Z",
);
try {
const report = generateReport();
// 验证使用受控值生成的结果
assertEquals(report.environment, "production");
assertEquals(report.generatedAt, "2023-06-15T12:00:00Z");
} finally {
// 始终还原 stub,避免影响其他测试
envStub.restore();
dateStub.restore();
}
});
模拟时间 (Faking time) Jump to heading
与时间相关的代码难以测试,因为测试结果可能随执行时间变化。Deno 提供了一个
FakeTime 工具,可在测试中模拟时间流动,控制日期相关函数。
以下示例演示如何测试依赖时间的函数:
isWeekend()(判断当天是否周六或周日返回 true),以及
delayedGreeting()(1 秒延迟后回调):
import { assertEquals } from "jsr:@std/assert";
import { FakeTime } from "jsr:@std/testing/time";
// 基于当前时间的函数
function isWeekend(): boolean {
const date = new Date();
const day = date.getDay();
return day === 0 || day === 6; // 0 是星期日,6 是星期六
}
// 使用定时器的函数
function delayedGreeting(callback: (message: string) => void): void {
setTimeout(() => {
callback("Hello after delay");
}, 1000); // 1 秒延迟
}
Deno.test("时间相关测试", () => {
using fakeTime = new FakeTime();
// 创建从特定日期(星期一)开始的假时间
const mockedTime: FakeTime = fakeTime(new Date("2023-05-01T12:00:00Z"));
try {
// 测试周一
assertEquals(isWeekend(), false);
// 向前推进时间到周六
mockedTime.tick(5 * 24 * 60 * 60 * 1000); // 前进 5 天
assertEquals(isWeekend(), true);
// 测试带定时器的异步操作
let greeting = "";
delayedGreeting((message) => {
greeting = message;
});
// 立即推进 1 秒以触发定时器
mockedTime.tick(1000);
assertEquals(greeting, "Hello after delay");
} finally {
// 始终还原真实时间
mockedTime.restore();
}
});
这里使用 fakeTime 创建受控时间环境,初始时间为 2023 年 5 月 1 日(星期一),返回的 FakeTime 对象可控制时间流逝。
我们在模拟周一时测试 isWeekend() 返回 false,推进到周六后为 true。
fakeTime 替换了 JS 的时间函数 (Date、setTimeout、setInterval 等),让你无论实际测试时间何时,都可测试指定时间条件。此技术可避免依赖系统时钟导致的测试不稳定,并可通过快速推进时间来加速测试。
模拟时间适用于测试:
- 日历或日期相关功能,如日程、预约、过期时间
- 包含超时或定时器的代码,如轮询、延迟操作、去抖
- 动画或过渡效果的定时测试
和 Stub 类似,测试结束后记得调用 restore() 还原真实时间函数,避免影响其他测试。
高级 Mock 模式 Jump to heading
部分 Mock Jump to heading
有时你只想 mock 某些对象方法,保留其他方法真实实现:
import { assertEquals } from "jsr:@std/assert";
import { stub } from "jsr:@std/testing/mock";
class UserService {
async getUser(id: string) {
// 复杂数据库查询
return { id, name: "Database User" };
}
async formatUser(user: { id: string; name: string }) {
return {
...user,
displayName: user.name.toUpperCase(),
};
}
async getUserFormatted(id: string) {
const user = await this.getUser(id);
return this.formatUser(user);
}
}
Deno.test("使用 stub 进行部分 Mock", async () => {
const service = new UserService();
// 仅 mock getUser 方法
const getUserMock = stub(
service,
"getUser",
() => Promise.resolve({ id: "test-id", name: "Mocked User" }),
);
try {
// formatUser 仍使用真实实现
const result = await service.getUserFormatted("test-id");
assertEquals(result, {
id: "test-id",
name: "Mocked User",
displayName: "MOCKED USER",
});
// 验证 getUser 被正确调用
assertEquals(getUserMock.calls.length, 1);
assertEquals(getUserMock.calls[0].args[0], "test-id");
} finally {
getUserMock.restore();
}
});
Mock fetch 请求 Jump to heading
测试涉及 HTTP 请求的代码通常需要模拟 fetch API:
import { assertEquals } from "jsr:@std/assert";
import { stub } from "jsr:@std/testing/mock";
// 使用 fetch 的函数
async function fetchUserData(userId: string) {
const response = await fetch(`https://api.example.com/users/${userId}`);
if (!response.ok) {
throw new Error(`Failed to fetch user: ${response.status}`);
}
return await response.json();
}
Deno.test("Mock fetch API", async () => {
const originalFetch = globalThis.fetch;
// 创建 mock fetch 返回的响应
const mockResponse = new Response(
JSON.stringify({ id: "123", name: "John Doe" }),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
// 用 stub 替换 fetch
globalThis.fetch = stub(
globalThis,
"fetch",
(_input: string | URL | Request, _init?: RequestInit) =>
Promise.resolve(mockResponse),
);
try {
const result = await fetchUserData("123");
assertEquals(result, { id: "123", name: "John Doe" });
} finally {
// 还原原始 fetch
globalThis.fetch = originalFetch;
}
});
真实案例 Jump to heading
现在我们将所有技巧合并,测试一个用户认证服务,该服务:
- 验证用户凭证
- 调用 API 进行认证
- 存储带有过期时间的 token
下面示例创建了完整的 AuthService 类,用于登录、token 管理和鉴权。测试中使用了多种 Mock 技术:Stub fetch 请求、Spy 方法、模拟时间来测试 token 过期,并使用结构化测试步骤组织。
Deno 的测试 API 提供了 t.step() 方法,将测试逻辑分割为步骤或子测试,使复杂测试更易读,更便于定位问题。每步可单独断言,测试结果中分别报告。
import { assertEquals, assertRejects } from "jsr:@std/assert";
import { spy, stub } from "jsr:@std/testing/mock";
import { FakeTime } from "jsr:@std/testing/time";
// 目标服务
class AuthService {
private token: string | null = null;
private expiresAt: Date | null = null;
async login(username: string, password: string): Promise<string> {
// 校验输入
if (!username || !password) {
throw new Error("Username and password are required");
}
// 调用认证 API
const response = await fetch("https://api.example.com/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
if (!response.ok) {
throw new Error(`Authentication failed: ${response.status}`);
}
const data = await response.json();
// 存储带 1 小时过期时间的 token
this.token = data.token;
this.expiresAt = new Date(Date.now() + 60 * 60 * 1000);
return this.token;
}
getToken(): string {
if (!this.token || !this.expiresAt) {
throw new Error("Not authenticated");
}
if (new Date() > this.expiresAt) {
this.token = null;
this.expiresAt = null;
throw new Error("Token expired");
}
return this.token;
}
logout(): void {
this.token = null;
this.expiresAt = null;
}
}
Deno.test("AuthService 综合测试", async (t) => {
await t.step("登录应该验证凭证", async () => {
const authService = new AuthService();
await assertRejects(
() => authService.login("", "password"),
Error,
"Username and password are required",
);
});
await t.step("登录应正确处理 API 调用", async () => {
const authService = new AuthService();
// mock 成功响应
const mockResponse = new Response(
JSON.stringify({ token: "fake-jwt-token" }),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
const fetchStub = stub(
globalThis,
"fetch",
(_url: string | URL | Request, options?: RequestInit) => {
// 验证请求数据正确
const body = options?.body as string;
const parsedBody = JSON.parse(body);
assertEquals(parsedBody.username, "testuser");
assertEquals(parsedBody.password, "password123");
return Promise.resolve(mockResponse);
},
);
try {
const token = await authService.login("testuser", "password123");
assertEquals(token, "fake-jwt-token");
} finally {
fetchStub.restore();
}
});
await t.step("token 过期应正常工作", () => {
using fakeTime = new FakeTime();
const authService = new AuthService();
const time = fakeTime(new Date("2023-01-01T12:00:00Z"));
try {
// mock 登录过程直接设置 token
authService.login = spy(
authService,
"login",
async () => {
(authService as any).token = "fake-token";
(authService as any).expiresAt = new Date(
Date.now() + 60 * 60 * 1000,
);
return "fake-token";
},
);
// 登录并验证 token
authService.login("user", "pass").then(() => {
const token = authService.getToken();
assertEquals(token, "fake-token");
// 将时间推进到过期后
time.tick(61 * 60 * 1000);
// token 应该已过期
assertRejects(
() => {
authService.getToken();
},
Error,
"Token expired",
);
});
} finally {
time.restore();
(authService.login as any).restore();
}
});
});
该代码定义了 AuthService 类,包含三大功能:
- 登录:校验凭证,调用 API,保存带过期时间的 token
- 获取 Token:返回有效且未过期的 token
- 登出:清除 token 和过期时间
测试通过一个主测试,分为三个逻辑步骤,分别检验凭证验证、API 调用处理和 token 过期。
🦕 高效的 Mock 技术对于编写可靠、可维护的单元测试至关重要。Deno 提供多种强大工具帮助你在测试中隔离代码。掌握这些技巧后,你能编写更可靠、更快速且不依赖外部服务的测试。
更多测试资源请参考: