测试管道
学习目标
-
验证同步、纯粹管道的输出
-
测试从服务加载数据的异步、非纯粹管道
Angular管道是从组件模板中调用的特殊函数。它的目的是转换一个值:您将一个值传递给管道,管道计算出一个新值并返回。
管道的名称源自位于值和管道名称之间的竖线 “\|” 。这个概念以及 “\|” 语法来自Unix管道和Unix shell。
在这个例子中,user.birthday
的值通过 date
管道进行转换:
{{ user.birthday | date }}
格式化 |
管道经常用于国际化,包括标签和消息的翻译,日期、时间和各种数字的格式化。在这些情况下,管道的输入值不应该显示给用户。输出值是可读的。
内置管道的示例包括DatePipe
、CurrencyPipe
和DecimalPipe
。它们分别根据本地化设置格式化日期、金额和数字。另一个著名的管道是AsyncPipe
,它用于解析Observable或Promise。
纯粹管道 |
大多数管道都是纯粹(pure)的,意味着它们仅仅接受一个值并计算一个新值。它们没有副作用(side effects):它们不改变输入值,也不改变其他应用程序部分的状态。像纯函数一样,纯粹的管道相对容易测试。
GreetPipe
让我们首先研究管道的结构,找到测试它的方法。本质上,管道是一个具有公共transform
方法的类。下面是一个简单的管道,它期望一个名称并向用户打招呼。
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({ name: 'greet' })
export class GreetPipe implements PipeTransform {
transform(name: string): string {
return `Hello, ${name}!`;
}
}
在组件模板中,我们使用管道来转换一个值:
{{ 'Julie' | greet }}
GreetPipe
接受字符串 'Julie'
并计算一个新的字符串,'Hello, Julie!'
。
简单设置 vs 复杂设置 |
有两种重要的方法来测试管道:
-
手动创建一个Pipe类的实例,然后调用
transform
方法。这种方式快速而直接。它需要最小的设置。
-
设置一个
TestBed
。渲染一个使用该管道的宿主组件,然后检查DOM中的文本内容。这种方式紧密模拟了管道在实践中的使用方式。它还测试了管道的名称,如
@Pipe()
装饰器中声明的名称。
这两种方式都允许测试依赖于服务的管道。我们可以提供原始依赖项来编写集成测试。或者我们可以提供虚拟依赖项来编写单元测试。
GreetPipe 测试
GreetPipe
没有任何依赖项。我们选择第一种方式,编写一个单元测试来检查单个实例。
首先,我们创建一个Jasmine测试套件。在beforeEach
块中,我们创建一个GreetPipe
的实例。在规范中,我们详细检查transform
方法。
describe('GreetPipe', () => {
let greetPipe: GreetPipe;
beforeEach(() => {
greetPipe = new GreetPipe();
});
it('says Hello', () => {
expect(greetPipe.transform('Julie')).toBe('Hello, Julie!');
});
});
我们使用字符串 'Julie'
调用transform
方法,并期望输出 'Hello, Julie!'
。
这是在GreetPipe
示例中需要进行测试的全部内容。如果transform
方法包含需要测试的更多逻辑,我们将添加更多的规格来使用不同的输入调用该方法。
测试具有依赖关系的管道
许多管道依赖于本地设置,包括用户界面语言、日期和数字格式规则,以及所选的国家、地区或货币。
我们正在引入并测试TranslatePipe
,这是一个具有服务依赖性的复杂管道。
示例应用程序允许您在运行时更改用户界面语言。解决这个任务的一种流行解决方案是 ngx-translate 库。为了本指南的目的,我们将采用ngx-translate已经证明有效的方法,但自己实现和测试代码。
TranslateService
当前语言存储在TranslateService
中。该服务还加载并保存当前语言的翻译内容。
翻译内容以键和翻译字符串的映射形式存储。例如,如果当前语言是英语,键greeting
将翻译为“Hello!”。
TranslateService
的代码如下所示:
import { HttpClient } from '@angular/common/http';
import { EventEmitter, Injectable } from '@angular/core';
import { Observable, of } from 'rxjs';
import { map, take } from 'rxjs/operators';
export interface Translations {
[key: string]: string;
}
@Injectable()
export class TranslateService {
/** The current language */
private currentLang = 'en';
/** Translations for the current language */
private translations: Translations | null = null;
/** Emits when the language change */
public onTranslationChange = new EventEmitter<Translations>();
constructor(private http: HttpClient) {
this.loadTranslations(this.currentLang);
}
/** Changes the language */
public use(language: string): void {
this.currentLang = language;
this.loadTranslations(language);
}
/** Translates a key asynchronously */
public get(key: string): Observable<string> {
if (this.translations) {
return of(this.translations[key]);
}
return this.onTranslationChange.pipe(
take(1),
map((translations) => translations[key])
);
}
/** Loads the translations for the given language */
private loadTranslations(language: string): void {
this.translations = null;
this.http
.get<Translations>(`assets/${language}.json`)
.subscribe((translations) => {
this.translations = translations;
this.onTranslationChange.emit(translations);
});
}
}
这是服务提供的功能:
-
use
方法:设置当前语言并通过HTTP加载翻译的JSON。 -
get
方法:获取键的翻译。 -
onTranslationChange
事件发射器(EventEmitter)
:观察翻译use
的变化结果。
在示例项目中,AppComponent
依赖于TranslateService
。在创建时,该服务加载英语翻译。AppComponent
渲染一个选择字段,允许用户更改语言。
TranslatePipe
为了显示一个已翻译的标签,一个组件可以为每个翻译键手动调用服务的get
方法。相反,我们引入TranslatePipe
来完成繁重的工作。它允许我们编写:
{{ 'greeting' | translate }}
这将翻译键 'greeting'
。
以下是代码:
import {
ChangeDetectorRef,
OnDestroy,
Pipe,
PipeTransform,
} from '@angular/core';
import { Subscription } from 'rxjs';
import { TranslateService } from './translate.service';
@Pipe({
name: 'translate',
pure: false,
})
export class TranslatePipe implements PipeTransform, OnDestroy {
private lastKey: string | null = null;
private translation: string | null = null;
private onTranslationChangeSubscription: Subscription;
private getSubscription: Subscription | null = null;
constructor(
private changeDetectorRef: ChangeDetectorRef,
private translateService: TranslateService
) {
this.onTranslationChangeSubscription =
this.translateService.onTranslationChange.subscribe(
() => {
if (this.lastKey) {
this.getTranslation(this.lastKey);
}
}
);
}
public transform(key: string): string | null {
if (key !== this.lastKey) {
this.lastKey = key;
this.getTranslation(key);
}
return this.translation;
}
private getTranslation(key: string): void {
this.getSubscription?.unsubscribe();
this.getSubscription = this.translateService
.get(key)
.subscribe((translation) => {
this.translation = translation;
this.changeDetectorRef.markForCheck();
this.getSubscription = null;
});
}
public ngOnDestroy(): void {
this.onTranslationChangeSubscription.unsubscribe();
this.getSubscription?.unsubscribe();
}
}
异步翻译 |
TranslatePipe
是不完美(impure)的,因为翻译是异步加载的。当第一次调用时,transform
方法无法同步返回正确的翻译。它调用TranslateService
的get
方法,该方法返回一个Observable。
触发变更检测 |
一旦翻译加载完成,TranslatePipe
会保存它并通知Angular变更检测器。特别是,它通过调用 ChangeDetectorRef
的markForCheck 方法将相应的视图标记为已更改。
然后,Angular会重新评估使用该管道的每个表达式,比如 'greeting' | translate
,并再次调用transform
方法。最后,transform
同步返回正确的翻译结果。
翻译变更 |
当用户更改语言并加载新的翻译时,同样的过程会发生。该管道订阅TranslateService
的onTranslationChange
事件,并再次调用TranslateService
来获取新的翻译。
TranslatePipe 测试
现在让我们来测试TranslatePipe
!我们可以编写一个整合TranslateService
依赖的测试。或者我们编写一个使用虚拟对象替代依赖的单元测试。
TranslateService
执行HTTP请求来加载翻译。在测试TranslatePipe
时,我们应该避免这些副作用。因此,让我们使用虚拟对象来替代Service来编写一个单元测试。
let translateService: Pick<
TranslateService, 'onTranslationChange' | 'get'
>;
/* … */
translateService = {
onTranslationChange: new EventEmitter<Translations>(),
get(key: string): Observable<string> {
return of(`Translation for ${key}`);
},
};
虚拟对象是对原始对象的部分实现。我们只需要测试TranslatePipe
中的onTranslationChange
属性和get
方法。后者返回一个包含键的虚拟翻译,这样我们就可以测试键是否被正确传递。
宿主组件 |
现在我们需要决定是直接测试管道还是在宿主组件中测试。两种解决方案都没有明显的更简单或更健壮的优势。在示例项目中,您会发现这两种解决方案。在本指南中,我们将讨论使用TestBed
和宿主组件的解决方案。
让我们从宿主组件开始:
const key1 = 'key1';
const key2 = 'key2';
@Component({
template: '{{ key | translate }}',
})
class HostComponent {
public key = key1;
}
该组件使用TranslatePipe
来翻译其key
属性。默认情况下,它被设置为key1
。还有第二个常量key2
,用于测试后续键的更改。
让我们设置测试套件:
describe('TranslatePipe: with TestBed and HostComponent', () => {
let fixture: ComponentFixture<HostComponent>;
let translateService: Pick<
TranslateService, 'onTranslationChange' | 'get'
>;
beforeEach(async () => {
translateService = {
onTranslationChange: new EventEmitter<Translations>(),
get(key: string): Observable<string> {
return of(`Translation for ${key}`);
},
};
await TestBed.configureTestingModule({
declarations: [TranslatePipe, HostComponent],
providers: [
{ provide: TranslateService, useValue: translateService }
],
}).compileComponents();
translateService = TestBed.inject(TranslateService);
fixture = TestBed.createComponent(HostComponent);
});
/* … */
});
在测试模块中,我们声明要测试的管道和 宿主组件(HostComponent)
。对于TranslateService
,我们提供一个虚拟对象。就像在组件测试中一样,我们创建组件并检查渲染的DOM。
同步和异步翻译 |
需要测试什么?我们需要检查 {{ key | translate }}
是否计算为 key1的翻译
。但是有两种需要测试的情况:
-
翻译已加载完成。管道的
transform
方法立即同步返回正确的翻译。TranslateService
的get
返回的Observable立即发出翻译并完成。 -
翻译正在进行中。
transform
方法返回null
(或过时的翻译)。Observable随后在任意时间完成。然后,触发变更检测,transform
方法第二次被调用并返回正确的翻译。
在测试中,我们为这两种情况编写规格:
it('translates the key, sync service response', /* … */);
it('translates the key, async service response', /* … */);
让我们从第一种情况开始。规格很简单。
it('translates the key, sync service response', () => {
fixture.detectChanges();
expectContent(fixture, 'Translation for key1');
});
请记住,TranslateService
虚拟对象返回使用of
创建的Observable。
return of(`Translation for ${key}`);
这个Observable发出一个值并立即完成。这模拟了服务已经加载完翻译的情况。
我们只需要调用detectChanges
。Angular会调用管道的transform
方法,该方法调用TranslateService
的get
方法。Observable立即发出翻译,transform
将其传递出去。
最后,我们使用 expectContent
组件辅助函数 来测试DOM输出。
模拟延迟 |
测试第二种情况比较棘手,因为Observable需要异步发出。有很多方法可以实现这一点。为了简单起见,我们将使用 RxJS的delay
操作符。
同时,我们正在编写一个异步的规格。也就是说,Jasmine需要等待Observable和断言完成后才能完成规格。
fakeAsync 和 tick
|
同样,有几种方法可以实现这一点。我们将使用Angular的fakeAsync
和tick
函数。我们在 测试具有异步验证器的表单时 介绍过它们。
简单回顾一下:fakeAsync
会冻结时间并阻止异步任务的执行。然后,tick
函数模拟时间的流逝,执行计划的任务。
fakeAsync
包装传递给 it
的函数:
it('translates the key, async service response', fakeAsync(() => {
/* … */
});
接下来,我们需要将TranslateService
的get
方法更改为异步方法。
it('translates the key, async service response', fakeAsync(() => {
translateService.get = (key) =>
of(`Async translation for ${key}`).pipe(delay(100));
/* … */
});
延迟 Observable |
我们仍然使用of
,但是我们将输出延迟100毫秒。具体的数字并不重要,只要有一些大于或等于1的延迟即可。
现在,我们可以第一次调用detectChanges
。
it('translates the key, async service response', fakeAsync(() => {
translateService.get = (key) =>
of(`Async translation for ${key}`).pipe(delay(100));
fixture.detectChanges();
/* … */
});
第一次调用管道的transform
方法,由于Observable不会立即发出值,所以返回null
。
因此,我们期望输出为空:
it('translates the key, async service response', fakeAsync(() => {
translateService.get = (key) =>
of(`Async translation for ${key}`).pipe(delay(100));
fixture.detectChanges();
expectContent(fixture, '');
/* … */
});
让时间流逝 |
下面有趣的部分来了。我们希望Observable现在发出一个值。我们使用 tick(100)
来模拟经过了100毫秒的时间。
it('translates the key, async service response', fakeAsync(() => {
translateService.get = (key) =>
of(`Async translation for ${key}`).pipe(delay(100));
fixture.detectChanges();
expectContent(fixture, '');
tick(100);
/* … */
});
这导致Observable发出翻译并完成。管道接收到翻译并保存起来。
为了在DOM中看到变化,我们进行第二次变更检测。管道的transform
方法第二次被调用并返回正确的翻译。
it('translates the key, async service response', fakeAsync(() => {
translateService.get = (key) =>
of(`Async translation for ${key}`).pipe(delay(100));
fixture.detectChanges();
expectContent(fixture, '');
tick(100);
fixture.detectChanges();
expectContent(fixture, 'Async translation for key1');
}));
一开始测试这些细节可能显得过于琐碎。但是TranslatePipe
中的逻辑是有原因的。
还有两个规格需要编写:
it('translates a changed key', /* … */);
it('updates on translation change', /* … */);
TranslatePipe
异步接收到翻译并存储了键和翻译。当Angular再次使用相同的键(key)调用transform
时,管道会同步返回翻译。由于管道被标记为impure,Angular不会缓存transform
的结果。
不同的键 |
当使用不同的键(key)调用translate
时,管道需要获取新的翻译。我们通过将HostComponent
的key
属性从key1
更改为key2
来模拟这种情况。
it('translates a changed key', () => {
fixture.detectChanges();
fixture.componentInstance.key = key2;
fixture.detectChanges();
expectContent(fixture, 'Translation for key2');
});
经过一次变更检测后,DOM中包含了key2
的更新翻译。
翻译更改 |
最后但并非最不重要的是,当用户更改语言并加载了新的翻译时,管道需要从TranslateService
获取新的翻译。为此,管道订阅了服务的onTranslationChange
事件发射器。
我们的TranslateService
虚拟对象也支持onTranslationChange
,因此我们调用emit
方法来模拟翻译更改。在此之前,我们让服务返回不同的翻译,以便在DOM中看到变化。
it('updates on translation change', () => {
fixture.detectChanges();
translateService.get = (key) =>
of(`New translation for ${key}`);
translateService.onTranslationChange.emit({});
fixture.detectChanges();
expectContent(fixture, 'New translation for key1');
});
我们成功完成了!毫无疑问,编写这些规格是具有挑战性的。
TranslateService
和TranslatePipe
是具有经过验证的API的非平凡示例。ngx-translate的原始类更加强大。如果您正在寻找一个稳健且灵活的解决方案,应该直接使用ngx-translate库。