测试指令

学习目标

  • 测试属性指令的效果

  • 测试具有输入和模板的复杂结构性指令

  • 为测试属性指令和结构性指令提供主机组件

Angular初学者很快会遇到四个核心概念:模块、组件、服务和管道。一个不太知名的核心概念是指令。即使是初学者也在不知不觉中使用了指令,因为指令无处不在。

在Angular中,有三种类型的指令:

  1. 组件(Component)是带有模板的指令。组件通常使用元素类型选择器,比如app-counter。Angular然后查找app-counter元素,并将组件模板渲染到这些宿主元素中。

  2. 属性指令(Attribute Directive)在DOM中的现有宿主元素上添加逻辑。内置的属性指令示例包括NgClassNgStyle

  3. 结构性指令(Structural Directive)改变DOM的结构,也就是以编程方式添加和删除元素。内置的结构性指令示例包括NgIfNgForNgSwitch

我们已经测试了组件。我们还需要测试其他两种类型的指令。

测试属性指令

属性指令的名称来自于属性选择器,例如[ngModel]。属性指令没有模板,不能改变DOM结构。

我们已经提到了内置的属性指令NgClassNgStyle。此外,模板驱动和响应式表单都严重依赖属性指令:NgFormNgModelFormGroupDirectiveFormControlName等。

样式逻辑

属性指令经常用于更改元素的样式,可以直接使用内联样式或间接使用类。

大多数样式逻辑可以仅使用CSS来实现,不需要JavaScript代码。但有时需要使用JavaScript来设置内联样式或以编程方式添加类。

ThresholdWarningDirective

我们的 示例应用程序 中都不包含属性指令,因此我们将引入和测试ThresholdWarningDirective

该指令适用于 <input type="number"> 元素。如果选择的数字超过给定的阈值,则切换类。如果数字高于阈值,则应在视觉上标记该字段。

请注意,阈值以上的数字是有效的输入。ThresholdWarningDirective不添加表单控件验证器。我们只是想提醒用户,以便他们检查输入两次。

输入一个大于10的数字以查看效果。

这是该指令的代码:

import {
  Directive, ElementRef, HostBinding, HostListener, Input
} from '@angular/core';

@Directive({
  selector: '[appThresholdWarning]',
})
export class ThresholdWarningDirective {
  @Input()
  public appThresholdWarning: number | null = null;

  @HostBinding('class.overThreshold')
  public overThreshold = false;

  @HostListener('input')
  public inputHandler(): void {
    this.overThreshold =
      this.appThresholdWarning !== null &&
      this.elementRef.nativeElement.valueAsNumber > this.appThresholdWarning;
  }

  constructor(private elementRef: ElementRef<HTMLInputElement>) {}
}

这是我们如何将指令应用于元素:

<input type="number" [appThresholdWarning]="10" />

这意味着:如果用户输入的数字大于10,则用视觉警告标记该字段。

还缺少一点:视觉警告的样式。

input[type='number'].overThreshold {
  background-color: #fe9;
}

在编写指令的测试之前,让我们逐步了解实现的部分。

同名输入

ThresholdWarningDirective 使用属性绑定 [appThresholdWarning]="…" 进行应用。它以相同名称的输入属性接收属性值。这是配置阈值的方式。

@Input()
public appThresholdWarning: number | null = null;
input 事件

使用 HostListener,该指令在宿主元素上监听 input 事件。当用户更改字段的值时,将调用 inputHandler 方法。

inputHandler 方法获取字段的值并检查其是否超过了阈值。结果存储在 overThreshold 布尔属性中。

@HostListener('input')
public inputHandler(): void {
  this.overThreshold =
    this.appThresholdWarning !== null &&
    this.elementRef.nativeElement.valueAsNumber > this.appThresholdWarning;
}
读取值

为了访问宿主元素,我们使用 ElementRef 依赖项。ElementRef 是宿主元素 DOM 节点的包装器。this.elementRef.nativeElement 返回 input 元素的 DOM 节点。valueAsNumber 包含输入值作为数字的值。

