伪造依赖

学习目标

  • 在隔离中测试代码单元

  • 用伪造物替换依赖项

  • 创建伪造物的规则,以避免陷阱

  • 使用 Jasmine 间谍来伪造函数和方法

在测试代码时,您需要在 集成测试单元测试 之间进行选择。回顾一下,集成测试包括("整合")依赖项。相反,单元测试用伪造物替换依赖项,以隔离测试代码。

也称为模拟

这些替代物也被称为 测试替身(test doubles)存根(stubs)_或 _模拟(mocks)。替换依赖项称为 存根(stubbing)模拟(mocking)

由于这些术语的使用不一致,它们之间的区别很微妙,本指南使用术语“伪造物”和“伪造” 来表示任何依赖项替换。

安全伪造

创建和注入伪造的依赖项对于单元测试至关重要。这种技术是双刃剑——既强大又危险。由于我们将在本指南中创建许多伪造物,我们需要制定 规则 以安全地应用 伪造依赖项 技术。

伪造物和原始物的等价性

伪造实现必须与原始实现具有相同的结构。如果依赖项是一个函数,则伪造物必须具有相同的签名,即相同的参数和相同的返回值。如果依赖项是一个对象,则伪造物必须具有相同的公共 API,即相同的公共方法和属性。

可替换性

伪造物不需要完整,但必须足够作为替代品。伪造物在测试代码所涉及的方面上需要与 原始物等价 ,而不必完全等于原始物。

想象一下电影拍摄现场的伪造建筑。外部形状必须与原始建筑无法区分。但在真实的外观背后,只有一个木质脚手架。建筑物是一个空壳。

创建伪造物的最大危险在于它不能正确模仿原始物。即使伪造物在编写代码时与原始物相似,但在原始物更改后很容易不同步。

当原始依赖项更改其公共 API 时,依赖代码需要进行调整。伪造物也需要进行调整。当伪造物过时时,单元测试就变成了一个神奇的世界,其中一切都能够奇迹般地工作。测试通过了,但实际上被测试的代码已经出错了。

保持伪造物同步

如何确保伪造物与原始物保持最新?如何确保原始物和伪造物的等价性,并防止任何可能的分歧?

我们可以使用 TypeScript 强制伪造物具有匹配的类型。伪造物需要是严格类型化的。伪造物的类型需要是原始物类型的子集。

类型等价性

然后,TypeScript 保证了等价性。如果我们忘记了更新实现和伪造物,编译器会提醒我们。我们将在接下来的示例中学习如何声明匹配的类型。

有效的伪造

原始的依赖项代码具有需要在测试期间抑制的副作用。伪造物需要 有效地 阻止原始代码的执行。如果混合使用伪造物和原始代码,可能会发生奇怪的错误。

不要混合使用伪造物和原始物

在某些伪造方法中,伪造物继承自原始物。只有当前被测试代码使用的属性和方法会被覆盖。

这是危险的,因为我们可能会忘记覆盖某些方法。当被测试代码发生变化时,测试可能会意外调用依赖项的原始方法。

本指南将介绍彻底的伪造技术,不允许出错。它们模仿原始代码,同时保护原始代码免受调用。

使用 Jasmine 间谍伪造函数

Jasmine 提供了简单而强大的模式来创建伪造实现。最基本的模式是使用 Jasmine 间谍 来替换函数依赖项。

调用记录

在最简单的形式中,间谍是一个记录其调用的函数。对于每个调用,它记录函数的参数。使用这个记录,我们之后可以断言该间谍已经以特定的输入值被调用。

例如,我们在一个规范中声明:“期望该间谍已经分别以 mickeyminnie 的值被调用两次。”

像其他任何函数一样,间谍可以有一个有意义的返回值。在简单的情况下,这是一个固定的值。无论输入参数如何,该间谍始终返回相同的值。在更复杂的情况下,返回值来自底层的伪造函数。

createSpy

通过调用 jasmine.createSpy 创建一个独立的间谍:

const spy = jasmine.createSpy('name');

createSpy 接受一个参数,一个可选的名称。建议传递一个描述原始功能的名称。当您对间谍进行期望时,该名称将在错误消息中使用。

假设我们有一个名为 TodoService 的类,负责从服务器获取待办事项列表。该类使用 Fetch API 进行HTTP请求。(这是一个普通的TypeScript示例,在Angular应用中直接使用 fetch 是不常见的。)

class TodoService {
  constructor(
    // Bind `fetch` to `window` to ensure that `window` is the `this` context
    private fetch = window.fetch.bind(window)
  ) {}

