测试管道

学习目标

  • 验证同步、纯粹管道的输出

  • 测试从服务加载数据的异步、非纯粹管道

Angular管道是从组件模板中调用的特殊函数。它的目的是转换一个值:您将一个值传递给管道,管道计算出一个新值并返回。

管道的名称源自位于值和管道名称之间的竖线 “\|” 。这个概念以及 “\|” 语法来自Unix管道和Unix shell。

在这个例子中,user.birthday的值通过 date 管道进行转换:

{{ user.birthday | date }}
格式化

管道经常用于国际化,包括标签和消息的翻译,日期、时间和各种数字的格式化。在这些情况下,管道的输入值不应该显示给用户。输出值是可读的。

内置管道的示例包括DatePipeCurrencyPipeDecimalPipe。它们分别根据本地化设置格式化日期、金额和数字。另一个著名的管道是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 复杂设置

有两种重要的方法来测试管道:

  1. 手动创建一个Pipe类的实例,然后调用transform方法。

    这种方式快速而直接。它需要最小的设置。

  2. 设置一个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);
      });
  }
}

这是服务提供的功能:

  1. use方法:设置当前语言并通过HTTP加载翻译的JSON。

  2. get方法:获取键的翻译。

  3. 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方法无法同步返回正确的翻译。它调用TranslateServiceget方法,该方法返回一个Observable。

触发变更检测

一旦翻译加载完成,TranslatePipe会保存它并通知Angular变更检测器。特别是,它通过调用 ChangeDetectorRef的markForCheck 方法将相应的视图标记为已更改。

然后,Angular会重新评估使用该管道的每个表达式,比如 'greeting' | translate,并再次调用transform方法。最后,transform同步返回正确的翻译结果。

翻译变更

当用户更改语言并加载新的翻译时,同样的过程会发生。该管道订阅TranslateServiceonTranslationChange事件,并再次调用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的翻译。但是有两种需要测试的情况:

  1. 翻译已加载完成。管道的transform方法立即同步返回正确的翻译。TranslateServiceget返回的Observable立即发出翻译并完成。

  2. 翻译正在进行中。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方法,该方法调用TranslateServiceget方法。Observable立即发出翻译,transform将其传递出去。

最后,我们使用 expectContent组件辅助函数 来测试DOM输出。

模拟延迟

测试第二种情况比较棘手,因为Observable需要异步发出。有很多方法可以实现这一点。为了简单起见,我们将使用 RxJS的delay 操作符。

同时,我们正在编写一个异步的规格。也就是说,Jasmine需要等待Observable和断言完成后才能完成规格。

fakeAsynctick

同样,有几种方法可以实现这一点。我们将使用Angular的fakeAsynctick函数。我们在 测试具有异步验证器的表单时 介绍过它们。

简单回顾一下:fakeAsync会冻结时间并阻止异步任务的执行。然后,tick函数模拟时间的流逝,执行计划的任务。

fakeAsync包装传递给 it 的函数:

it('translates the key, async service response', fakeAsync(() => {
  /* … */
});

接下来,我们需要将TranslateServiceget方法更改为异步方法。

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时,管道需要获取新的翻译。我们通过将HostComponentkey属性从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');
});

我们成功完成了!毫无疑问,编写这些规格是具有挑战性的。

TranslateServiceTranslatePipe是具有经过验证的API的非平凡示例。ngx-translate的原始类更加强大。如果您正在寻找一个稳健且灵活的解决方案,应该直接使用ngx-translate库。