切换类

最后,通过使用 HostBinding,将 overThreshold 属性绑定到具有相同名称的类上。这是如何切换类的方式。

@HostBinding('class.overThreshold')
public overThreshold = false;

ThresholdWarningDirective 测试

现在我们了解了发生了什么,我们需要在我们的测试中复制这个工作流程。

宿主组件

首先,属性指令和结构指令需要一个已经存在的宿主元素来应用它们。在测试这些指令时,我们使用一个宿主组件(host Component)来渲染宿主元素。例如,ThresholdWarningDirective 需要一个 <input type="number"> 宿主元素。

@Component({
  template: `
    <input type="number"
      [appThresholdWarning]="10" />
  `
})
class HostComponent {}

我们将渲染这个组件。我们需要使用 TestBed 进行标准的 组件测试设置

describe('ThresholdWarningDirective', () => {
  let fixture: ComponentFixture<HostComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [ThresholdWarningDirective, HostComponent],
    }).compileComponents();

    fixture = TestBed.createComponent(HostComponent);
    fixture.detectChanges();
  });

  /* … */
});

在配置测试模块时,我们声明了要测试的指令和宿主组件。就像在组件测试中一样,我们渲染了组件并获得了一个 ComponentFixture

找到输入元素

在接下来的规范中,我们需要访问输入元素。我们使用标准的方法:使用 data-testid 属性和 findEl 测试助手

为了方便起见,我们在 beforeEach 块中选择了输入元素。我们将其保存在一个名为 input 的共享变量中。

@Component({
  template: `
    <input type="number"
      [appThresholdWarning]="10"
      data-testid="input" />
  `
})
class HostComponent {}

describe('ThresholdWarningDirective', () => {
  let fixture: ComponentFixture<HostComponent>;
  let input: HTMLInputElement;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [ThresholdWarningDirective, HostComponent],
    }).compileComponents();

    fixture = TestBed.createComponent(HostComponent);
    fixture.detectChanges();

    input = findEl(fixture, 'input').nativeElement;
  });

  /* … */
});
检查类

第一个规范确保当用户未触摸输入时,指令不起作用。使用元素的 classList,我们期望overThreshold类不存在。

it('does not set the class initially', () => {
  expect(input.classList.contains('overThreshold')).toBe(false);
});

下一个规范输入一个超过阈值的数字。为了模拟用户输入,我们使用了我们方便的测试助手setFieldValue。然后,该规范期望该类存在。

it('adds the class if the number is over the threshold', () => {
  setFieldValue(fixture, 'input', '11');
  fixture.detectChanges();
  expect(input.classList.contains('overThreshold')).toBe(true);
});

setFieldValue触发了一个伪造的 input 事件。这会触发指令的事件处理程序。11大于阈值10,所以添加了该类。我们仍然需要调用detectChanges以便更新DOM。

最后一个规范确保阈值仍然被视为安全值。不应该显示任何警告。

it('removes the class if the number is at the threshold', () => {
  setFieldValue(fixture, 'input', '10');
  fixture.detectChanges();
  expect(input.classList.contains('overThreshold')).toBe(false);
});

就是这样了!测试ThresholdWarningDirective就像测试一个组件一样。不同之处在于组件作为指令的宿主。

ThresholdWarningDirective的完整规范如下:

import { Component } from '@angular/core';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';

import { findEl, setFieldValue } from './spec-helpers/element.spec-helper';
import { ThresholdWarningDirective } from './threshold-warning.directive';

@Component({
  template: `
    <input type="number"
      [appThresholdWarning]="10"
      data-testid="input" />
  `
})
class HostComponent {}

