伪造依赖
学习目标
-
在隔离中测试代码单元
-
用伪造物替换依赖项
-
创建伪造物的规则,以避免陷阱
-
使用 Jasmine 间谍来伪造函数和方法
也称为模拟 |
这些替代物也被称为 测试替身(test doubles)、存根(stubs)_或 _模拟(mocks)。替换依赖项称为 存根(stubbing) 或 模拟(mocking)。
由于这些术语的使用不一致,它们之间的区别很微妙,本指南使用术语“伪造物”和“伪造” 来表示任何依赖项替换。
安全伪造 |
创建和注入伪造的依赖项对于单元测试至关重要。这种技术是双刃剑——既强大又危险。由于我们将在本指南中创建许多伪造物,我们需要制定 规则 以安全地应用 伪造依赖项 技术。
伪造物和原始物的等价性
伪造实现必须与原始实现具有相同的结构。如果依赖项是一个函数,则伪造物必须具有相同的签名,即相同的参数和相同的返回值。如果依赖项是一个对象,则伪造物必须具有相同的公共 API,即相同的公共方法和属性。
可替换性 |
伪造物不需要完整,但必须足够作为替代品。伪造物在测试代码所涉及的方面上需要与 原始物等价 ,而不必完全等于原始物。
想象一下电影拍摄现场的伪造建筑。外部形状必须与原始建筑无法区分。但在真实的外观背后,只有一个木质脚手架。建筑物是一个空壳。
创建伪造物的最大危险在于它不能正确模仿原始物。即使伪造物在编写代码时与原始物相似,但在原始物更改后很容易不同步。
当原始依赖项更改其公共 API 时,依赖代码需要进行调整。伪造物也需要进行调整。当伪造物过时时,单元测试就变成了一个神奇的世界,其中一切都能够奇迹般地工作。测试通过了,但实际上被测试的代码已经出错了。
保持伪造物同步 |
如何确保伪造物与原始物保持最新?如何确保原始物和伪造物的等价性,并防止任何可能的分歧?
我们可以使用 TypeScript 强制伪造物具有匹配的类型。伪造物需要是严格类型化的。伪造物的类型需要是原始物类型的子集。
类型等价性 |
然后,TypeScript 保证了等价性。如果我们忘记了更新实现和伪造物,编译器会提醒我们。我们将在接下来的示例中学习如何声明匹配的类型。
有效的伪造
原始的依赖项代码具有需要在测试期间抑制的副作用。伪造物需要 有效地 阻止原始代码的执行。如果混合使用伪造物和原始代码,可能会发生奇怪的错误。
不要混合使用伪造物和原始物 |
在某些伪造方法中,伪造物继承自原始物。只有当前被测试代码使用的属性和方法会被覆盖。
这是危险的,因为我们可能会忘记覆盖某些方法。当被测试代码发生变化时,测试可能会意外调用依赖项的原始方法。
本指南将介绍彻底的伪造技术,不允许出错。它们模仿原始代码,同时保护原始代码免受调用。
使用 Jasmine 间谍伪造函数
Jasmine 提供了简单而强大的模式来创建伪造实现。最基本的模式是使用 Jasmine 间谍 来替换函数依赖项。
调用记录 |
在最简单的形式中,间谍是一个记录其调用的函数。对于每个调用,它记录函数的参数。使用这个记录,我们之后可以断言该间谍已经以特定的输入值被调用。
例如,我们在一个规范中声明:“期望该间谍已经分别以 mickey
和 minnie
的值被调用两次。”
像其他任何函数一样,间谍可以有一个有意义的返回值。在简单的情况下,这是一个固定的值。无论输入参数如何,该间谍始终返回相同的值。在更复杂的情况下,返回值来自底层的伪造函数。
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);
我们可以创建一个不依赖于构造注入而直接使用 fetch
的 TodoService
版本。
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 应用程序时,这两种方式都经常被使用,并且可以很好地与注入到构造函数中的依赖项配合使用。