  public async getTodos(): Promise<string[]> {
    const response = await this.fetch('/todos');
    if (!response.ok) {
      throw new Error(
        `HTTP error: ${response.status} ${response.statusText}`
      );
    }
    return await response.json();
  }
}
注入假的依赖项

TodoService 使用 构造函数注入 模式。fetch 依赖项可以通过可选的构造函数参数进行注入。在生产代码中,该参数为空,并且默认为原始的 window.fetch。在测试中,传递一个假的依赖项给构造函数。

无论是原始的还是假的 fetch 参数,都保存为实例属性 this.fetch 。最终,公共方法 getTodos 使用它来发起HTTP请求。

在我们的单元测试中,我们不希望Service进行任何HTTP请求。我们使用Jasmine spy作为 window.fetch 的替代品。

// Fake todos and response object
const todos = [
  'shop groceries',
  'mow the lawn',
  'take the cat to the vet'
];
const okResponse = new Response(JSON.stringify(todos), {
  status: 200,
  statusText: 'OK',
});

describe('TodoService', () => {
  it('gets the to-dos', async () => {
    // Arrange
    const fetchSpy = jasmine.createSpy('fetch')
      .and.returnValue(okResponse);
    const todoService = new TodoService(fetchSpy);

    // Act
    const actualTodos = await todoService.getTodos();

    // Assert
    expect(actualTodos).toEqual(todos);
    expect(fetchSpy).toHaveBeenCalledWith('/todos');
  });
});

在这个示例中有很多要理解的内容。让我们从 describe 块之前的假数据开始解析:

const todos = [
  'shop groceries',
  'mow the lawn',
  'take the cat to the vet'
];
const okResponse = new Response(JSON.stringify(todos), {
  status: 200,
  statusText: 'OK',
});

首先,我们定义了我们希望 fetch spy返回的假数据。实质上,这是一个字符串数组。

假响应

原始的 fetch 函数返回一个 Response 对象。我们使用内置的 Response 构造函数创建一个 Response 对象。在被解析为JSON之前,原始的服务器响应是一个字符串。因此,我们需要将数组序列化为字符串,然后将其传递给 Response 构造函数。(这些 fetch 的细节与理解spy示例无关。)

然后,我们使用 describe 声明一个测试套件:

describe('TodoService', () => {
  /* … */
});

该测试套件包含一个规范(spec),用于测试 getTodos 方法:

it('gets the to-dos', async () => {
  /* … */
});

该规范(spec)以“安排(Arrange)”代码开始:

// Arrange
const fetchSpy = jasmine.createSpy('fetch')
  .and.returnValue(okResponse);
const todoService = new TodoService(fetchSpy);

在这里,我们创建一个 Jasmine spy(间谍)。使用 .and.returnValue(…),我们设置一个固定的返回值:成功的响应。

注入 spy

我们还创建了 TodoService 的一个实例,也就是待测试的类。我们将 spy 传递给构造函数。这是一种手动依赖注入的形式。

在行动(Act)阶段,我们调用待测试的方法:

const actualTodos = await todoService.getTodos();

getTodos 返回一个 Promise。我们使用 async 函数结合 await 来轻松访问返回值。Jasmine 对于异步函数处理得很好,并等待它们完成。

在断言(Assert)阶段,我们创建了两个期望(expectations):

expect(actualTodos).toEqual(todos);
expect(fetchSpy).toHaveBeenCalledWith('/todos');
数据处理

首先,我们验证返回值。我们将实际数据(actualTodos)与 spy 返回的伪数据(todos)进行比较。如果它们相等,我们就证明了 getTodos 将响应解析为 JSON 并返回结果。(由于 getTodos 访问伪数据的唯一方式就是通过 spy,我们可以推断出 spy 已被调用。)

验证调用记录

其次,我们验证 fetch spy 是否 使用了正确的参数 进行调用,即 API 终端点 URL。Jasmine 提供了多种匹配器来对 spy 进行期望。示例中使用了 toHaveBeenCalledWith 来断言 spy 是否以参数 '/todos' 被调用。

这两个期望都是必要的,以确保 getTodos 正确工作。

正常和异常路径

在编写了 getTodos 的第一个规范后,我们需要问自己:这个测试是否完全覆盖了其行为?我们已经测试了成功情况,也称为 正常路径(happy path),但尚未测试错误情况,也称为 异常路径(unhappy path),特别是以下错误处理代码:

if (!response.ok) {
  throw new Error(
    `HTTP error: ${response.status} ${response.statusText}`
  );
}