describe('ThresholdWarningDirective', () => {
  let fixture: ComponentFixture<HostComponent>;
  let input: HTMLInputElement;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [ThresholdWarningDirective, HostComponent],
    }).compileComponents();

    fixture = TestBed.createComponent(HostComponent);
    fixture.detectChanges();
    input = findEl(fixture, 'input').nativeElement;
  });

  it('does not set the class initially', () => {
    expect(input.classList.contains('overThreshold')).toBe(false);
  });

  it('adds the class if the number is over the threshold', () => {
    setFieldValue(fixture, 'input', '11');
    fixture.detectChanges();
    expect(input.classList.contains('overThreshold')).toBe(true);
  });

  it('removes the class if the number is at the threshold', () => {
    setFieldValue(fixture, 'input', '10');
    fixture.detectChanges();
    expect(input.classList.contains('overThreshold')).toBe(false);
  });
});

测试结构型指令

结构指令没有像组件那样的模板,而是在内部使用ng-template进行操作。该指令以编程方式将模板呈现到DOM中,并将上下文数据传递给模板。

Render template programmatically

以NgIf指令和NgFor指令为例,它们展示了结构指令的功能:

  • NgIf指令决定是否呈现模板。

  • NgFor指令遍历一个项目列表,并针对每个项目重复呈现模板。

结构指令使用属性选择器,例如[ngIf]。属性应用于具有特殊星号语法的宿主元素,例如*ngIf。在内部,这将被转换为 <ng-template [ngIf]="…"> … </ng-template>

本指南假设您大致了解结构指令的工作原理以及微语法如何转换为指令输入。请参考 官方全面的结构指令指南

PaginateDirective

我们将介绍和测试PaginateDirective,这是一个复杂的结构指令。

分页的 NgFor

PaginateDirective的工作方式类似于NgFor,但不会一次渲染所有列表项。它将项目分散到页面上,通常称为 分页(pagination)

默认情况下,只呈现十个项目。用户可以通过点击“下一页”或“上一页”按钮来切换页面。

在编写测试之前,我们首先需要了解PaginateDirective的外部结构。

最简单的使用方式如下:

<ul>
  <li *appPaginate="let item of items">
    {{ item }}
  </li>
</ul>

这类似于NgFor指令。假设items是一个数字数组([1, 2, 3, …]),上面的示例将渲染数组中的前10个数字。

星号语法appPaginate和所谓的微语法let item of items是语法糖。这是一种更简短、更美观的写法,用于表示复杂的结构。在内部,Angular将代码转换为以下形式:

<ng-template appPaginate let-item [appPaginateOf]="items">
  <li>
    {{ item }}
  </li>
</ng-template>

有一个带有属性appPaginate和属性绑定appPaginateOfng-template。还有一个名为item的模板输入变量。

为每个项目渲染模板

如上所述,结构性指令没有自己的模板,而是在ng-template上进行操作,并以编程方式进行渲染。我们的PaginateDirective与上述ng-template一起工作。该指令为当前页面上的每个项目渲染模板。

既然我们已经看到了Angular的内部表示,我们可以理解PaginateDirective类的结构:

@Directive({
  selector: '[appPaginate]',
})
export class PaginateDirective<T> implements OnChanges {
  @Input()
  public appPaginateOf: T[] = [];

  /* … */
}

该指令使用[appPaginate]属性选择器,并具有名为appPaginateOf的输入。通过编写微语法 *appPaginate="let item of items",实际上将appPaginateOf输入设置为items的值。

指令输入

PaginateDirective具有一个名为perPage的配置选项。它指定每页可见的项目数。

默认情况下,每页有十个项目。要更改它,我们在微语法中设置 perPage: …

<ul>
  <li *appPaginate="let item of items; perPage: 5">
    {{ item }}
  </li>
</ul>

这将被翻译为:

<ng-template
  appPaginate
  let-item
  [appPaginateOf]="items"
  [appPaginatePerPage]="5">
  <li>
    {{ item }}
  </li>
</ng-template>

perPage在Directive的代码中会被翻译为名为appPaginatePerPage的输入属性:

@Directive({
  selector: '[appPaginate]',
})
export class PaginateDirective<T> implements OnChanges {
  @Input()
  public appPaginateOf: T[] = [];

