测试服务
学习目标
-
编写具有内部状态的服务的测试
-
测试服务返回的可观察对象
-
验证HTTP请求和有效负载处理
-
涵盖HTTP成功和错误情况
在Angular应用程序中,服务负责获取、存储和处理数据。服务是单例,意味着在运行时只有一个服务实例。它们适用于中央数据存储、HTTP和WebSocket通信以及数据验证。
单例 |
单个服务实例在组件和其他应用程序部分之间共享。因此,在不是父子关系的组件之间需要通信和交换数据时,使用服务。
可注入 |
“服务”是指任何作为依赖项注入并提供特定功能的对象的总称。从技术上讲,服务没有什么共同之处。没有关于服务结构或行为的规则。
通常,服务是类,但不一定。虽然模块、组件和指令带有相应的装饰器(@Module
、@Component
、@Directive
),但服务使用通用的 @Injectable
装饰器。
职责 |
那么服务是做什么的,我们如何测试它呢?服务是多样化的,但一些模式是普遍的。
-
服务有公共方法返回值。
在测试中,我们检查方法是否返回正确的数据。
-
服务存储数据。它们保持一种内部状态。我们可以获取或设置该状态。
在测试中,我们检查状态是否正确地更改。由于状态应该保存在私有属性中,因此我们不能直接访问状态。我们通过调用公共方法来测试状态变化。我们不应窥视 黑盒。
-
服务与依赖项交互。这些通常是其他服务。例如,服务可能通过Angular的
HttpClient
发送HTTP请求。在单元测试中,我们用返回预先设定响应的虚拟对象替换依赖项。
测试一个具有内部状态的服务
让我们从测试 CounterService
开始。现在,您应该对服务已经很熟悉了。作为提醒,这里是一段包括私有成员的代码:
class CounterService {
private count: number;
private subject: BehaviorSubject<number>;
public getCount(): Observable<number> { /* … */ }
public increment(): void { /* … */ }
public decrement(): void { /* … */ }
public reset(newCount: number): void { /* … */ }
private notify(): void { /* … */ }
}
我们需要确定该服务的功能,需要测试什么以及如何进行测试。
它的功能 |
-
该服务保存一个内部状态,即私有
计数(count)
和主题(subject)
属性。我们不能也不应该在测试中访问这些属性。 -
为了读取状态,该服务有一个名为
getCount
的方法。它不返回同步值,而是一个RxJS可观察对象。我们将使用getCount
来获取当前计数,并订阅更改。 -
为了改变状态,该服务提供了
增加(increment)
,减少(decrement)
和重置(reset)
的方法。我们将调用它们并检查状态是否相应地改变。
让我们编写测试代码!我们创建一个名为counter.service.spec.ts
的文件,并填充测试套件样板代码:
describe('CounterService', () => {
/* … */
});
我们已经知道该服务的功能以及需要测试什么。因此,我们为所有功能添加规范:
describe('CounterService', () => {
it('returns the count', () => { /* … */ });
it('increments the count', () => { /* … */ });
it('decrements the count', () => { /* … */ });
it('resets the count', () => { /* … */ });
});
不使用 TestBed 实例化
|
在Arrange阶段,每个规范都需要创建CounterService
的一个实例。最简单的方法是:
const counterService = new CounterService();
对于没有依赖关系的简单服务,这样做是可以的。对于具有依赖关系的服务进行测试,稍后我们将使用TestBed
。
我们在beforeEach
块中创建新实例,因为每个规范都需要它:
describe('CounterService', () => {
let counterService: CounterService;
beforeEach(() => {
counterService = new CounterService();
});
it('returns the count', () => { /* … */ });
it('increments the count', () => { /* … */ });
it('decrements the count', () => { /* … */ });
it('resets the count', () => { /* … */ });
});
让我们从编写规范 it('returns the count', /* … */)
开始。它测试返回Observable的getCount
方法。
更改变量值 |
为了测试Observable,我们使用了与 测试组件输出 相同的模式:
-
我们声明一个变量
actualCount
,最初为undefined。 -
我们订阅Observable。我们将发射的值赋给
actualCount
变量。 -
最后,在订阅函数的外部,我们将实际值与期望值进行比较。
it('returns the count', () => {
let actualCount: number | undefined;
counterService.getCount().subscribe((count) => {
actualCount = count;
});
expect(actualCount).toBe(0);
});
这是行得通的,因为Observable由一个BehaviorSubject
支持,它存储最新的值并立即将其发送给新的订阅者。
状态更改 |
下一个规范测试increment
方法。我们调用该方法并验证计数状态是否已更改。
如前所述,出于此目的,我们无法访问私有属性。就像上面的规范一样,我们需要使用公共的getCount
方法来读取计数。
it('increments the count', () => {
counterService.increment();
let actualCount: number | undefined;
counterService.getCount().subscribe((count) => {
actualCount = count;
});
expect(actualCount).toBe(1);
});
期望更改的值 |
这里的顺序很重要:首先,我们调用increment
,然后我们订阅Observable以读取并验证更改后的值。同样,BehaviorSubject
将当前值同步地发送给新的订阅者。
剩下的两个规范几乎相同。我们只是调用了相应的方法。
it('decrements the count', () => {
counterService.decrement();
let actualCount: number | undefined;
counterService.getCount().subscribe((count) => {
actualCount = count;
});
expect(actualCount).toBe(-1);
});
it('resets the count', () => {
const newCount = 123;
counterService.reset(newCount);
let actualCount: number | undefined;
counterService.getCount().subscribe((count) => {
actualCount = count;
});
expect(actualCount).toBe(newCount);
});
重复的模式 |
我们很快就会注意到这些规范非常重复且嘈杂。在每个规范的Assert阶段,我们都使用这个模式来检查服务状态:
let actualCount: number | undefined;
counterService.getCount().subscribe((count) => {
actualCount = count;
});
expect(actualCount).toBe(/* … */);
这是一个很好的助手函数候选项。我们称其为expectCount
。
function expectCount(count: number): void {
let actualCount: number | undefined;
counterService.getCount().subscribe((actualCount2) => {
actualCount = actualCount2;
});
expect(actualCount).toBe(count);
}
这个模式有一个可变的位,即期望的数量,这就是为什么助手函数只有一个参数的原因。
取消订阅 |
现在我们已经将代码提取到一个中央助手函数中,我们应该添加一个优化。 RxJS Observables的第一条规则是:“任何订阅者都必须取消订阅”。
在expectCount
中,我们只需要获取当前计数一次。我们不想创建一个长期持续的订阅。我们对未来的更改不感兴趣。
如果我们每个规范只调用一次expectCount
,这不是一个很大的问题。如果我们编写一个更复杂的规范,有几个expectCount
调用,我们将创建无意义的订阅。这很可能在调试订阅函数时引起混淆。
简而言之,我们想要获取计数,然后取消订阅以减少不必要的订阅。
手动取消订阅 |
一个可能的解决方案是立即在订阅后取消订阅。 subscribe
方法返回一个Subscription
,其中包含有用的unsubscribe
方法。
function expectCount(count: number): void {
let actualCount: number | undefined;
counterService
.getCount()
.subscribe((actualCount2) => {
actualCount = actualCount2;
})
.unsubscribe();
expect(actualCount).toBe(count);
}
RxJS操作符 |
更符合惯用方式的方法是使用一个RxJS操作符,它在第一个值之后就完成Observable: first
。
import { first } from 'rxjs/operators';
function expectCount(count: number): void {
let actualCount: number | undefined;
counterService
.getCount()
.pipe(first())
.subscribe((actualCount2) => {
actualCount = actualCount2;
});
expect(actualCount).toBe(count);
}
如果您不熟悉这个神秘的RxJS魔法,不要担心。在简单的CounterService
测试中,取消订阅并不是必须的。但这是一个良好的实践,可以避免在测试使用Observables的更复杂的服务时出现奇怪的错误。
完整的测试套件现在如下所示:
describe('CounterService', () => {
let counterService: CounterService;
function expectCount(count: number): void {
let actualCount: number | undefined;
counterService
.getCount()
.pipe(first())
.subscribe((actualCount2) => {
actualCount = actualCount2;
});
expect(actualCount).toBe(count);
}
beforeEach(() => {
counterService = new CounterService();
});
it('returns the count', () => {
expectCount(0);
});
it('increments the count', () => {
counterService.increment();
expectCount(1);
});
it('decrements the count', () => {
counterService.decrement();
expectCount(-1);
});
it('resets the count', () => {
const newCount = 123;
counterService.reset(newCount);
expectCount(newCount);
});
});
测试发送HTTP请求的服务
没有依赖关系的服务,如 CounterService
,相对容易测试。让我们来看一个具有依赖关系的更复杂的服务。
在 Flickr搜索 中,https://github.com/9elements/angular-flickr-search/blob/main/src/app/services/flickr.service.ts[FlickrService]负责通过Flickr API搜索照片。它向www.flickr.com发送一个HTTP GET请求。服务器以JSON格式响应。以下是完整的代码:
@Injectable()
export class FlickrService {
constructor(private http: HttpClient) {}
public searchPublicPhotos(searchTerm: string): Observable<Photo[]> {
return this.http
.get<FlickrAPIResponse>(
'https://www.flickr.com/services/rest/',
{
params: {
tags: searchTerm,
method: 'flickr.photos.search',
format: 'json',
nojsoncallback: '1',
tag_mode: 'all',
media: 'photos',
per_page: '15',
extras: 'tags,date_taken,owner_name,url_q,url_m',
api_key: 'XYZ',
},
}
)
.pipe(map((response) => response.photos.photo));
}
}
该服务标有@Injectable()
,因此它参与Angular的依赖注入。它依赖于Angular标准的HTTP库,即 @angular/common/http
包中的HttpClient
。大多数Angular应用程序使用HttpClient
与HTTP API通信。
有两种测试FlickrService
的方法:集成测试或单元测试。
针对生产环境的请求 |
集成测试提供真正的HttpClient
。这会导致在运行测试时向Flickr API发送HTTP请求。这使整个测试不可靠。
网络或Web服务可能会很慢或不可用。此外,Flickr API端点针对每个请求返回不同的响应。如果输入未知,则很难期望某个FlickrService
的行为。
在测试环境中,针对第三方生产API的请求意义不大。如果要为进行HTTP请求的服务编写集成测试,请使用返回固定数据的专用测试API。此API可以在同一台计算机或本地网络上运行。
拦截请求 |
在FlickrService
的情况下,我们最好编写单元测试。Angular具有用于测试依赖于HttpClient
的代码的强大辅助程序: HttpClientTestingModule
。
对于具有依赖关系的服务进行测试,使用new
实例化服务是很繁琐的。相反,我们使用TestBed
来设置一个测试模块。
我们导入HttpClientTestingModule
来代替HttpClient
。
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [FlickrService],
});
HttpClientTestingModule
提供了一个假的HttpClient
实现。它实际上不会发送HTTP请求,而只是拦截并在内部记录它们。
在测试中,我们检查HTTP请求的记录。我们使用虚假数据手动响应未决请求。
查找、响应、验证 |
我们的测试将执行以下步骤:
-
调用发送HTTP请求的被测方法
-
查找未决请求
-
使用虚假数据响应这些请求
-
检查方法调用的结果
-
验证所有请求都已得到回答
调用被测方法
在第一步中,我们调用被测方法searchPublicPhotos
。搜索词仅为固定字符串。
const searchTerm = 'dragonfly';
describe('FlickrService', () => {
let flickrService: FlickrService;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [FlickrService],
});
flickrService = TestBed.inject(FlickrService);
});
it('searches for public photos', () => {
flickrService.searchPublicPhotos(searchTerm).subscribe(
(actualPhotos) => {
/* … */
}
);
/* … */
});
});
我们订阅了由searchPublicPhotos
返回的Observable,从而发送了(虚假的)HTTP请求。我们稍后会在第四步中调查响应actualPhotos
。
查找挂起的请求
在第二步中,我们使用 HttpTestingController
查找挂起的请求。这个类是 HttpClientTestingModule
的一部分。我们通过调用 TestBed.inject(HttpTestingController)
来获取实例。
expectOne
|
该控制器具有按不同条件查找请求的方法。最简单的是expectOne
。它查找与给定条件匹配的请求,并期望恰好有一个匹配项。
在我们的情况下,我们搜索Flickr API的给定URL的请求。
const searchTerm = 'dragonfly';
const expectedUrl = `https://www.flickr.com/services/rest/?tags=${searchTerm}&method=flickr.photos.search&format=json&nojsoncallback=1&tag_mode=all&media=photos&per_page=15&extras=tags,date_taken,owner_name,url_q,url_m&api_key=XYZ`;
describe('FlickrService', () => {
let flickrService: FlickrService;
let controller: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [FlickrService],
});
flickrService = TestBed.inject(FlickrService);
controller = TestBed.inject(HttpTestingController);
});
it('searches for public photos', () => {
flickrService.searchPublicPhotos(searchTerm).subscribe(
(actualPhotos) => {
/* … */
}
);
const request = controller.expectOne(expectedUrl);
/* … */
});
});
expectOne
返回找到的请求,它是TestRequest
的实例。如果没有匹配URL的挂起请求,expectOne
会抛出异常,导致测试失败。
使用虚假数据响应
现在我们手头有挂起的请求,我们使用一个对象来响应它,这个对象模仿了原始的API响应。Flickr API返回一个复杂的对象,其中深度包含一组照片对象。在FlickrService
测试中,我们只关心有效载荷,即照片数组。
Flickr搜索存储库包含用于整个测试的 虚假照片对象。对于FlickrService
测试,我们导入具有两个虚假照片对象的照片(photos)
数组。
我们使用请求的flush
方法来响应虚假数据。这模拟了一个成功的“200 OK”服务器响应。
request.flush({ photos: { photo: photos } });
检查方法调用的结果
该规范已经证明searchPublicPhotos
向预期的URL发出了请求。它仍然需要证明该方法传递了所需的API响应部分。特别是,它需要证明Observable发出了照片(photos)
数组。
我们已经订阅了Observable:
flickrService.searchPublicPhotos(searchTerm).subscribe(
(actualPhotos) => {
/* … */
}
);
我们期望Observable发出的照片数组与API响应中的数组相等:
flickrService.searchPublicPhotos(searchTerm).subscribe(
(actualPhotos) => {
expect(actualPhotos).toEqual(photos);
}
);
这会导致一个已知的 测试输出 问题:如果正在测试的代码出现问题,那么Observable将不会发出任何内容。因此,expect
中的 下一个(next)
回调函数将不会被调用。尽管出现了缺陷,Jasmine仍然认为一切正常。
期望值的变化 |
有几种方法可以解决这个问题。我们选择了一个最初为undefined
的变量,并分配了一个值。
let actualPhotos: Photo[] | undefined;
flickrService.searchPublicPhotos(searchTerm).subscribe(
(otherPhotos) => {
actualPhotos = otherPhotos;
}
);
const request = controller.expectOne(expectedUrl);
// Answer the request so the Observable emits a value.
request.flush({ photos: { photo: photos } });
// Now verify emitted valued.
expect(actualPhotos).toEqual(photos);
expect
调用位于next
回调函数之外,以确保它被确实调用。如果Observable没有发出任何值或错误的值,那么该规范(spec)将会失败。
验证所有请求都已得到回应
在最后一步,我们确保没有任何未处理的请求。我们期望被测试的方法向特定的URL发起一个请求。我们使用expectOne
找到请求并使用flush
回答它。
最后,我们调用:
controller.verify();
如果有任何未处理的请求,这会导致测试失败。
verify
保证被测试的代码不会发出多余的请求。但它也保证您的规范(spec)检查所有请求,例如通过检查它们的URL。
将所有部分整合起来,完整的测试套件:
const searchTerm = 'dragonfly';
const expectedUrl = `https://www.flickr.com/services/rest/?tags=${searchTerm}&method=flickr.photos.search&format=json&nojsoncallback=1&tag_mode=all&media=photos&per_page=15&extras=tags,date_taken,owner_name,url_q,url_m&api_key=XYZ`;
describe('FlickrService', () => {
let flickrService: FlickrService;
let controller: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [FlickrService],
});
flickrService = TestBed.inject(FlickrService);
controller = TestBed.inject(HttpTestingController);
});
it('searches for public photos', () => {
let actualPhotos: Photo[] | undefined;
flickrService.searchPublicPhotos(searchTerm).subscribe(
(otherPhotos) => {
actualPhotos = otherPhotos;
}
);
const request = controller.expectOne(expectedUrl);
request.flush({ photos: { photo: photos } });
controller.verify();
expect(actualPhotos).toEqual(photos);
});
});
测试错误情况
我们是否已经完成了对searchPublicPhotos
的测试?我们已经在服务器返回200 OK
的成功情况下进行了测试。但我们尚未测试错误情况!
不良路径 |
searchPublicPhotos
通过从HttpClient
传递错误。如果this.http.get
返回的Observable失败,则由searchPublicPhotos
返回的Observable也会因相同的错误而失败。
无论服务中是否有自定义错误处理,都应该测试不良路径。
让我们模拟“500内部服务器错误”。我们不会使用flush
响应请求,而是通过调用error
来让其失败。
const status = 500;
const statusText = 'Internal Server Error';
const errorEvent = new ErrorEvent('API error');
/* … */
const request = controller.expectOne(expectedUrl);
request.error(
errorEvent,
{ status, statusText }
);
TestRequest
的error
方法需要一个ErrorEvent
和一个可选的options对象。
ErrorEvent
是一种特殊的 错误(Error)
类型。为了测试目的,我们使用 new ErrorEvent('…')
创建一个实例。构造函数参数是描述错误情况的字符串消息。
第二个参数,options对象,允许我们设置HTTP 状态(status)
(如500
),状态文本(statusText)
(如 'Internal Server Error'
)和响应头。在上面的示例中,我们设置了 状态(status)
和 状态文本(statusText)
。
期望Observable出错 |
现在我们检查返回的Observable是否正确地行为。它不应该发出下一个值,也不应该完成。它必须失败并出现错误。
我们通过订阅next
、error
和complete
事件来实现这一点:
flickrService.searchPublicPhotos(searchTerm).subscribe(
() => {
/* next handler must not be called! */
},
(error) => {
/*
error handler must be called!
Also, we need to inspect the error.
*/
},
() => {
/* complete handler must not be called! */
},
);
fail
|
当调用next
或complete
处理程序时,规范必须立即失败。为此,有一个方便的全局Jasmine函数:fail
。
为了检查错误,我们使用与上面相同的模式,在外部作用域中将错误保存在变量中。
let actualError: HttpErrorResponse | undefined;
flickrService.searchPublicPhotos(searchTerm).subscribe(
() => {
fail('next handler must not be called');
},
(error) => {
actualError = error;
},
() => {
fail('complete handler must not be called');
},
);
在回答请求时发生服务器错误后,我们会检查错误是否被传递。error
处理程序接收到一个包含ErrorEvent
和状态信息的HttpErrorResponse
对象。
if (!actualError) {
throw new Error('Error needs to be defined');
}
expect(actualError.error).toBe(errorEvent);
expect(actualError.status).toBe(status);
expect(actualError.statusText).toBe(statusText);
类型守卫 |
由于actualError
被定义为 HttpErrorResponse | undefined
,我们需要在访问属性之前先排除undefined
的情况。
使用 expect(actualError).toBeDefined()
可以实现这一点。但是TypeScript编译器不知道这将排除undefined
的情况。因此,我们需要手动抛出异常。
以下是完整的错误情况规范:
it('passes through search errors', () => {
const status = 500;
const statusText = 'Server error';
const errorEvent = new ErrorEvent('API error');
let actualError: HttpErrorResponse | undefined;
flickrService.searchPublicPhotos(searchTerm).subscribe(
() => {
fail('next handler must not be called');
},
(error) => {
actualError = error;
},
() => {
fail('complete handler must not be called');
},
);
controller.expectOne(expectedUrl).error(
errorEvent,
{ status, statusText }
);
if (!actualError) {
throw new Error('Error needs to be defined');
}
expect(actualError.error).toBe(errorEvent);
expect(actualError.status).toBe(status);
expect(actualError.statusText).toBe(statusText);
});
这个例子有意地冗长。它向您展示了如何测试所有细节。它会快速失败并提供有用的错误消息。
这种方法适用于具有专门错误处理的服务方法。例如,服务可能区分成功的响应(如“200 OK”)、客户端错误(如“404 Not Found”)和服务器错误(如“500 Server error”)。
查找待处理请求的替代方法
我们使用了controller.expectOne
来查找与预期URL匹配的请求。有时需要指定更多的条件,如方法(GET
、POST
等)、头部或请求体。
expectOne
有几种签名。我们使用了最简单的签名,一个被解释为URL的字符串:
controller.expectOne('https://www.example.org')
要搜索具有特定方法和URL的请求,请传递一个包含这些属性的对象:
controller.expectOne({
method: 'GET',
url: 'https://www.example.org'
})
如果您需要通过查看请求的详细信息来查找一个请求,可以传递一个函数:
controller.expectOne(
(requestCandidate) =>
requestCandidate.method === 'GET' &&
requestCandidate.url === 'https://www.example.org' &&
requestCandidate.headers.get('Accept') === 'application/json',
);
这个判断函数会对每个请求进行调用,决定候选请求是否匹配,并返回一个布尔值。
这使您能够以编程方式筛选所有请求并检查所有条件。候选请求是一个具有诸如method
、url
、headers
、body
、params
等属性的 HttpRequest 实例。
有两种可能的方法:要么您使用具有多个条件的expectOne
,就像谓词示例中一样。如果某个请求细节不匹配,expectOne
会抛出异常并导致测试失败。
要么您使用具有少量条件的expectOne
,传递 { method: '…', url: '…' }
。要检查请求的详细信息,仍然可以使用Jasmine的断言。
expectOne
返回一个TestRequest
实例。该对象只有用于响应请求的方法,但没有关于请求的直接信息。使用request
属性来访问底层的HttpRequest
。
// Get the TestRequest.
const request = controller.expectOne({
method: 'GET',
url: 'https://www.example.org'
});
// Get the underlying HttpRequest. Yes, this is confusing.
const httpRequest = request.request;
expect(httpRequest.headers.get('Accept')).toBe('application/json');
request.flush({ success: true });
这与上面判断的示例相等,但如果标头不正确,则会给出更具体的错误消息。
match
|
除了expectOne
之外,还有一个match
方法可用于找到满足特定条件的多个请求。它返回一个请求数组。如果没有匹配项,数组为空,但规范不会失败。因此,您需要添加Jasmine的断言来检查数组和其中的请求。
假设存在一个名为CommentService
的服务,其中有一个名为postTwoComments
的方法。被测试的代码发起两个请求到相同的URL,但请求体不同。
@Injectable()
class CommentService() {
constructor(private http: HttpClient) {}
public postTwoComments(firstComment: string, secondComment: string) {
return combineLatest([
this.http.post('/comments/new', { comment: firstComment }),
this.http.post('/comments/new', { comment: secondComment }),
]);
}
}
规范可以包含以下内容:
const firstComment = 'First comment!';
const secondComment = 'Second comment!';
commentService
.postTwoComments(firstComment, secondComment)
.subscribe();
const requests = controller.match({
method: 'POST',
url: '/comments/new',
});
expect(requests.length).toBe(2);
expect(requests[0].request.body).toEqual({ comment: firstComment });
expect(requests[1].request.body).toEqual({ comment: secondComment });
requests[0].flush({ success: true });
requests[1].flush({ success: true });
我们验证请求的数量以及每个请求的请求体。如果这些检查通过,我们回答每个请求。
测试服务:概述
总体而言,测试服务比测试其他Angular应用程序部分更容易。大多数服务都有明确的目的和明确定义的公共API。
如果要测试的服务依赖于另一个服务,单元测试需要对依赖进行虚拟化。这可能是最困难的部分,但与虚拟化组件依赖的服务所需的工作量相同。
预定义的测试模块 |
Angular提供了一些关键的服务,这些服务通常在您自己的服务中使用。由于Angular旨在具有可测试性,Angular还提供了用于将这些服务替换为虚拟化版本的工具。
我们在测试依赖于HttpClient
的服务时使用了HttpClientTestingModule
。再举一个例子,还有 RouterTestingModule
用于测试依赖于Router
和Location
的服务。