使用模拟进行隔离测试
本指南基于Deno 中的测试基础知识,专门关注有助于您在测试期间隔离代码的模拟技术。
为了进行有效的单元测试,您通常需要“模拟”您的代码与之交互的数据。模拟是一种在测试中使用的技术,您将真实数据替换为可控制的模拟版本。这在测试与外部服务(如 API 或数据库)交互的组件时特别有用。
Deno 通过 Deno 标准库提供了有用的模拟工具,使您的测试更易于编写、更可靠、更快。
侦测 跳转到标题
在 Deno 中,您可以侦测
一个函数,以跟踪它在测试执行期间是如何被调用的。侦测器不会改变函数的行为,但它们会记录重要的详细信息,例如函数被调用了多少次以及传递给它的参数是什么。
通过使用侦测器,您可以验证您的代码与其依赖项的交互是否正确,而无需设置复杂的底层架构。
在以下示例中,我们将测试一个名为 saveUser()
的函数,它接受一个用户对象和一个数据库对象,并调用数据库的 save
方法
import { assertEquals } from "jsr:@std/assert";
import { assertSpyCalls, spy } from "jsr:@std/testing/mock";
// Define types for better code quality
interface User {
name: string;
}
interface Database {
save: (user: User) => Promise<User & { id: number }>;
}
// Function to test
function saveUser(
user: User,
database: Database,
): Promise<User & { id: number }> {
return database.save(user);
}
// Test with a mock
Deno.test("saveUser calls database.save", async () => {
// Create a mock database with a spy on the save method
const mockDatabase = {
save: spy((user: User) => Promise.resolve({ id: 1, ...user })),
};
const user: User = { name: "Test User" };
const result = await saveUser(user, mockDatabase);
// Verify the mock was called correctly
assertSpyCalls(mockDatabase.save, 1);
assertEquals(mockDatabase.save.calls[0].args[0], user);
assertEquals(result, { id: 1, name: "Test User" });
});
我们从 Deno 标准库导入必要的函数,用于断言相等性以及创建和验证侦测函数。
模拟数据库是真实数据库对象的替代品,其 save
方法被 spy
包装。侦测函数跟踪对该方法的调用,记录传递给它的参数,并执行底层实现(在本例中返回一个包含 user
和 id
的 promise)。
测试使用模拟数据调用 saveUser()
,我们使用断言来验证
- save 方法被调用且仅调用了一次
- 调用的第一个参数是我们传入的
user
对象 - 结果包含原始用户数据和添加的 ID
我们能够在不设置或拆除任何复杂数据库状态的情况下测试 saveUser
操作。
清除侦测器 跳转到标题
当使用多个使用侦测器的测试时,在测试之间重置或清除侦测器以避免干扰非常重要。Deno 测试库提供了一种使用 restore()
方法将所有侦测器恢复到其原始状态的简单方法。
以下是如何在使用完侦测器后清除它
import { assertEquals } from "jsr:@std/assert";
import { assertSpyCalls, spy } from "jsr:@std/testing/mock";
Deno.test("spy cleanup example", () => {
// Create a spy on a function
const myFunction = spy((x: number) => x * 2);
// Use the spy
const result = myFunction(5);
assertEquals(result, 10);
assertSpyCalls(myFunction, 1);
// After testing, restore the spy
try {
// Test code using the spy
// ...
} finally {
// Always clean up spies
myFunction.restore();
}
});
方法侦测器是可一次性使用的,它们可以通过 using
关键字自动恢复。这种方法意味着您不需要将断言包装在 try 语句中,以确保在测试结束前恢复方法。
import { assertEquals } from "jsr:@std/assert";
import { assertSpyCalls, spy } from "jsr:@std/testing/mock";
Deno.test("using disposable spies", () => {
const calculator = {
add: (a: number, b: number) => a + b,
multiply: (a: number, b: number) => a * b,
};
// The spy will automatically be restored when it goes out of scope
using addSpy = spy(calculator, "add");
// Use the spy
const sum = calculator.add(3, 4);
assertEquals(sum, 7);
assertSpyCalls(addSpy, 1);
assertEquals(addSpy.calls[0].args, [3, 4]);
// No need for try/finally blocks - the spy will be restored automatically
});
Deno.test("using multiple disposable spies", () => {
const calculator = {
add: (a: number, b: number) => a + b,
multiply: (a: number, b: number) => a * b,
};
// Both spies will automatically be restored
using addSpy = spy(calculator, "add");
using multiplySpy = spy(calculator, "multiply");
calculator.add(5, 3);
calculator.multiply(4, 2);
assertSpyCalls(addSpy, 1);
assertSpyCalls(multiplySpy, 1);
// No cleanup code needed
});
对于有多个不支持 using
关键字的侦测器的情况,您可以将它们存储在一个数组中并一次性恢复所有侦测器。
Deno.test("multiple spies cleanup", () => {
const spies = [];
// Create spies
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);
// Use the spies in tests
// ...
// Clean up all spies at the end
try {
// Test code using spies
} finally {
// Restore all spies
spies.forEach((spyFn) => spyFn.restore());
}
});
通过正确清理侦测器,您可以确保每个测试都以干净的状态开始,并避免测试之间的副作用。
模拟 (Stubbing) 跳转到标题
侦测器跟踪方法调用而不改变其行为,而模拟器 (stubs) 则完全替换原始实现。模拟 (Stubbing) 是一种模拟形式,您临时用受控实现替换函数或方法。这允许您模拟特定条件或行为并返回预设值。当您需要覆盖依赖于环境的功能时,也可以使用它。
在 Deno 中,您可以使用标准测试库中的 stub
函数创建模拟器。
import { assertEquals } from "jsr:@std/assert";
import { Stub, stub } from "jsr:@std/testing/mock";
// Define types for better code quality
interface User {
name: string;
role: string;
}
// Original function
function getCurrentUser(userId: string): User {
// Implementation that might involve database calls
return { name: "Real User", role: "admin" };
}
// Function we want to test
function hasAdminAccess(userId: string): boolean {
const user = getCurrentUser(userId);
return user.role === "admin";
}
Deno.test("hasAdminAccess with stubbed user", () => {
// Create a stub that replaces getCurrentUser
const getUserStub: Stub<typeof getCurrentUser> = stub(
globalThis,
"getCurrentUser",
// Return a test user with non-admin role
() => ({ name: "Test User", role: "guest" }),
);
try {
// Test with the stubbed function
const result = hasAdminAccess("user123");
assertEquals(result, false);
// You can also change the stub's behavior during the test
getUserStub.restore(); // Remove first stub
const adminStub = stub(
globalThis,
"getCurrentUser",
() => ({ name: "Admin User", role: "admin" }),
);
try {
const adminResult = hasAdminAccess("admin456");
assertEquals(adminResult, true);
} finally {
adminStub.restore();
}
} finally {
// Always restore the original function
getUserStub.restore();
}
});
在这里,我们从 Deno 标准库导入必要的函数,然后设置我们将要模拟的函数。在实际应用程序中,这可能连接到数据库、进行 API 调用或执行我们可能希望在测试期间避免的其他操作。
我们设置了被测试函数,在本例中是 hasAdminAccess()
函数。我们想测试它是否
- 调用
getCurrentUser()
函数以获取用户对象 - 检查用户角色是否为 "admin"
- 返回一个布尔值,指示用户是否具有管理员权限
接下来我们创建一个名为 hasAdminAccess with a stubbed user
的测试,并为 getCurrentUser
函数设置一个模拟器。这将用一个返回角色为 guest
的用户的实现来替换实际实现。
我们使用模拟函数运行测试,它将使用用户 ID 调用 hasAdminAccess
。尽管真实函数会返回角色为 admin
的用户,但我们的模拟器返回 guest
,因此我们可以断言 hasAdminAccess
返回 false
(因为我们的模拟器返回的是非管理员用户)。
我们可以更改模拟器的行为,使其返回 admin
,并断言函数现在返回 true
。
最后,我们使用 finally
块来确保原始函数被恢复,以免意外影响其他测试。
模拟环境变量 跳转到标题
为了进行确定性测试,您通常需要控制环境变量。Deno 的标准库提供了实现此目的的工具。
import { assertEquals } from "jsr:@std/assert";
import { stub } from "jsr:@std/testing/mock";
// Function that depends on environment variables and time
function generateReport() {
const environment = Deno.env.get("ENVIRONMENT") || "development";
const timestamp = new Date().toISOString();
return {
environment,
generatedAt: timestamp,
data: {/* report data */},
};
}
Deno.test("report generation with controlled environment", () => {
// Stub environment
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 time
const dateStub = stub(
Date.prototype,
"toISOString",
() => "2023-06-15T12:00:00Z",
);
try {
const report = generateReport();
// Verify results with controlled values
assertEquals(report.environment, "production");
assertEquals(report.generatedAt, "2023-06-15T12:00:00Z");
} finally {
// Always restore stubs to prevent affecting other tests
envStub.restore();
dateStub.restore();
}
});
模拟时间 跳转到标题
时间相关的代码可能难以测试,因为它可能根据测试运行时的时间产生不同的结果。Deno 提供了一个 FakeTime
工具,允许您在测试期间模拟时间流逝并控制日期相关函数。
下面的示例演示了如何测试时间相关函数:isWeekend()
,如果当前日期是星期六或星期日则返回 true;以及 delayedGreeting()
,它在 1 秒延迟后调用回调。
import { assertEquals } from "jsr:@std/assert";
import { FakeTime } from "jsr:@std/testing/time";
// Function that depends on the current time
function isWeekend(): boolean {
const date = new Date();
const day = date.getDay();
return day === 0 || day === 6; // 0 is Sunday, 6 is Saturday
}
// Function that works with timeouts
function delayedGreeting(callback: (message: string) => void): void {
setTimeout(() => {
callback("Hello after delay");
}, 1000); // 1 second delay
}
Deno.test("time-dependent tests", () => {
using fakeTime = new FakeTime();
// Create a fake time starting at a specific date (a Monday)
const mockedTime: FakeTime = fakeTime(new Date("2023-05-01T12:00:00Z"));
try {
// Test with the mocked Monday
assertEquals(isWeekend(), false);
// Move time forward to Saturday
mockedTime.tick(5 * 24 * 60 * 60 * 1000); // Advance 5 days
assertEquals(isWeekend(), true);
// Test async operations with timers
let greeting = "";
delayedGreeting((message) => {
greeting = message;
});
// Advance time to trigger the timeout immediately
mockedTime.tick(1000);
assertEquals(greeting, "Hello after delay");
} finally {
// Always restore the real time
mockedTime.restore();
}
});
在这里,我们设置了一个测试,它使用 fakeTime
创建了一个受控时间环境,将起始日期设置为 2023 年 5 月 1 日(星期一)。它返回一个 FakeTime
控制器对象,允许我们操纵时间。
我们用模拟的星期一运行测试,将看到 isWeekend
函数返回 false
。然后我们可以将时间提前到星期六并再次运行测试,以验证 isWeekend
返回 true
。
fakeTime
函数将 JavaScript 的计时函数(Date
、setTimeout
、setInterval
等)替换为您可控制的版本。这允许您测试具有特定日期或时间的代码,无论测试何时运行。这项强大的技术意味着您将避免依赖系统时钟的不可靠测试,并且可以通过即时推进时间而不是等待实际超时来加快测试速度。
模拟时间对于测试特别有用,例如
- 日历或基于日期的功能,例如日程安排、约会或到期日期
- 带有超时或间隔的代码,例如轮询、延迟操作或防抖
- 动画或过渡,例如测试定时视觉效果的完成
与模拟器一样,始终在测试后使用 restore()
方法恢复真实的计时函数,以避免影响其他测试。
高级模拟模式 跳转到标题
局部模拟 跳转到标题
有时您只想模拟对象的某些方法,而保持其他方法不变。
import { assertEquals } from "jsr:@std/assert";
import { stub } from "jsr:@std/testing/mock";
class UserService {
async getUser(id: string) {
// Complex database query
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("partial mocking with stubs", async () => {
const service = new UserService();
// Only mock the getUser method
const getUserMock = stub(
service,
"getUser",
() => Promise.resolve({ id: "test-id", name: "Mocked User" }),
);
try {
// The formatUser method will still use the real implementation
const result = await service.getUserFormatted("test-id");
assertEquals(result, {
id: "test-id",
name: "Mocked User",
displayName: "MOCKED USER",
});
// Verify getUser was called with the right arguments
assertEquals(getUserMock.calls.length, 1);
assertEquals(getUserMock.calls[0].args[0], "test-id");
} finally {
getUserMock.restore();
}
});
模拟 fetch 请求 跳转到标题
测试发出 HTTP 请求的代码通常需要模拟 fetch
API。
import { assertEquals } from "jsr:@std/assert";
import { stub } from "jsr:@std/testing/mock";
// Function that uses 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("mocking fetch API", async () => {
const originalFetch = globalThis.fetch;
// Create a response that the mock fetch will return
const mockResponse = new Response(
JSON.stringify({ id: "123", name: "John Doe" }),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
// Replace fetch with a stubbed version
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 {
// Restore original fetch
globalThis.fetch = originalFetch;
}
});
实际示例 跳转到标题
让我们在一个更全面的示例中将所有内容整合起来。我们将测试一个用户认证服务,它将
- 验证用户凭据
- 调用 API 进行认证
- 存储带有时效的令牌
在下面的示例中,我们将创建一个完整的 AuthService
类,用于处理用户登录、令牌管理和身份验证。我们将使用前面介绍的各种模拟技术对其进行彻底测试:模拟 fetch 请求、侦测方法以及操纵时间以测试令牌过期——所有这些都在有组织的测试步骤中进行。
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";
// The service we want to test
class AuthService {
private token: string | null = null;
private expiresAt: Date | null = null;
async login(username: string, password: string): Promise<string> {
// Validate inputs
if (!username || !password) {
throw new Error("Username and password are required");
}
// Call authentication 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();
// Store token with expiration (1 hour)
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 comprehensive test", async (t) => {
await t.step("login should validate credentials", async () => {
const authService = new AuthService();
await assertRejects(
() => authService.login("", "password"),
Error,
"Username and password are required",
);
});
await t.step("login should handle API calls", async () => {
const authService = new AuthService();
// Mock successful response
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) => {
// Verify correct data is being sent
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 expiration should work correctly", () => {
using fakeTime = new FakeTime();
const authService = new AuthService();
const time = fakeTime(new Date("2023-01-01T12:00:00Z"));
try {
// Mock the login process to set token directly
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";
},
);
// Login and verify token
authService.login("user", "pass").then(() => {
const token = authService.getToken();
assertEquals(token, "fake-token");
// Advance time past expiration
time.tick(61 * 60 * 1000);
// Token should now be expired
assertRejects(
() => {
authService.getToken();
},
Error,
"Token expired",
);
});
} finally {
time.restore();
(authService.login as any).restore();
}
});
});
此代码定义了具有三个主要功能的 AuthService
类
- 登录 - 验证凭据,调用 API,并存储带有时效的令牌
- 获取令牌 - 如果令牌有效且未过期则返回令牌
- 登出 - 清除令牌和过期时间
测试结构被组织为一个主测试,包含三个逻辑步骤,每个步骤测试服务的不同方面:凭据验证、API 调用处理和令牌过期。
🦕 有效的模拟对于编写可靠、可维护的单元测试至关重要。Deno 提供了几个强大的工具,可帮助您在测试期间隔离代码。通过掌握这些模拟技术,您将能够编写更可靠、运行更快且不依赖外部服务的测试。
有关更多测试资源,请查看