Angular 应用级别的依赖 Fake

原文:Faking dependencies in Angular applications

使用 Angular 依赖注入系统的强大功能,我们可以伪造特定的用例。 这对于自动化测试很有用,但在本文中,我们将研究一种将其用于手动测试的方法。

为了让我们的生活更轻松,我们将创建一个浏览器伪造组件,由于自定义结构指令,该组件仅在开发模式下启用。 为了好玩,我们将添加文本管道以在我们的组件模板中使用常见的字符串操作。

Simulating a browser environment

Dynamically replacing a dependency using a class-based service

用户代理令牌工厂只对每个模块注入器评估一次,如果它没有被祖先组件或指令提供的元素注入器替换,我们必须使用另一种技术来伪造依赖项。 我们将使用基于类的服务依赖替换依赖注入令牌依赖。

// internet-explorer-11-banner.component.ts
import { Component } from '@angular/core';

import { InternetExplorerService } from './internet-explorer.service';

@Component({
  selector: 'internet-explorer-11-banner',
  templateUrl: './internet-explorer-11-banner.component.html',
})
export class InternetExplorer11BannerComponent {
  private isDismissed = false;

  get isBannerVisible() {
    return this.internetExplorer.isInternetExplorer11State && !this.isDismissed;
  }

  constructor(
    private internetExplorer: InternetExplorerService,
  ) {}

  onDismiss() {
    this.isDismissed = true;
  }
}
// internet-explorer-service.ts
import { Inject, Injectable } from '@angular/core';

import { userAgentToken } from './user-agent.token';

@Injectable({
  providedIn: 'root',
})
export class InternetExplorerService {
  get isInternetExplorer11State(): boolean {
    return this.isInternetExplorer11(this.userAgent);
  }

  constructor(
    @Inject(userAgentToken) private userAgent: string,
  ) {}

  isInternetExplorer11(userAgent: string): boolean {
    return /Trident/7.0.+rv:11.0/.test(userAgent);
  }
}

首先,我们从依赖注入令牌中提取 Internet Explorer 11 检测到我们新创建的 InternetExplorerService 类。 Internet Explorer 11 检测令牌现在在根据用户代理评估其值时委托给服务。

如前所述,我们不会使用元素注入器在模板中以声明方式动态替换用户代理令牌。 相反,我们将强制更改状态。

Creating an observable state

下面展示的办法不使用 userAgent token 的 injection token,而是使用 Observable. 这个 Observable 对象从另一个 Browser service 里获得。

// internet-explorer.service.ts
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';

import { BrowserService } from './browser.service';

@Injectable({
  providedIn: 'root',
})
export class InternetExplorerService {
  isInternetExplorer11$: Observable<boolean> =
    this.browser.userAgent$.pipe(
      map(userAgent => this.isInternetExplorer11(userAgent)),
    );

  constructor(
    private browser: BrowserService,
  ) {}

  isInternetExplorer11(userAgent: string): boolean {
    return /Trident/7.0.+rv:11.0/.test(userAgent);
  }
}

browser service 实现里,还是会使用 user agent injection token:

// browser.service.ts

import { Inject, Injectable, OnDestroy } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
import { distinctUntilChanged } from 'rxjs/operators';

import { FakeUserAgent } from './fake-user-agent';
import { userAgentToken } from './user-agent.token';

@Injectable({
  providedIn: 'root',
})
export class BrowserService implements OnDestroy {

// 这体现了 Observable 和 BehaviorSubject 的区别:后者实例化时,需要一个初始值:
  
private userAgent = new BehaviorSubject(this.realUserAgent);

  userAgent$ = this.userAgent.pipe(
    distinctUntilChanged(),
  );

  constructor(
    @Inject(userAgentToken) private realUserAgent: string,
  ) {}

  ngOnDestroy() {
    this.userAgent.complete();
  }

  fakeUserAgent(value: FakeUserAgent) {
    this.userAgent.next(FakeUserAgent[value]);
  }

  stopFakingUserAgent() {
    this.userAgent.next(this.realUserAgent);
  }
}

我们将当前用户代理状态存储在 BehaviorSubject 中,它暴露在 BrowserService 的可观察 userAgent$ 属性中。 当整个应用程序需要用户代理时,它应该依赖于这个 observable。