  @Input()
  public appPaginatePerPage = 10;

  /* … */
}

这也是内置的结构性指令(例如 NgIfNgFor)的工作方式。

现在情况变得更加复杂。由于我们想要对项目进行分页,除了渲染项目之外,我们还需要用户控制来翻页。

同样,Structural Directive缺少模板。PaginateDirective不能自己渲染“next”和“previous”按钮。为了保持灵活性,它不应该渲染特定的标记。使用该Directive的Component应该决定控件的外观。

传递另一个模板

通过传递一个模板给Directive,我们解决了这个问题。具体来说,我们将一个独立的ng-template的引用传递给Directive。这将成为Directive操作的第二个模板。

控件模板可能如下所示:

<ng-template
  #controls
  let-previousPage="previousPage"
  let-page="page"
  let-pages="pages"
  let-nextPage="nextPage"
>
  <button (click)="previousPage()">
    Previous page
  </button>
  {{ page }} / {{ pages }}
  <button (click)="nextPage()">
    Next page
  </button>
</ng-template>

#controls 设置一个 模板引用变量。这意味着我们可以通过名称controls进一步引用模板。

上下文对象

该指令使用实现以下TypeScript接口的上下文对象呈现控件模板:

interface ControlsContext {
  page: number;
  pages: number;
  previousPage(): void;
  nextPage(): void;
}

page 是当前页码,pages 是总页数。previousPagenextPage 是用于翻页的函数。

使用上下文中的属性

ng-template从上下文中获取这些属性,并将它们保存在同名的本地变量中:

let-previousPage="previousPage"
let-page="page"
let-pages="pages"
let-nextPage="nextPage"

这意味着:将上下文属性previousPage提取出来,在模板中以previousPage的名称进行访问。其他属性也是如此。

模板的内容相当简单。它呈现了两个按钮用于翻页,使用这些函数作为点击事件处理程序。它输出当前页码和总页数。

<button (click)="previousPage()">
  Previous page
</button>
{{ page }} / {{ pages }}
<button (click)="nextPage()">
  Next page
</button>

最后但同样重要的是,我们使用微语法将模板传递给PaginateDirective

<ul>
  <li *appPaginate="let item of items; perPage: 5; controls: controls">
    {{ item }}
  </li>
</ul>

这将被翻译为:

<ng-template
  appPaginate
  let-item
  [appPaginateOf]="items"
  [appPaginatePerPage]="5"
  [appPaginateControls]="controls">
  <li>
    {{ item }}
  </li>
</ng-template>

在微语法中,controls: … 被翻译为一个名为 appPaginateControls 的输入。这完成了指令的外部结构。

@Directive({
  selector: '[appPaginate]',
})
export class PaginateDirective<T> implements OnChanges {
  @Input()
  public appPaginateOf: T[] = [];

  @Input()
  public appPaginatePerPage = 10;

  @Input()
  public appPaginateControls?: TemplateRef<ControlsContext>;

  /* … */
}

PaginateDirective的内部工作对于测试来说并不相关,因此我们不会在这里详细讨论它们。请参考Angular指南中的 编写结构型指令 以获取一般说明。

PaginateDirective 测试

我们已经探索了PaginateDirective的所有功能,现在准备好进行测试!

宿主组件

首先,我们需要一个宿主组件来应用正在测试的结构指令。我们让它呈现一个包含十个数字的列表,每页显示三个数字。

const items = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

@Component({
  template: `
    <ul>
      <li
        *appPaginate="let item of items; perPage: 3"
        data-testid="item"
      >
        {{ item }}
      </li>
    </ul>
  `,
})
class HostComponent {
  public items = items;
}
控件模板

由于我们还想测试自定义控件功能,我们需要传递一个控件模板。我们将使用上面讨论过的简单控件。

const items = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

