测试依赖于服务的组件
学习目标
-
在需要与服务交互的组件之间选择单元测试还是集成测试。
-
创建伪造服务以在隔离环境中测试组件。
-
验证组件与服务正确地交互。
-
了解不同的伪造服务依赖的方法。
我们已经成功地测试了独立的 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. 有效的伪造:原始实例保持不变。
-