最初,behavior subject 的初始值来自用户代理令牌的真实用户代理字符串。 该值也被存储以备后用,因为我们允许通过两个命令更改浏览器状态。

我们公开了 fakeUserAgent 方法,该方法将用户代理状态设置为假用户代理字符串。 此外,我们允许依赖者调用 stopFakingUserAgent 方法,该方法将用户代理状态重置为真实的用户代理字符串。

Internet Explorer Service 现在公开一个名为 isInternetExplorer11$ 的可观察属性,只要浏览器服务的可观察用户代理属性发出值,就会评估该属性。

The Internet Explorer service now exposes an observable property called isInternetExplorer11$ which is evaluated whenever the observable user agent property of the browser service emits a value.

我们现在需要的只是让弃用横幅组件依赖于可观察的 Internet Explorer 11 检测属性,而不是我们替换的常规属性。

<!-- internet-explorer-11-banner.component.html -->
<aside *ngIf="isBannerVisible$ | async">
  Sorry, we will not continue to support Internet Explorer 11.<br />
  Please upgrade to Microsoft Edge.<br />

  <button (click)="onDismiss()">
    Dismiss
  </button>
</aside>

现在 banner 是否 visible,是由两个 boolean 值控制了,所以使用 combineLatest.

// internet-explorer-11-banner.component.ts
import { Component } from '@angular/core';
import { BehaviorSubject, combineLatest } from 'rxjs';
import { map } from 'rxjs/operators';

import { InternetExplorerService } from './internet-explorer.service';

@Component({
  host: { style: 'display: block;' },
  selector: 'internet-explorer-11-banner',
  templateUrl: './internet-explorer-11-banner.component.html',
})
export class InternetExplorer11BannerComponent {
  private isDismissed = new BehaviorSubject(false);

  isBannerVisible$ = combineLatest(
    this.internetExplorer.isInternetExplorer11$,
    this.isDismissed,
  ).pipe(
    map(([isInternetExplorer11, isDismissed]) =>
      isInternetExplorer11 && !isDismissed),
  );

  constructor(
    private internetExplorer: InternetExplorerService,
  ) {}

  onDismiss(): void {
    this.isDismissed.next(true);
  }
}

在弃用横幅组件中,我们将 Boolean isDismissed 属性替换为 BehaviorSubject ,该属性最初被清除(设置为 false)。 我们现在有一个可观察的 isBannerVisible$ 属性,它是来自 isDismissed 和 InternetExplorerService#isInternetExplorer11$ 的可观察状态的组合。 UI 行为逻辑与之前类似,不同之处在于它现在表示为 observable 管道的一部分。

现在,onDismiss 事件处理程序不再为属性分配布尔值,而是通过 isDismissed 行为主体发出布尔值。

此时,应用程序的行为与我们引入 Internet Explorer 服务和浏览器服务之前的行为完全相同。 我们有浏览器状态更改命令,但我们需要某种机制来触发它们。

为此,我们将开发一个浏览器伪造器组件,使我们能够为应用程序的其余部分伪造浏览器环境。

<!-- browser-faker.component.html -->
<label>
  Fake a browser

  <select [formControl]="selectedBrowser">
    <option value="">
      My browser
    </option>

    <option *ngFor="let browser of browsers"
      [value]="browser">
      {{browser | replace:wordStartPattern:' $&' | trim}}
    </option>
  </select>
</label>
// browser-faker.component.ts
import { Component, OnDestroy, OnInit } from '@angular/core';
import { FormControl } from '@angular/forms';
import { Observable, Subject } from 'rxjs';
import { filter, takeUntil } from 'rxjs/operators';

import { BrowserService } from './browser.service';
import { FakeUserAgent } from './fake-user-agent';

@Component({
  host: { style: 'display: block;' },
  selector: 'browser-faker',
  templateUrl: './browser-faker.component.html',
})
export class BrowserFakerComponent implements OnDestroy, OnInit {
  private defaultOptionValue = '';
  private destroy = new Subject<void>();
  private fakeBrowserSelection$: Observable<FakeUserAgent>;
  private realBrowserSelection$: Observable<void>;