当服务器响应不是“ok”时,我们抛出一个错误。“Ok”表示HTTP响应状态码为200-299。不“ok”的示例有“403 Forbidden”、“404 Not Found”和“500 Internal Server Error”。抛出错误会拒绝 Promise,这样调用 getTodos 的调用方就知道获取待办事项失败了。

伪造的 okResponse 模拟了成功的情况。对于错误的情况,我们需要定义另一个伪造的 Response。让我们称之为 errorResponse,它具有臭名昭著的 HTTP 状态码 404 Not Found:

const errorResponse = new Response('Not Found', {
  status: 404,
  statusText: 'Not Found',
});

假设服务器在错误的情况下不返回 JSON,响应主体只是字符串 'Not Found'

现在我们为错误情况添加第二个规范:

describe('TodoService', () => {
  /* … */
  it('handles an HTTP error when getting the to-dos', async () => {
    // Arrange
    const fetchSpy = jasmine.createSpy('fetch')
      .and.returnValue(errorResponse);
    const todoService = new TodoService(fetchSpy);

    // Act
    let error;
    try {
      await todoService.getTodos();
    } catch (e) {
      error = e;
    }

    // Assert
    expect(error).toEqual(new Error('HTTP error: 404 Not Found'));
    expect(fetchSpy).toHaveBeenCalledWith('/todos');
  });
});

Arrange 阶段,我们注入了一个返回错误响应的 spy。

捕获错误

Act 阶段,我们调用要测试的方法,但预计它会抛出错误。在 Jasmine 中,有几种方法可以测试 Promise 是否被拒绝并抛出错误。上面的示例使用 try/catch 语句包裹 getTodos 调用并保存错误。很可能,这是实现代码处理错误的方式。

Assert 阶段,我们再次进行两个断言。与验证返回值不同,我们确保捕获的错误是 Error 实例,并具有有用的错误消息。最后,我们验证 spy 是否已使用正确的值调用,就像成功情况的规范中一样。

再次强调,这是一个纯 TypeScript 示例,用于说明 spy 的用法。通常,Angular 服务不直接使用 fetch,而是使用 HttpClient。我们将在后面介绍如何测试这一点(请参阅 发送 HTTP 请求的服务的测试)。

对现有方法进行间谍操作

我们已经使用 jasmine.createSpy('name') 创建了一个独立的 spy,并将其注入到构造函数中。显式构造函数注入在 Angular 代码中很直观,并且被广泛使用。

对对象方法进行间谍操作

有时,我们已经有一个对象,我们需要对其方法进行间谍操作。如果代码使用了来自浏览器环境的全局方法,如上面示例中的 window.fetch,这将特别有帮助。

为此,我们可以使用 spyOn 方法:

spyOn(window, 'fetch');
覆盖和恢复

这将在全局的 fetch 方法上安装一个 spy。在内部,Jasmine 会保存原始的 window.fetch 函数以供以后使用,并将 window.fetch 覆盖为一个 spy。一旦规范完成,Jasmine 会自动恢复原始函数。

spyOn 返回创建的 spy,使我们能够设置返回值,就像我们之前学到的那样。

spyOn(window, 'fetch')
  .and.returnValue(okResponse);

我们可以创建一个不依赖于构造注入而直接使用 fetchTodoService 版本。

class TodoService {
  public async getTodos(): Promise<string[]> {
    const response = await fetch('/todos');
    if (!response.ok) {
      throw new Error(
        `HTTP error: ${response.status} ${response.statusText}`
      );
    }
    return await response.json();
  }
}

测试套件接下来使用 spyOn 来捕获所有对 window.fetch 的调用:

// Fake todos and response object
const todos = [
  'shop groceries',
  'mow the lawn',
  'take the cat to the vet'
];
const okResponse = new Response(JSON.stringify(todos), {
  status: 200,
  statusText: 'OK',
});

describe('TodoService', () => {
  it('gets the to-dos', async () => {
    // Arrange
    spyOn(window, 'fetch')
      .and.returnValue(okResponse);
    const todoService = new TodoService();

    // Act
    const actualTodos = await todoService.getTodos();

    // Assert
    expect(actualTodos).toEqual(todos);
    expect(window.fetch).toHaveBeenCalledWith('/todos');
  });
});

在这里并没有太多变化。我们使用 spyOn 来对 fetch 进行监视,并使其返回 okResponse。由于 window.fetch 被重写为一个 spy,我们对它进行期望以验证它是否被调用。

创建独立的 spy 和对现有方法进行监视并不是相互排斥的。在测试 Angular 应用程序时,这两种方式都经常被使用,并且可以很好地与注入到构造函数中的依赖项配合使用。