deno.com
本页内容

使用模拟进行隔离测试

本指南基于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 包装。侦测函数跟踪对该方法的调用,记录传递给它的参数,并执行底层实现(在本例中返回一个包含 userid 的 promise)。

测试使用模拟数据调用 saveUser(),我们使用断言来验证

  1. save 方法被调用且仅调用了一次
  2. 调用的第一个参数是我们传入的 user 对象
  3. 结果包含原始用户数据和添加的 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 的计时函数(DatesetTimeoutsetInterval 等)替换为您可控制的版本。这允许您测试具有特定日期或时间的代码,无论测试何时运行。这项强大的技术意味着您将避免依赖系统时钟的不可靠测试,并且可以通过即时推进时间而不是等待实际超时来加快测试速度。

模拟时间对于测试特别有用,例如

  • 日历或基于日期的功能,例如日程安排、约会或到期日期
  • 带有超时或间隔的代码,例如轮询、延迟操作或防抖
  • 动画或过渡,例如测试定时视觉效果的完成

与模拟器一样,始终在测试后使用 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;
  }
});

实际示例 跳转到标题

让我们在一个更全面的示例中将所有内容整合起来。我们将测试一个用户认证服务,它将

  1. 验证用户凭据
  2. 调用 API 进行认证
  3. 存储带有时效的令牌

在下面的示例中,我们将创建一个完整的 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 提供了几个强大的工具,可帮助您在测试期间隔离代码。通过掌握这些模拟技术,您将能够编写更可靠、运行更快且不依赖外部服务的测试。

有关更多测试资源,请查看

您找到所需内容了吗?

隐私政策