  browsers = Object.keys(FakeUserAgent);
  selectedBrowser = new FormControl(this.defaultOptionValue);
  wordStartPattern = /[A-Z]|d+/g;

  constructor(
    private browser: BrowserService,
  ) {
    this.realBrowserSelection$ = this.selectedBrowser.valueChanges.pipe(
      filter(value => value === this.defaultOptionValue),
      takeUntil(this.destroy),
    );
    this.fakeBrowserSelection$ = this.selectedBrowser.valueChanges.pipe(
      filter(value => value !== this.defaultOptionValue),
      takeUntil(this.destroy),
    );
  }

  ngOnInit(): void {
    this.bindEvents();
  }

  ngOnDestroy() {
    this.unbindEvents();
  }

  private bindEvents(): void {
// 一旦这个 Observable 有事件发生,说明用户选择了 fake browser
    this.fakeBrowserSelection$.subscribe(userAgent =>
      this.browser.fakeUserAgent(userAgent));
    this.realBrowserSelection$.subscribe(() =>
      this.browser.stopFakingUserAgent());
  }

  private unbindEvents(): void {
    this.destroy.next();
    this.destroy.complete();
  }
}

browser faker 组件注入浏览器服务。 它有一个绑定到本机 select 控件的表单控件。 选择浏览器后,我们开始通过浏览器服务伪造其用户代理。 选择默认浏览器选项后,我们会停止伪造用户代理。

现在我们有一个浏览器伪造组件,但我们只希望在开发过程中启用它。 让我们创建一个仅在开发模式下有条件地呈现的结构指令。

创建一个 injection token:

// is-development-mode.token.ts
import { InjectionToken, isDevMode } from '@angular/core';

export const isDevelopmentModeToken: InjectionToken<boolean> =
  new InjectionToken('Development mode flag', {
    factory: (): boolean => isDevMode(),
    providedIn: 'root',
  });
// development-only.directive.ts
import {
  Directive,
  Inject,
  OnDestroy,
  OnInit,
  TemplateRef,
  ViewContainerRef,
} from '@angular/core';

import { isDevelopmentModeToken } from './is-development-mode.token';

@Directive({
  exportAs: 'developmentOnly',
  selector: '[developmentOnly]',
})
export class DevelopmentOnlyDirective implements OnDestroy, OnInit {
  private get isEnabled(): boolean {
    return this.isDevelopmentMode;
  }

  constructor(
    private container: ViewContainerRef,
    private template: TemplateRef<any>,
    @Inject(isDevelopmentModeToken) private isDevelopmentMode: boolean,
  ) {}

  ngOnInit(): void {
    if (this.isEnabled) {
      this.createAndAttachView();
    }
  }

  ngOnDestroy(): void {
    this.destroyView();
  }

  private createAndAttachView(): void {
    this.container.createEmbeddedView(this.template);
  }

  private destroyView(): void {
    this.container.clear();
  }
}

如果应用程序在开发模式下运行,则此结构指令仅呈现它所附加的组件或元素,正如其测试套件所验证的那样。

现在,剩下的就是将弃用横幅和浏览器伪装器添加到我们的应用程序中。

<!-- app.component.html -->
<browser-faker *developmentOnly></browser-faker>
<internet-explorer-11-banner></internet-explorer-11-banner>

URL: <code><browser-url></browser-url></code>

最后的效果:选择 IE 11 时,出现 deprecation 提示:

选择其他浏览器时,该提示消失:

Summary

为了能够模拟用户环境,我们创建了一个在开发模式下有条件地呈现的浏览器伪造组件。 我们将浏览器状态封装在一个基于类的服务中,并让应用程序依赖它。 这与浏览器伪造者使用的服务相同。

浏览器伪造器是在 Angular 应用程序中伪造依赖项的一个简单示例。 我们讨论了动态配置 Angular 依赖注入机制的其他技术。

本文提到的测试程序地址:https://stackblitz.com/edit/testing-and-faking-angular-dependencies-app?file=src%2Fapp%2Fbrowser%2Fbrowser-faker.component.html

更多Jerry的原创文章,尽在:"汪子熙":

原文地址:https://www.cnblogs.com/sap-jerry/p/15043742.html