测试依赖于服务的组件
学习目标
-
在需要与服务交互的组件之间选择单元测试还是集成测试。
-
创建伪造服务以在隔离环境中测试组件。
-
验证组件与服务正确地交互。
-
了解不同的伪造服务依赖的方法。
我们已经成功地测试了独立的 CounterComponent 和容器 HomeComponent。接下来我们要测试的组件是 ServiceCounterComponent。
顾名思义,该组件依赖于 CounterService。计数状态不存储在组件本身,而是存储在中央服务中。
| 共享中央状态 |
Angular 的依赖注入仅维护一个应用范围的服务实例,也就是所谓的单例。因此,多个 ServiceCounterComponent 实例共享相同的计数状态。如果用户在一个实例中增加计数,其他实例中的计数也会发生变化。
同样,对于该组件,有两种基本的测试方式:
-
使用伪造对象替换
CounterService依赖的单元测试。 -
包含真实
CounterService的集成测试。
本指南将演示这两种方式。对于您的组件,您需要根据实际情况进行选择。以下问题可能会给您提供一些指导:哪种测试类型更有益、更有意义?哪种测试在设置和长期维护方面更容易?
服务依赖的集成测试
对于 ServiceCounterComponent,集成测试比单元测试更容易设置。简单的 CounterService 几乎没有逻辑,也没有其他依赖项。它没有我们需要在测试环境中抑制的副作用,例如 HTTP 请求。它只是改变了它的内部状态。
集成测试与我们已经编写的 CounterComponent 测试几乎相同。
describe('ServiceCounterComponent: integration test', () => {
let component: ServiceCounterComponent;
let fixture: ComponentFixture<ServiceCounterComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ServiceCounterComponent],
providers: [CounterService],
}).compileComponents();
fixture = TestBed.createComponent(ServiceCounterComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('shows the start count', () => {
expectText(fixture, 'count', '0');
});
it('increments the count', () => {
click(fixture, 'increment-button');
fixture.detectChanges();
expectText(fixture, 'count', '1');
});
it('decrements the count', () => {
click(fixture, 'decrement-button');
fixture.detectChanges();
expectText(fixture, 'count', '-1');
});
it('resets the count', () => {
const newCount = 456;
setFieldValue(fixture, 'reset-input', String(newCount));
click(fixture, 'reset-button');
fixture.detectChanges();
expectText(fixture, 'count', String(newCount));
});
});
与 CounterComponent 的测试相比,这里没有什么新的东西,除了一行代码:
providers: [CounterService],
| 提供服务 |
这行代码将 CounterService 添加到测试模块中。Angular 创建一个服务的实例并将其注入到要测试的组件中。这个测试比较简短,因为 ServiceCounterComponent 没有要测试的输入或输出。
由于 CounterService 总是以 count 0 开始,测试需要默认使用这个初始计数。组件和服务都不允许使用不同的初始计数。
| 与服务的交互 |
集成测试不会检查组件的内部工作方式。它只提供了服务,但不检查组件和服务之间的交互。组件可能根本不与服务进行通信。
如果我们想要一个集成测试来验证组件是否将计数存储在服务中,我们需要编写一个包含两个 ServiceCounterComponent 的测试:使用一个组件增加计数时,另一个组件中显示的计数应相应更改。
虚拟服务依赖
让我们继续进行 ServiceCounterComponent 的 单元测试。为了解决这个问题,我们需要学习如何虚拟化服务依赖。
有几种实际的方法,各有利弊。我们已经讨论了 伪造依赖 的两个主要要求:
-
虚拟与原始服务的等价性:虚拟必须具有派生自原始服务的类型。
-
有效的虚拟化:原始服务保持不变。
| 推荐的虚拟化方法 |
本指南将介绍一种实现这些要求的解决方案。请注意,其他解决方案也可能满足这些要求。
我们需要虚拟化的依赖项 CounterService 是一个简单的类,带有 @Injectable() 注解。这是 CounterService 的外部形式:
class CounterService {
public getCount(): Observable<number> { /* … */ }
public increment(): void { /* … */ }
public decrement(): void { /* … */ }
public reset(newCount: number): void { /* … */ }
private notify(): void { /* … */ }
}
我们需要构建一个满足上述需求的虚拟对象。
| 虚拟实例 |
创建一个简单的虚拟对象的最简单方法是使用对象字面量 {…} 和方法:
const currentCount = 123;
const fakeCounterService = {
getCount() {
return of(currentCount);
},
increment() {},
decrement() {},
reset() {},
};
getCount 方法从名为 currentCount 的常量中返回一个固定值。我们稍后将使用该常量来检查组件是否正确使用了该值。
这个虚拟对象还远远不完美,但已经可以替代 CounterService 实例。它像原始对象一样行走和交谈。方法为空或返回固定数据。
| 类型等价性 |
上面的虚拟实现恰好具有与原始对象相同的形状。正如我们讨论的那样,让虚拟对象始终与原始对象保持同步非常重要。
目前,TypeScript还没有强制执行等价性。我们希望TypeScript能够检查虚拟对象是否正确地复制了原始对象。第一次尝试可以添加一个类型声明:
const fakeCounterService: CounterService = {
getCount() {
return of(currentCount);
},
increment() {},
decrement() {},
reset() {},
};
不幸的是,这样不起作用。TypeScript会报错说缺少私有方法和属性:
Type '{ getCount(): Observable<number>; increment(): void; decrement(): void; reset(): void; }' is missing the following properties from type 'CounterService': count, subject, notify
这是正确的。但我们不能在对象字面量中添加私有成员,也不应该这样做。
| 挑选公共成员 |
const fakeCounterService:
Pick<CounterService, keyof CounterService> = {
getCount() {
return of(currentCount);
},
increment() {},
decrement() {},
reset() {},
};
| 保持 fake 与原始内容同步 |
当 CounterService 改变其公共 API 时,依赖的 ServiceCounterComponent 需要进行调整。同样地,fakeCounterService 需要反映这些更改。类型声明会提醒您更新 fake。它防止 fake 与原始内容不同步。
| 只 fake 必要的部分 |
ServiceCounterComponent 调用了 CounterService 的所有公共方法,因此我们已将它们添加到了 fake 中。
如果被测试的代码并未使用整个 API,那么 fake 也无需复制整个 API。只声明被测试代码实际使用的方法和属性即可。
例如,如果被测试的代码只调用了 getCount,只提供这个方法即可。确保添加一个类型声明,从原始类型中挑选出该方法:
const fakeCounterService: Pick<CounterService, 'getCount'> = {
getCount() {
return of(currentCount);
},
};
挑选和其他 映射类型有助于以一种 TypeScript 可以检查等效性的方式将伪造类型与原始类型绑定。
| 对方法进行监视 |
带有方法的普通对象是创建伪实例的一种简单方式。规范需要验证方法是否以正确的参数调用。
Jasmine 间谍 非常适合这项任务。首先的方法是用独立的间谍填充伪实例:
const fakeCounterService:
Pick<CounterService, keyof CounterService> = {
getCount:
jasmine.createSpy('getCount').and.returnValue(of(currentCount)),
increment: jasmine.createSpy('increment'),
decrement: jasmine.createSpy('decrement'),
reset: jasmine.createSpy('reset'),
};
createSpyObj
|
这样做是可以的,但过于冗长。Jasmine 提供了一个便捷的辅助函数 createSpyObj,用于创建带有多个间谍方法的对象。它需要一个描述性名称和一个包含方法名称和返回值的对象:
const fakeCounterService = jasmine.createSpyObj<CounterService>(
'CounterService',
{
getCount: of(currentCount),
increment: undefined,
decrement: undefined,
reset: undefined,
}
);
上面的代码创建了一个对象,其中包含四个方法,它们都是间谍。它们返回给定的值:getCount 返回一个 Observable<number>。其他方法返回 undefined。
| 类型等价性 |
createSpyObj 接受一个 TypeScript 类型变量,用于声明所创建对象的类型。我们在尖括号中传递 CounterService,这样 TypeScript 就会检查伪实例是否与原始对象匹配。
让我们让我们的伪实例开始工作。在 安排(Arrange) 阶段,我们创建了伪实例并将其注入到测试模块中。
describe('ServiceCounterComponent: unit test', () => {
const currentCount = 123;
let component: ServiceCounterComponent;
let fixture: ComponentFixture<ServiceCounterComponent>;
// Declare shared variable
let fakeCounterService: CounterService;
beforeEach(async () => {
// Create fake
fakeCounterService = jasmine.createSpyObj<CounterService>(
'CounterService',
{
getCount: of(currentCount),
increment: undefined,
decrement: undefined,
reset: undefined,
}
);
await TestBed.configureTestingModule({
declarations: [ServiceCounterComponent],
// Use fake instead of original
providers: [
{ provide: CounterService, useValue: fakeCounterService }
],
}).compileComponents();
fixture = TestBed.createComponent(ServiceCounterComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
/* … */
});
测试模块的 提供者(providers) 部分出现了一种新的模式:
providers: [
{ provide: CounterService, useValue: fakeCounterService }
]
| 使用伪实例替代 |
这是关键时刻,我们告诉 Angular:对于 CounterService 依赖项,请使用 fakeCounterService 这个值。这就是我们如何用伪实例替换原始实例。
通常情况下,当组件、服务等请求 CounterService 时,Angular 会实例化并注入一个 CounterService 实例。通过使用 { provide: …, useValue: … },我们跳过了实例化过程,直接提供要注入的值。
现在,安排(Arrange) 阶段已经完成,让我们来编写实际的规范。
行动(Act) 阶段与其他计数器组件的测试相同:我们点击按钮并填写表单字段。
| 验证间谍 |
在 断言(Assert) 阶段,我们需要验证 Service 方法是否已被调用。由于使用了 jasmine.createSpyObj,fakeCounterService 的所有方法都是间谍。我们使用 expect 结合适当的匹配器,如 toHaveBeenCalled、toHaveBeenCalledWith 等。
expect(fakeCounterService.getCount).toHaveBeenCalled();
应用于所有规范,测试套件的样子如下所示:
describe('ServiceCounterComponent: unit test', () => {
const currentCount = 123;
let component: ServiceCounterComponent;
let fixture: ComponentFixture<ServiceCounterComponent>;
// Declare shared variable
let fakeCounterService: CounterService;
beforeEach(async () => {
// Create fake
fakeCounterService = jasmine.createSpyObj<CounterService>(
'CounterService',
{
getCount: of(currentCount),
increment: undefined,
decrement: undefined,
reset: undefined,
}
);
await TestBed.configureTestingModule({
declarations: [ServiceCounterComponent],
// Use fake instead of original
providers: [
{ provide: CounterService, useValue: fakeCounterService }
],
}).compileComponents();
fixture = TestBed.createComponent(ServiceCounterComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('shows the count', () => {
expectText(fixture, 'count', String(currentCount));
expect(fakeCounterService.getCount).toHaveBeenCalled();
});
it('increments the count', () => {
click(fixture, 'increment-button');
expect(fakeCounterService.increment).toHaveBeenCalled();
});
it('decrements the count', () => {
click(fixture, 'decrement-button');
expect(fakeCounterService.decrement).toHaveBeenCalled();
});
it('resets the count', () => {
const newCount = 456;
setFieldValue(fixture, 'reset-input', String(newCount));
click(fixture, 'reset-button');
expect(fakeCounterService.reset).toHaveBeenCalledWith(newCount);
});
});
具有最小逻辑的伪造服务
上面的规范检查了用户交互是否调用了 Service 的方法。它们没有检查在调用 Service 后,组件是否重新呈现了新的计数。
ServiceCounter 的 getCount 方法返回一个 Observable<number>,并且在计数发生变化时通过 Observable 推送新值。规范 it('shows the count', …) 已经证明组件从 Service 获取了计数并进行了渲染。
此外,我们还将检查在推送新值时组件是否进行了更新。在我们的简单 ServiceCounterComponent 和 CounterService 示例中,这并不是严格必要的。但是在组件与服务之间的更复杂交互中,这一点非常重要。
| 组件更新 |
伪造的 getCount 方法返回 of(currentCount),这是一个具有固定值 123 的 Observable。Observable 立即完成并且不会推送其他值。我们需要改变这种行为,以展示组件的更新。
迄今为止,缺乏逻辑的伪造 CounterService 需要获得一些逻辑。getCount 需要返回一个 Observable,在调用 increment、decrement 和 reset 时会发出新的值。
BehaviorSubject
|
我们使用 BehaviorSubject,而不是固定的 Observable,就像原始的 CounterService 实现一样。BehaviorSubject 有一个 next 方法用于推送新值。
我们在测试套件的第一个 beforeEach 块中,在作用域中声明了一个名为 fakeCount$ 的变量,并赋值为一个 BehaviorSubject:
describe('ServiceCounterComponent: unit test with minimal Service logic', () => {
/* … */
let fakeCount$: BehaviorSubject<number>;
beforeEach(async () => {
fakeCount$ = new BehaviorSubject(0);
/* … */
});
/* … */
});
然后我们更改 fakeCounterService,使得方法通过 fakeCount$ 推送新的值。
const newCount = 123;
/* … */
fakeCounterService = {
getCount(): Observable<number> {
return fakeCount$;
},
increment(): void {
fakeCount$.next(1);
},
decrement(): void {
fakeCount$.next(-1);
},
reset(): void {
fakeCount$.next(Number(newCount));
},
};
上面的伪实例是一个带有普通方法的对象。我们不再使用 createSpyObj,因为它不允许伪造方法的实现。
| 对方法进行监视 |
我们丢失了 Jasmine 间谍,需要将它们重新引入。有几种方法可以将方法包装为间谍。为了简单起见,我们使用 spyOn 在所有方法上安装间谍:
spyOn(fakeCounterService, 'getCount').and.callThrough();
spyOn(fakeCounterService, 'increment').and.callThrough();
spyOn(fakeCounterService, 'decrement').and.callThrough();
spyOn(fakeCounterService, 'reset').and.callThrough();
记得添加 .and.callThrough(),这样底层的伪造方法才会被调用。
现在我们的伪服务向组件发送新的计数。我们可以重新引入对组件输出的检查:
fixture.detectChanges();
expectText(fixture, 'count', '…');
将所有部分组装在一起,完整的 ServiceCounterComponent 单元测试如下所示:
describe('ServiceCounterComponent: unit test with minimal Service logic', () => {
const newCount = 456;
let component: ServiceCounterComponent;
let fixture: ComponentFixture<ServiceCounterComponent>;
let fakeCount$: BehaviorSubject<number>;
let fakeCounterService: Pick<CounterService, keyof CounterService>;
beforeEach(async () => {
fakeCount$ = new BehaviorSubject(0);
fakeCounterService = {
getCount(): Observable<number> {
return fakeCount$;
},
increment(): void {
fakeCount$.next(1);
},
decrement(): void {
fakeCount$.next(-1);
},
reset(): void {
fakeCount$.next(Number(newCount));
},
};
spyOn(fakeCounterService, 'getCount').and.callThrough();
spyOn(fakeCounterService, 'increment').and.callThrough();
spyOn(fakeCounterService, 'decrement').and.callThrough();
spyOn(fakeCounterService, 'reset').and.callThrough();
await TestBed.configureTestingModule({
declarations: [ServiceCounterComponent],
providers: [
{ provide: CounterService, useValue: fakeCounterService }
],
}).compileComponents();
fixture = TestBed.createComponent(ServiceCounterComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('shows the start count', () => {
expectText(fixture, 'count', '0');
expect(fakeCounterService.getCount).toHaveBeenCalled();
});
it('increments the count', () => {
click(fixture, 'increment-button');
fakeCount$.next(1);
fixture.detectChanges();
expectText(fixture, 'count', '1');
expect(fakeCounterService.increment).toHaveBeenCalled();
});
it('decrements the count', () => {
click(fixture, 'decrement-button');
fakeCount$.next(-1);
fixture.detectChanges();
expectText(fixture, 'count', '-1');
expect(fakeCounterService.decrement).toHaveBeenCalled();
});
it('resets the count', () => {
setFieldValue(fixture, 'reset-input', newCount);
click(fixture, 'reset-button');
fixture.detectChanges();
expectText(fixture, 'count', newCount);
expect(fakeCounterService.reset).toHaveBeenCalledWith(newCount);
});
});
再次强调,此示例有意冗长。伪实例重新实现了原始逻辑的大部分。这是因为原始的 CounterService 本身逻辑较少。
实际上,服务更加复杂,组件会处理从服务接收到的数据。因此,伪造基本逻辑的工作是值得的。
伪造服务:总结
在测试 Angular 应用程序时,创建伪造服务依赖项并验证其使用是最具挑战性的问题之一。本指南只能对此主题进行简要介绍。
| 可测试的服务 |
伪造服务需要付出努力并进行持续的实践。您编写的单元测试越多,您就会获得更多的经验。更重要的是,实践教会您编写简单的服务,这些服务易于伪造:具有明确的 API 和明显目的的服务。
不幸的是,对于伪造服务而言,并没有最佳实践。您会在网上找到很多方法,它们各有优势和劣势。相关的单元测试具有不同程度的准确性和完整性。
争论如何“正确“伪造服务是没有意义的。您需要根据具体情况决定适合服务的伪造方法。
| 准则 |
有两个准则可能对您有所帮助:
-
测试是否有价值?它是否涵盖了组件和服务之间的重要交互?决定是浅显地测试还是深入地测试交互。
-
无论选择哪种方法,请确保满足 基本要求:
-
1. 伪实例与原始实例等价:伪实例必须具有派生自原始实例的类型。
-
2. 有效的伪造:原始实例保持不变。
-