@Component({
  template: `
    <ul>
      <li
        *appPaginate="let item of items; perPage: 3; controls: controls"
        data-testid="item"
      >
        {{ item }}
      </li>
    </ul>
    <ng-template
      #controls
      let-previousPage="previousPage"
      let-page="page"
      let-pages="pages"
      let-nextPage="nextPage"
    >
      <button
        (click)="previousPage()"
        data-testid="previousPage">
        Previous page
      </button>
      <span data-testid="page">{{ page }}</span>
      /
      <span data-testid="pages">{{ pages }}</span>
      <button
        (click)="nextPage()"
        data-testid="nextPage">
        Next page
      </button>
    </ng-template>
  `,
})
class HostComponent {
  public items = items;
}

模板代码已经包含了data-testid属性。这是我们在测试中查找和检查元素的方式(请参阅 使用测试ID查询DOM)。

这是相当复杂的设置,但毕竟,我们希望在实际条件下测试PaginateDirective

测试套件配置了一个测试模块,声明了 宿主组件(HostComponent)PaginateDirective,并渲染了宿主组件:

describe('PaginateDirective', () => {
  let fixture: ComponentFixture<HostComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [PaginateDirective, HostComponent],
    }).compileComponents();

    fixture = TestBed.createComponent(HostComponent);
    fixture.detectChanges();
  });

  /* … */
});

这是一个标准的组件测试设置-暂时没有特殊之处。

第一个规范验证指令在第一页上呈现项目,对于我们来说,是数字1、2和3。

我们使用 data-testid="item" 标记了项目元素。我们使用 findEls测试助手 来查找具有该测试ID的所有元素。

预期渲染的项目

我们期望找到三个项目。然后,我们检查每个项目的文本内容,并期望它与数字列表中的项目相匹配。

it('renders the items of the first page', () => {
  const els = findEls(fixture, 'item');
  expect(els.length).toBe(3);

  expect(els[0].nativeElement.textContent.trim()).toBe('1');
  expect(els[1].nativeElement.textContent.trim()).toBe('2');
  expect(els[2].nativeElement.textContent.trim()).toBe('3');
});

目前,期望值重复且难以阅读。因此,我们引入一个小的辅助函数。

function expectItems(
  elements: DebugElement[],
  expectedItems: number[],
): void {
  elements.forEach((element, index) => {
    const actualText = element.nativeElement.textContent.trim();
    expect(actualText).toBe(String(expectedItems[index]));
  });
}

这样,我们可以重新编写规范,使其更易于理解:

it('renders the items of the first page', () => {
  const els = findEls(fixture, 'item');
  expect(els.length).toBe(3);
  expectItems(els, [1, 2, 3]);
});
检查控件

下一个规范证明了控件模板被渲染,并传递了当前页和总页数。

这些元素分别具有 data-testid="page"data-testid="pages" 属性。我们使用 expectText测试助手 来检查它们的文本内容。

it('renders the current page and total pages', () => {
  expectText(fixture, 'page', '1');
  expectText(fixture, 'pages', '4');
});

另外还有三个规范处理翻页控件。让我们先从“下一页”按钮开始。

it('shows the next page', () => {
  click(fixture, 'nextPage');
  fixture.detectChanges();

  const els = findEls(fixture, 'item');
  expect(els.length).toBe(3);
  expectItems(els, [4, 5, 6]);
});
翻页操作

我们使用 click 测试助手模拟点击“下一页”按钮。然后,我们启动Angular的变更检测,以便重新渲染组件和指令。

最后,我们验证指令是否已经渲染了接下来的三个项目,即数字4、5和6。

“上一页”按钮的规范看起来类似。首先,我们跳转到第二页,然后返回到第一页。

it('shows the previous page', () => {
  click(fixture, 'nextPage');
  click(fixture, 'previousPage');
  fixture.detectChanges();

  const els = findEls(fixture, 'item');
  expect(els.length).toBe(3);
  expectItems(els, [1, 2, 3]);
});
压力测试

我们现在已经涵盖了指令的重要行为。是时候测试边缘情况了!如果我们在第一页点击“上一页”按钮,在最后一页点击“下一页”按钮,指令是否能正确地处理?

it('checks the pages bounds', () => {
  click(fixture, 'nextPage'); // -> 2
  click(fixture, 'nextPage'); // -> 3
  click(fixture, 'nextPage'); // -> 4
  click(fixture, 'nextPage'); // -> 4
  click(fixture, 'previousPage'); // -> 3
  click(fixture, 'previousPage'); // -> 2
  click(fixture, 'previousPage'); // -> 1
  click(fixture, 'previousPage'); // -> 1
  fixture.detectChanges();

  // Expect that the first page is visible again
  const els = findEls(fixture, 'item');
  expect(els.length).toBe(3);
  expectItems(els, [1, 2, 3]);
});

通过点击按钮,我们前进到最后一页,然后再回到第一页。

就是这样!以下是完整的测试代码:

import { Component, DebugElement } from '@angular/core';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';

import {
  findEls,
  expectText,
  click,
} from './spec-helpers/element.spec-helper';
import { PaginateDirective } from './paginate.directive';

const items = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

@Component({
  template: `
    <ul>
      <li
        *appPaginate="let item of items; perPage: 3; controls: controls"
        data-testid="item"
      >
        {{ item }}
      </li>
    </ul>
    <ng-template
      #controls
      let-previousPage="previousPage"
      let-page="page"
      let-pages="pages"
      let-nextPage="nextPage"
    >
      <button (click)="previousPage()" data-testid="previousPage">
        Previous page
      </button>
      <span data-testid="page">{{ page }}</span>
      /
      <span data-testid="pages">{{ pages }}</span>
      <button (click)="nextPage()" data-testid="nextPage">
        Next page
      </button>
    </ng-template>
  `,
})
class HostComponent {
  public items = items;
}

function expectItems(
  elements: DebugElement[],
  expectedItems: number[],
): void {
  elements.forEach((element, index) => {
    const actualText = element.nativeElement.textContent.trim();
    expect(actualText).toBe(String(expectedItems[index]));
  });
}

describe('PaginateDirective', () => {
  let fixture: ComponentFixture<HostComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [PaginateDirective, HostComponent],
    }).compileComponents();

    fixture = TestBed.createComponent(HostComponent);
    fixture.detectChanges();
  });

  it('renders the items of the first page', () => {
    const els = findEls(fixture, 'item');
    expect(els.length).toBe(3);
    expectItems(els, [1, 2, 3]);
  });

  it('renders the current page and total pages', () => {
    expectText(fixture, 'page', '1');
    expectText(fixture, 'pages', '4');
  });

  it('shows the next page', () => {
    click(fixture, 'nextPage');
    fixture.detectChanges();

    const els = findEls(fixture, 'item');
    expect(els.length).toBe(3);
    expectItems(els, [4, 5, 6]);
  });

  it('shows the previous page', () => {
    click(fixture, 'nextPage');
    click(fixture, 'previousPage');
    fixture.detectChanges();

    const els = findEls(fixture, 'item');
    expect(els.length).toBe(3);
    expectItems(els, [1, 2, 3]);
  });

  it('checks the pages bounds', () => {
    click(fixture, 'nextPage'); // -> 2
    click(fixture, 'nextPage'); // -> 3
    click(fixture, 'nextPage'); // -> 4
    click(fixture, 'previousPage'); // -> 3
    click(fixture, 'previousPage'); // -> 2
    click(fixture, 'previousPage'); // -> 1
    fixture.detectChanges();

    // Expect that the first page is visible again
    const els = findEls(fixture, 'item');
    expect(els.length).toBe(3);
    expectItems(els, [1, 2, 3]);
  });
});

PaginateDirective是一个复杂的结构型指令,需要一个复杂的测试设置。一旦我们创建了一个合适的宿主组件,我们就可以使用我们熟悉的测试助手对其进行测试。指令中的逻辑事实对于规范来说并不重要。