组件测试

学习目标

  • 使用 Angular 的测试模块设置组件测试

  • 熟悉 Angular 的组件测试抽象

  • 访问渲染的 DOM 并检查文本内容

  • 模拟用户输入,如点击和表单字段输入

  • 测试组件的输入和输出

  • 使用辅助函数处理常见的组件测试任务

组件是 Angular 应用程序的核心。组件组合在一起形成用户界面。

组件涉及多个任务,包括:

  • 将模板渲染到 HTML DOM 中。

  • 使用输入属性接收来自父组件的数据。

  • 使用输出属性向父组件发出数据。

  • 通过注册事件处理程序响应用户输入。

  • 渲染传递的内容(ng-content)和模板(ng-template)。

  • 将数据绑定到表单控件,并允许用户编辑数据。

  • 与服务或其他状态管理器进行通信。

  • 使用路由信息,如当前 URL 和 URL 参数。

所有这些任务都需要进行适当的测试。

计数器组件的单元测试

作为第一个示例,我们将测试 CounterComponent 组件。

在设计组件测试时,指导性的问题是:组件做什么,需要测试什么?如何测试这个行为?

计数器的功能

我们将测试 CounterComponent 的以下功能:

  • 它显示当前计数。初始值为 0,可以通过输入属性进行设置。

  • 当用户激活“+”按钮时,计数增加。

  • 当用户激活“-”按钮时,计数减少。

  • 当用户在重置输入字段中输入一个数字并激活重置按钮时,计数将设置为给定值。

  • 当用户更改计数时,输出属性会发出新的计数。

写下组件的功能已经有助于组织单元测试。上述功能大致对应于测试套件中的规范。

TestBed

即使是简单的计数器组件,在 Angular 中渲染组件也需要进行一些准备工作。如果你查看典型 Angular 应用程序的 main.tsAppModule,你会发现创建了一个“平台”,声明了一个模块,并对该模块进行了初始化。

Angular 编译器将模板转换为 JavaScript 代码。为了准备渲染,会创建组件的实例,解析并注入依赖项,然后设置输入属性。

最后,将模板渲染到 DOM 中。在测试中,你可以手动完成所有这些操作,但你需要深入了解 Angular 的内部实现。

TestBed

作为替代,Angular 团队提供了 TestBed 来简化单元测试。TestBed 创建并配置了一个 Angular 环境,因此你可以安全且轻松地测试特定的应用程序部分,如组件和服务。

配置测试模块

TestBed 提供了一个测试模块,该模块的配置方式与你应用程序中的普通模块相同:你可以声明组件、指令和管道,提供服务和其他可注入对象,以及导入其他模块。TestBed 有一个静态方法 configureTestingModule,它接受一个模块定义:

TestBed.configureTestingModule({
  imports: [ /*… */ ],
  declarations: [ /*… */ ],
  providers: [ /*… */ ],
});
声明必要的部分

在单元测试中,将那些严格必要的部分添加到模块中:被测试的代码、必需的依赖项和伪装对象。例如,当编写 CounterComponent 的单元测试时,我们需要声明该组件类。由于该组件没有依赖项,也没有渲染其他组件、指令或管道,好了我们已经完成了配置。

TestBed.configureTestingModule({
  declarations: [CounterComponent],
});

我们要测试的组件现在是模块的一部分了。我们准备好渲染它了吗?还没有。首先,我们需要编译所有声明的组件、指令和管道:

TestBed.compileComponents();

这指示 Angular 编译器将模板文件转换为 JavaScript 代码。

配置和编译

由于 configureTestingModule 返回的仍然是 TestBed,我们可以将这两个调用链接在一起:

TestBed
  .configureTestingModule({
    declarations: [CounterComponent],
  })
  .compileComponents();

你会在大多数依赖于 TestBed 的 Angular 测试中看到这种模式。

渲染组件

现在,我们有一个完全配置的测试模块,其中包含编译后的组件。最后,我们可以使用 createComponent 渲染要测试的组件:

const fixture = TestBed.createComponent(CounterComponent);

createComponent 返回一个 ComponentFixture,它实质上是围绕组件的一个包装器,提供了有用的测试工具。我们将在后面更详细地了解 ComponentFixture

createComponent 将组件渲染到 HTML DOM 中的一个 div 容器元素中。然而,还有一些东西缺失。组件没有完全渲染出来。所有静态的 HTML 存在,但动态的 HTML 却缺失了。模板绑定,如示例中的 {{ count }},没有被计算。

手动触发变更检测

在我们的测试环境中,没有自动的变更检测。即使使用默认的变更检测策略,组件在更新时也不会自动渲染和重新渲染。

在测试代码中,我们必须 手动触发变更检测。这可能有点麻烦,但实际上这是一个特性。它允许我们以同步的方式测试异步行为,这要简单得多。

因此,我们需要做的最后一件事就是触发变更检测:

fixture.detectChanges();

TestBed 和 Jasmine

使用 TestBed 渲染组件的代码现在已经完成。让我们将代码包装在一个 Jasmine 测试套件中。

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

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

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

  it('…', () => {
    /* … */
  });
});

使用 describe,我们为 CounterComponent 定义了一个测试套件。其中包含一个 beforeEach 块,用于配置 TestBed 并渲染组件。

异步编译

你可能会想知道为什么传递给 beforeEach 的函数被标记为 异步 函数。这是因为 compileComponents 是一个异步操作。为了编译组件,Angular 需要获取 templateUrl 引用的外部模板文件。

如果你使用的是 Angular CLI(很可能是这样),模板文件已经包含在测试捆绑包中,因此它们可以立即使用。如果你没有使用 CLI,那么这些文件必须以异步方式加载。

这是一个可能在将来发生变化的实现细节。安全的做法是等待 compileComponents 完成。

asyncawait

默认情况下,Jasmine 希望你的测试代码是同步的。你传递给 it 的函数以及 beforeEachbeforeAllafterEachafterAll 函数需要在一定时间内完成,也就是所谓的超时时间。Jasmine 也支持 异步 的规范。如果你传递一个异步函数,Jasmine 会等待它完成。

ComponentFixture 和 DebugElement

TestBed.createComponent(CounterComponent) 返回一个 fixture,即 ComponentFixture 的实例。fixture 是什么,它提供了什么功能?

夹具(fixture) 这个术语来源于对机械部件或电子设备的现实世界测试。一个夹具是一个标准化的框架,用于安装测试对象。夹具持有对象并连接到电子接点,以提供电源以及进行测量。

ComponentFixture

在 Angular 的上下文中,ComponentFixture 包含了组件并为组件实例和渲染的 DOM 提供了方便的接口。

fixture 通过 componentInstance 属性引用组件实例。在我们的示例中,它包含了一个 CounterComponent 实例。

const component = fixture.componentInstance;

组件实例主要用于设置输入属性(Inputs)和订阅输出属性(Outputs),例如:

// This is a ComponentFixture<CounterComponent>
const component = fixture.componentInstance;
// Set Input
component.startCount = 10;
// Subscribe to Output
component.countChange.subscribe((count) => {
  /* … */
});

我们稍后将更详细地了解如何测试输入属性和输出属性。

DebugElement

为了访问 DOM 中的元素,Angular 提供了另一个抽象层:DebugElement,它封装了原生的 DOM 元素。fixture 的 debugElement 属性返回组件的宿主元素。对于 CounterComponent,它是 app-counter 元素。

const { debugElement } = fixture;

DebugElement 提供了一些方便的属性,例如 propertiesattributesclassesstyles,用于检查 DOM 元素本身。属性 parentchildrenchildNodes 有助于在 DOM 树中进行导航。它们也会返回 DebugElement

nativeElement

通常需要解包 DebugElement,以访问内部的原生 DOM 元素。每个 DebugElement 都有一个 nativeElement 属性:

const { debugElement } = fixture;
const { nativeElement } = debugElement;
console.log(nativeElement.tagName);
console.log(nativeElement.textContent);
console.log(nativeElement.innerHTML);

nativeElement 的类型为 any,因为 Angular 无法确定包装的 DOM 元素的确切类型。大多数情况下,它是 HTMLElement 的子类。

当使用 nativeElement 时,您需要了解特定元素的 DOM 接口。例如,button 元素在 DOM 中表示为 HTMLButtonElement

编写第一个组件规范

我们已经编译了一个测试套件,用于渲染 CounterComponent。我们已经了解了 Angular 的主要测试抽象: TestBedComponentFixtureDebugElement

现在让我们开始撸起袖子,编写第一个规范!我们的小计数器的主要功能是能够递增计数。因此,我们的规范如下:

it('increments the count', () => {
  /* … */
});

安排(Arrange)、行动(Act)和断言(Assert) 阶段有助于规范我们的规范:

  • 我们已经在 beforeEach 块中涵盖了 安排(Arrange) 阶段,该阶段渲染了组件。

  • 行动(Act) 阶段,我们点击增加按钮。

  • 断言(Assert) 阶段,我们检查显示的计数是否已增加。

it('increments the count', () => {
  // Act: Click on the increment button
  // Assert: Expect that the displayed count now reads “1”.
});

要点击增加按钮,需要进行两个操作:

  1. 在 DOM 中找到增加按钮元素。

  2. 触发点击事件。

让我们首先学习如何在 DOM 中查找元素。

使用测试 ID 查询 DOM

每个 DebugElement 都提供了 queryqueryAll 方法来查找后代元素(子元素、孙子元素等)。

query and queryAll
  • query 方法返回满足条件的第一个后代元素。

  • queryAll 方法返回所有匹配的元素数组。

这两个方法都需要一个谓词函数,该函数用于判断每个元素并返回 truefalse

By.css

Angular 提供了预定义的谓词函数,可以使用熟悉的 CSS 选择器来查询 DOM。为此,将带有 CSS 选择器的 By.css('…') 传递给 queryqueryAll 方法。

const { debugElement } = fixture;
// Find the first h1 element
const h1 = debugElement.query(By.css('h1'));
// Find all elements with the class .user
const userElements = debugElement.queryAll(By.css('.user'));

query 方法的返回值是一个 DebugElement,而 queryAll 的返回值是一个 DebugElement 数组(DebugElement[] 在 TypeScript 中的表示方式)。

在上面的示例中,我们使用了类型选择器(h1)和类选择器(.user)来查找 DOM 中的元素。对于熟悉 CSS 的人来说,这是非常熟悉的。

虽然这些选择器在样式化组件时很好用,但在测试中使用它们需要考虑一下。

避免紧密耦合

类型和类选择器在测试和模板之间引入了 紧密耦合。HTML 元素是根据语义原因选择的。类通常是为了样式化选择的。当组件模板进行重构时,这些都经常发生变化。如果元素类型或类发生变化,测试应该失败吗?

有时候,元素类型和类对于正在测试的功能至关重要。但大多数情况下,它们与功能无关。测试应该通过一个永远不会改变且不带有其他含义的特征来找到元素:测试 ID。

测试 ID

测试 ID 是专门为了在测试中查找元素而赋予元素的标识符。如果元素类型或其他属性发生变化,测试仍然能够找到该元素。

标记 HTML 元素的首选方式是使用 data 属性。与元素类型、classid 属性不同,数据属性没有任何预定义的含义。数据属性永远不会互相冲突。

data-testid

在本指南中,我们使用 data-testid 属性来标记元素。例如,我们可以使用 data-testid="increment-button" 来标记 CounterComponent 中的增加按钮:

<button (click)="increment()" data-testid="increment-button">+</button>

在测试中,我们使用相应的属性选择器:

const incrementButton = debugElement.query(
  By.css('[data-testid="increment-button"]')
);
建立一个约定

关于在测试中查找元素的最佳方法有很多细微的讨论。当然,有几种有效且复杂的方法。本指南只提供一种可能的简单和可行的方法。

在DOM查询方面,Angular测试工具是中立的。它们可以容忍不同的方法。经过考虑后,您应该选择一种具体的解决方案,将其作为 测试约定 记录下来,并在所有测试中一致应用。

触发事件处理程序

现在,我们已经标记并获取了增加按钮,我们需要点击它。

在测试中模拟用户输入,如点击、输入文本、移动指针和按键,是一个常见的任务。从Angular的角度来看,用户输入会导致DOM事件。

组件模板使用模式 (event)="handler($event)" 注册事件处理程序。在测试中,我们需要模拟事件以调用这些处理程序。

触发事件处理程序

DebugElement 有一个非常有用的方法用于触发事件:triggerEventHandler。该方法调用给定事件类型(例如 click)的所有事件处理程序。作为第二个参数,它期望一个伪事件对象,该对象将传递给处理程序:

incrementButton.triggerEventHandler('click', {
  /* … Event properties … */
});

这个示例在增加按钮上触发了一个 click 事件。由于模板包含了 (click)="increment()"CounterComponentincrement 方法将被调用。

事件对象

increment 方法并没有使用事件对象。调用的是简单的 increment(),而不是 increment($event)。因此,我们不需要传递一个伪造的事件对象,可以直接传递 null

incrementButton.triggerEventHandler('click', null);

值得注意的是,triggerEventHandler 不会触发合成的DOM事件。它的影响仅限于 DebugElement 的抽象层级,不会触及原生的DOM。

没有事件冒泡

只要事件处理程序在元素上注册了,就可以这样做。如果事件处理程序注册在父元素上,并依赖于事件冒泡,你就需要直接在该父元素上调用 triggerEventHandlertriggerEventHandler 不会模拟事件冒泡或任何其他真实事件可能具有的效果。

期望文本输出

我们已经完成了 Act 阶段,在该阶段测试会点击增加按钮。在 Assert 阶段,我们需要期望显示的计数从“0”变为“1”。

在模板中,计数被渲染到一个 strong 元素中:

<strong>{{ count }}</strong>
通过测试id查找元素

在我们的测试中,我们需要找到这个元素并读取它的文本内容。为此,我们添加一个测试id:

<strong data-testid="count">{{ count }}</strong>

现在我们可以像往常一样找到该元素:

const countOutput = debugElement.query(
  By.css('[data-testid="count"]')
);
文本内容

接下来的步骤是读取元素的内容。在DOM中,计数是 strong 的子节点,它是一个文本节点。

不幸的是,DebugElement 没有用于读取文本内容的方法或属性。我们需要访问具有方便的 textContent 属性的原生DOM元素。

countOutput.nativeElement.textContent

最后,我们使用Jasmine的 expect 来断言这个字符串是否为 "1"

expect(countOutput.nativeElement.textContent).toBe('1');

counter.component.spec.ts 现在如下所示:

/* Incomplete! */
describe('CounterComponent', () => {
  let fixture: ComponentFixture<CounterComponent>;
  let debugElement: DebugElement;

  // Arrange
  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [CounterComponent],
    }).compileComponents();

    fixture = TestBed.createComponent(CounterComponent);
    fixture.detectChanges();
    debugElement = fixture.debugElement;
  });

  it('increments the count', () => {
    // Act
    const incrementButton = debugElement.query(
      By.css('[data-testid="increment-button"]')
    );
    incrementButton.triggerEventHandler('click', null);

    // Assert
    const countOutput = debugElement.query(
      By.css('[data-testid="count"]')
    );
    expect(countOutput.nativeElement.textContent).toBe('1');
  });
});

当我们运行该测试套件时,该规范将失败:

CounterComponent increments the count FAILED
  Error: Expected '0' to be '1'.

这里出了什么问题?是实现有问题吗?不,测试只是漏掉了一些重要的内容。

手动进行变更检测

我们已经提到在测试环境中,Angular不会自动检测变更以更新DOM。点击增加按钮会更改组件实例的 count 属性。要更新模板绑定 {{ count }},我们需要 手动触发变更检测

fixture.detectChanges();

完整的测试套件现在如下所示:

describe('CounterComponent', () => {
  let fixture: ComponentFixture<CounterComponent>;
  let debugElement: DebugElement;

  // Arrange
  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [CounterComponent],
    }).compileComponents();

    fixture = TestBed.createComponent(CounterComponent);
    fixture.detectChanges();
    debugElement = fixture.debugElement;
  });

  it('increments the count', () => {
    // Act
    const incrementButton = debugElement.query(
      By.css('[data-testid="increment-button"]')
    );
    incrementButton.triggerEventHandler('click', null);
    // Re-render the Component
    fixture.detectChanges();

    // Assert
    const countOutput = debugElement.query(
      By.css('[data-testid="count"]')
    );
    expect(countOutput.nativeElement.textContent).toBe('1');
  });
});

恭喜!我们已经编写了我们的第一个组件测试。虽然它还不完整,但已经包含了一个典型的工作流程。随着我们添加每个规范,我们将对现有代码进行小的改进。

测试辅助函数

我们需要测试的下一个 CounterComponent 功能是递减按钮。它与递增按钮非常相似,所以规范几乎相同。

首先,我们给递减按钮添加一个测试ID:

<button (click)="decrement()" data-testid="decrement-button">-</button>

然后我们编写规范:

it('decrements the count', () => {
  // Act
  const decrementButton = debugElement.query(
    By.css('[data-testid="decrement-button"]')
  );
  decrementButton.triggerEventHandler('click', null);
  // Re-render the Component
  fixture.detectChanges();

  // Assert
  const countOutput = debugElement.query(
    By.css('[data-testid="count"]')
  );
  expect(countOutput.nativeElement.textContent).toBe('-1');
});

这里我们会发现,这个规范没有新内容,只不过是测试标识、变量名和预期输出发生了变化。

重复的模式

现在我们有两个几乎完全相同的规范,代码的重复度很高,信噪比(signal-to-noise ratio)很低,也就是说很多代码只起到了很少的作用。让我们识别出这里重复的模式:

  1. 通过测试标识查找元素

  2. 点击通过测试标识找到的元素

  3. 在通过测试标识找到的元素上期望给定的文本内容

这些任务是非常通用的,几乎会出现在每个组件规范中。所以就值得为它们编写一个测试助手。

测试助手

测试助手 是简化测试代码的工具。它使测试代码更简洁、更有意义。由于规范应该描述实现,因此可读性强的规范要比晦涩难懂的规范更好。

您的测试助手应将 测试约定 转化为代码。它们不仅改善了单个测试,还确保所有测试使用相同的模式并且同样正常工作。

测试助手可以是一个简单的函数,也可以是一个抽象类或Jasmine扩展。首先,我们将常见任务提取为普通函数。

通过测试标识查找

首先,让我们编写一个通过测试标识查找元素的辅助函数。我们已经多次使用了这个模式:

const xyzElement = fixture.debugElement.query(
  By.css('[data-testid="xyz"]')
);

我们将这段代码转移到可重用的函数中:

function findEl<T>(
  fixture: ComponentFixture<T>,
  testId: string
): DebugElement {
  return fixture.debugElement.query(
    By.css(`[data-testid="${testId}"]`)
  );
}

这个函数是自包含的。我们需要显式地传递组件的 fixture。由于 ComponentFixture<T> 需要一个类型参数 - 包装的组件类型 -,findEl 也有一个名为 T 的类型参数。当你传递一个 ComponentFixture 时,TypeScript 会自动推断出组件的类型。

点击

其次,我们编写一个测试助手,它可以点击具有给定测试 id 的元素。这个助手基于 findEl

export function click<T>(
  fixture: ComponentFixture<T>,
  testId: string
): void {
  const element = findEl(fixture, testId);
  const event = makeClickEvent(element.nativeElement);
  element.triggerEventHandler('click', event);
}

为了创建一个模拟的点击事件对象,click 函数调用另一个函数 makeClickEvent

export function makeClickEvent(
  target: EventTarget
): Partial<MouseEvent> {
  return {
    preventDefault(): void {},
    stopPropagation(): void {},
    stopImmediatePropagation(): void {},
    type: 'click',
    target,
    currentTarget: target,
    bubbles: true,
    cancelable: true,
    button: 0
  };
}

这个函数返回一个部分的 MouseEvent 模拟对象,其中包含真实点击事件的最重要的方法和属性。它适用于在指针位置和修饰键不重要的情况下点击按钮和链接。

点击意味着激活(activate)

点击(click) 测试助手可用于每个具有 (click)="…" 事件处理程序的元素。为了辅助功能,请确保元素可以聚焦(focussed)和激活(activated)。对于按钮(button 元素)和链接(a 元素)来说,这已经是默认情况。

从历史上看,点击(click) 事件只由鼠标输入触发。但是现在,它是一个通用的“激活(activate)”事件。它还可以由触摸输入(“轻触(tap)”)、键盘输入或语音输入触发。

因此,在组件中,您不需要单独监听触摸或键盘事件。在测试中,通常只需要一个通用的 点击 事件即可。

期望文本内容

第三个测试助手是一个函数,用于在具有给定测试 id 的元素上期望给定的文本内容。

export function expectText<T>(
  fixture: ComponentFixture<T>,
  testId: string,
  text: string,
): void {
  const element = findEl(fixture, testId);
  const actualText = element.nativeElement.textContent;
  expect(actualText).toBe(text);
}

再次强调,这是一个简单的实现,我们将在以后进行改进。

使用这些助手函数,我们重新编写我们的规范(spec):

it('decrements the count', () => {
  // Act
  click(fixture, 'decrement-button');
  // Re-render the Component
  fixture.detectChanges();

  // Assert
  expectText(fixture, 'count', '-1');
});

这样的代码读起来更清晰,写起来也更简洁!一眼就能看出规范在做什么。

填充表单

我们已经成功测试了增加和减少按钮。我们还需要测试的剩下的用户界面功能是重置功能。

在用户界面中,有一个重置输入字段和一个重置按钮。用户在字段中输入一个新数字,然后点击按钮。组件将计数重置为用户提供的数字。

设置字段值

我们已经知道如何点击按钮,但是如何填写表单字段呢?不幸的是,Angular 的测试工具没有提供一种简单地填写表单的解决方案。

答案取决于字段类型和值。一般的答案是:找到原生 DOM 元素,并将 value 属性设置为新值。

对于重置输入字段来说,这意味着:

const resetInput = debugElement.query(
  By.css('[data-testid="reset-input"]')
);
resetInput.nativeElement.value = '123';

使用我们的测试助手:

const resetInputEl = findEl(fixture, 'reset-input').nativeElement;
resetInputEl.value = '123';

这样可以通过编程的方式对值进行填充。

CounterComponent 的模板中,重置输入字段有一个 模板引用变量#resetInput:

<input type="number" #resetInput data-testid="reset-input" />
<button (click)="reset(resetInput.value)" data-testid="reset-button">
  Reset
</button>

点击处理程序使用 resetInput 来访问 input 元素,读取其 并将其传递给 reset 方法。

这个示例已经可以工作,因为表单非常简单。设置字段的 并不能完全模拟用户输入,并且还不能与模板驱动或响应式表单一起使用。

伪造 输入 事件

Angular 表单不能直接观察到 的变化。相反,Angular 监听浏览器在字段值变化时触发的 input 事件。

为了 兼容模板驱动和响应式表单,我们需要触发一个伪造的 input 事件。这样的事件也被称为 合成事件

在较新的浏览器中,我们可以使用 new Event('input') 来创建一个伪造的 input 事件。要触发事件,我们使用目标元素的 dispatchEvent 方法。

const resetInputEl = findEl(fixture, 'reset-input').nativeElement;
resetInputEl.value = '123';
resetInputEl.dispatchEvent(new Event('input'));

如果您需要在旧版的 Internet Explorer 中运行测试,需要更多的代码。Internet Explorer 不支持 new Event('…'),而是使用 document.createEvent 方法:

const event = document.createEvent('Event');
event.initEvent('input', true, false);
resetInputEl.dispatchEvent(event);

重置功能的完整规范如下所示:

it('resets the count', () => {
  const newCount = '123';

  // Act
  const resetInputEl = findEl(fixture, 'reset-input').nativeElement;
  // Set field value
  resetInputEl.value = newCount;
  // Dispatch input event
  const event = document.createEvent('Event');
  event.initEvent('input', true, false);
  resetInputEl.dispatchEvent(event);

  // Click on reset button
  click(fixture, 'reset-button');
  // Re-render the Component
  fixture.detectChanges();

  // Assert
  expectText(fixture, 'count', newCount);
});

填写表单是测试中常见的任务,因此将代码提取出来放入助手函数中是有意义的。

辅助函数

辅助函数 setFieldValue 接受一个组件 fixture、一个测试 id 和一个字符串值。它使用 findEl 找到相应的元素。使用另一个辅助函数 setFieldElementValue,它设置 并触发一个输入事件。

export function setFieldValue<T>(
  fixture: ComponentFixture<T>,
  testId: string,
  value: string,
): void {
  setFieldElementValue(
    findEl(fixture, testId).nativeElement,
    value
  );
}

您可以在 element.spec-helper.ts 文件中找到所涉及的辅助函数的完整源代码。

使用新创建的 setFieldValue 辅助函数,我们可以简化规范(spec):

it('resets the count', () => {
  const newCount = '123';

  // Act
  setFieldValue(fixture, 'reset-input', newCount);
  click(fixture, 'reset-button');
  fixture.detectChanges();

  // Assert
  expectText(fixture, 'count', newCount);
});

尽管重置功能很简单,但这是如何测试大多数表单逻辑的方式。稍后,我们将学习如何 测试复杂的表单

无效输入

CounterComponent 在重置计数之前会检查输入值。如果值不是数字,点击重置按钮将不会有任何操作。

我们需要用另一个规范来覆盖这种行为:

it('does not reset if the value is not a number', () => {
  const value = 'not a number';

  // Act
  setFieldValue(fixture, 'reset-input', value);
  click(fixture, 'reset-button');
  fixture.detectChanges();

  // Assert
  expectText(fixture, 'count', startCount);
});

在这个规范中的小差异是,我们将字段值设置为“not a number”,这是一个无法解析为数字的字符串,并期望计数保持不变。

就是这样!我们已经分别使用有效和无效的输入值,对重置表单功能进行了测试。

测试输入

CounterComponent 有一个 @Input(): startCount ,用于设置初始count值。我们需要测试计数器如何正确处理这个 @Input()

例如,如果我们将 startCount 设置为 123,渲染的count也应该是 123。如果输入为空,渲染的count则应该是 0,即默认值。

设置 @Input()

@Input() 是组件实例的特殊属性。我们可以在 Arrange 阶段设置这个属性。

const component = fixture.componentInstance;
component.startCount = 10;

在组件中最好不要改变 @Input() 的值。@Input() 属性应始终反映父组件传入的数据。

@Input() 与组件状态

这就是为什么 CounterComponent 既有一个名为 startCount 的公共 @Input(),又有一个名为 count 的内部属性。当用户点击增加或减少按钮时,count 会发生变化,但 startCount 保持不变。

每当 startCount @Input() 发生变化时,count 需要被设置为 startCount 的值。最安全的做法是在 ngOnChanges 生命周期方法中进行设置:

public ngOnChanges(): void {
  this.count = this.startCount;
}

ngOnChanges 在“数据绑定属性”发生变化时被调用,包括 Inputs。

让我们为 startCount @Input() 编写一个测试。我们在 beforeEach 块中设置 @Input(),在调用 detectChanges 之前。规范本身检查正确的计数是否被渲染出来。

/* Incomplete! */
beforeEach(async () => {
  /* … */

  // Set the Input
  component.startCount = startCount;
  fixture.detectChanges();
});

it('shows the start count', () => {
  expectText(fixture, 'count', String(count));
});

当我们运行这个规范时,我们发现它失败了:

CounterComponent > shows the start count
  Expected '0' to be '123'.
ngOnChanges

这里出了什么问题?我们是不是再次忘记了调用 detectChanges ?不是的,我们忘记了调用 ngOnChanges

在测试环境中,ngOnChanges 不会自动调用。我们必须在设置完 @Input() 后手动调用它。

以下是修正后的示例:

describe('CounterComponent', () => {
  let component: CounterComponent;
  let fixture: ComponentFixture<CounterComponent>;

  const startCount = 123;

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

    fixture = TestBed.createComponent(CounterComponent);
    component = fixture.componentInstance;
    component.startCount = startCount;
    // Call ngOnChanges, then re-render
    component.ngOnChanges();
    fixture.detectChanges();
  });

  /* … */

  it('shows the start count', () => {
    expectText(fixture, 'count', String(startCount));
  });
});

CounterComponent 期望一个 number 输入,并将其渲染到 DOM 中。当从 DOM 中读取文本时,我们处理的却总是字符串。这就是为什么我们传入一个数字 123,但期望找到字符串 '123' 的原因。

测试输出

输入(@Input())将数据从父组件传递到子组件,输出(@Output())将数据从子组件发送到父组件。结合起来,一个组件可以只使用所需的数据执行特定的操作。

例如,一个组件可以呈现一个表单,以便用户可以编辑或审查数据。一旦完成,组件就会将数据作为输出发出。

输出不是一个面向用户的功能,但是它是公共组件 API 的重要组成部分。从技术上讲,输出是组件实例的属性。单元测试必须仔细检查输出,以证明组件与其他组件的协作良好。

CounterComponent 有一个名为 countChange 的输出。每当计数发生变化时,countChange 输出会发出新的值。

export class CounterComponent implements OnChanges {
  /* … */
  @Output()
  public countChange = new EventEmitter<number>();
  /* … */
}
订阅 Observable

EventEmitter 是 RxJS Subject 的子类,它本身扩展了 RxJS Observable。组件使用 emit 方法来发布新的值,而父组件使用 subscribe 方法来监听发布的值。在测试环境中,我们也将进行相同的操作。

让我们为 countChange 输出编写一个规范!

it('emits countChange events on increment', () => {
  /* … */
});

在规范中,我们通过 fixture.componentInstance.countChange 访问 Output。在 Arrange 阶段,我们订阅 EventEmitter

it('emits countChange events on increment', () => {
  // Arrange
  component.countChange.subscribe((count) => {
    /* … */
  });
});

我们需要验证当点击递增按钮时,观察者函数是否以正确的值被调用。在 Act 阶段,我们使用我们的辅助函数点击按钮:

it('emits countChange events on increment', () => {
  // Arrange
  component.countChange.subscribe((count) => {
    /* … */
  });

  // Act
  click(fixture, 'increment-button');
});
修改变量值

Assert 阶段,我们期望 count 具有正确的值。最简单的方法是在 spec 的作用域中声明一个变量。让我们将其命名为 actualCount。最初,它的值是 未定义(undefined) 的。观察者函数设置一个值 - 或者如果它从未被调用,则不设置任何值。

it('emits countChange events on increment', () => {
  // Arrange
  let actualCount: number | undefined;
  component.countChange.subscribe((count: number) => {
    actualCount = count;
  });

  // Act
  click(fixture, 'increment-button');

  // Assert
  expect(actualCount).toBe(1);
});
期望值发生变化

点击按钮会同步地发出 count 并调用观察者函数。这就是为什么代码的下一行可以期望 actualCount 已经发生了变化。

你可能想知道为什么我们没有将 expect 调用放在观察者函数中:

/* Not recommended! */
it('emits countChange events on increment', () => {
  // Arrange
  component.countChange.subscribe((count: number) => {
    // Assert
    expect(count).toBe(1);
  });

  // Act
  click(fixture, 'increment-button');
});
始终执行断言

这种方法也是可行的。但是想象一下,如果被测试的功能出现故障,输出发不出来,那么 expect 就不会被调用了。

默认情况下,Jasmine 会警告您该规范没有期望值,但将该规范视为成功(参见 配置 Karma 和 Jasmine)。在这种情况下,我们希望该规范的失败结果明确地显示出来,以确保期望值始终能被执行。

现在,我们已经验证了在点击增加按钮时 countChange 是否发出。我们还需要证明输出在减少和重置时也会发出。我们可以通过添加两个更多的规范来实现,这两个规范与现有规范相同:

it('emits countChange events on decrement', () => {
  // Arrange
  let actualCount: number | undefined;
  component.countChange.subscribe((count: number) => {
    actualCount = count;
  });

  // Act
  click(fixture, 'decrement-button');

  // Assert
  expect(actualCount).toBe(-1);
});

it('emits countChange events on reset', () => {
  const newCount = '123';

  // Arrange
  let actualCount: number | undefined;
  component.countChange.subscribe((count: number) => {
    actualCount = count;
  });

  // Act
  setFieldValue(fixture, 'reset-input', newCount);
  click(fixture, 'reset-button');

  // Assert
  expect(actualCount).toBe(newCount);
});

重复的组件规范

使用三个规范测试 countChange ,这样的输出效果很好,但代码重复度却很高。测试辅助函数虽然可以进一步减少重复代码的,但是关于重复的测试代码是否成问题,是否真的有必要减少这些重复,专家们有不同的意见。

一方面,很难抓住重复规范的核心(共同点)。测试辅助函数形成了一种自定义语言,可以清晰而简洁地表达测试指令。例如,如果您的规范通过测试 ID 查找 DOM 元素,测试辅助函数会建立约定并隐藏实现细节。

另一方面,像辅助函数这样的抽象会使测试变得更复杂,因此会更难理解。阅读规范的开发人员首先需要熟悉测试辅助函数的逻辑。毕竟,测试应该比实现代码更易读才对。

重复与抽象

在软件开发中,有关重复和抽象价值的问题存在争议。 正如 Sandi Metz 所说,“重复比错误的抽象要好得多”。

当编写规范时,这一点尤为正确。您应该尝试使用 beforeEach/beforeAll、更简单的辅助函数甚至是测试库来消除重复代码和样板代码。但是不要试图将您在业务实现代码上的优化习惯和技巧应用在测试代码上。

测试应该重现所有相关的逻辑情况。为这些不同的、有时甚至是互斥的情况提取出合适的抽象,通常没什么用。

谨慎地减少重复

对于这个问题,您的实践可能会有所不同。为了完整起见,让我们讨论一下如何减少 countChange 输出规范中的重复。

@Output() 是一个 EventEmitter,它是一个有完全功能的 RxJS Observable。这使得我们可以按照自己的方式转换 Observable。具体来说,我们可以点击所有三个按钮,然后期望 countChange 输出已发出三个值。

it('emits countChange events', () => {
  // Arrange
  const newCount = 123;

  // Capture all emitted values in an array
  let actualCounts: number[] | undefined;

  // Transform the Observable, then subscribe
  component.countChange.pipe(
    // Close the Observable after three values
    take(3),
    // Collect all values in an array
    toArray()
  ).subscribe((counts) => {
    actualCounts = counts;
  });

  // Act
  click(fixture, 'increment-button');
  click(fixture, 'decrement-button');
  setFieldValue(fixture, 'reset-input', String(newCount));
  click(fixture, 'reset-button');

  // Assert
  expect(actualCounts).toEqual([1, 0, newCount]);
});

这个示例需要一些 RxJS 知识。在测试 Angular 应用程序时,我们会一遍又一遍地遇到 RxJS Observable。如果您不理解上面的示例,完全没有关系。这只是一种将三个规范合并为一个的可选方法。

组件测试黑盒 vs. 白盒

如果组件测试的模拟能够与用户和组件的交互更相似,那么这些测试就是最有意义的。我们编写的测试符合这个原则。我们直接使用 DOM 来读取文本、点击按钮和填写表单字段,因为这就是用户所做的操作。

这些测试是黑盒测试。我们已经在理论上讨论了 黑盒与白盒测试。两者都是有效的测试方法。正如先前所述,本指南首先建议使用黑盒测试。

强制进行黑盒测试的一种常见技术是将内部方法标记为 private ,以便在测试中无法调用这些方法。测试应该只检查文档化的公共 API。

内部但仍然是 public

在 Angular 组件中,外部和内部属性和方法的区别与它们在 TypeScript 中的可见性(public vs. private)不一致。属性和方法需要是 public 的,以便模板能够访问它们。

这对于 Input 和 Output 属性是有意义的。它们需要从外部(即您的测试)进行读取和写入。然而,还存在一些仅对模板 public 的内部属性和方法。

例如,CounterComponent 具有一个名为 startCount 的 Input 和一个名为 countChange 的 Output。它们都是 public 的:

@Input()
public startCount = 0;

@Output()
public countChange = new EventEmitter<number>();

它们构成了公共 API。然而,还有几个其他的属性和方法是 public 的:

public count = 0;
public increment(): void { /* … */ }
public decrement(): void { /* … */ }
public reset(newCount: string): void { /* … */ }
对于模板而言是 public

这些属性和方法是内部的,仅在组件内部使用。然而,它们需要是 public 的,以便模板可以访问它们。Angular将模板编译为TypeScript代码,而TypeScript确保模板代码只能访问公共属性和方法。

在我们的 CounterComponent 黑盒测试中,我们通过点击“+”按钮来增加计数。相比之下,许多 Angular 测试教程进行组件的白盒测试。它们直接调用 increment 方法:

/* Not recommended! */
describe('CounterComponent', () => {
  /* … */
  it('increments the count', () => {
    component.increment();
    fixture.detectChanged();
    expectText(fixture, 'count', '1');
  });
});

这个白盒测试通过调用内部但是 public 的方法来访问组件。有时候这样做是有价值的,但大部分情况下是被滥用的。

输入、输出和 DOM

正如我们所学到的,组件测试在与组件通过输入、输出和呈现的 DOM 进行交互时才具有意义。如果组件测试调用了内部方法或访问了内部属性,它往往会错过重要的模板逻辑和事件处理。

上面的白盒测试规范调用了 increment 方法,但没有测试相应的模板代码,即增加按钮。

<button (click)="increment()" data-testid="increment-button">+</button>

如果我们完全从模板中删除增加按钮,该功能显然就会出现问题。但是白盒测试不会失败。

优先进行黑盒测试

当应用于 Angular 组件时,黑盒测试对于初学者来说更直观和更容易理解。在编写黑盒测试时,询问组件对用户和父组件做了什么,然后在测试中模仿使用情况。

白盒测试不仅从 DOM 视角严格地检查组件。因此,它存在错过关键组件行为的风险。它给人一种所有代码都经过测试的错觉。

也就是说,白盒测试是一种可行的高级技术。有经验的测试人员可以编写高效的白盒规范,仍然测试所有组件功能并覆盖所有代码。

下表显示了在黑盒测试中应该访问或不访问的 Angular 组件的属性和方法。

推荐方式
Table 1. 黑盒测试 Angular 组件
类成员 测试中的访问

@Input 属性

是 (写入)

@Output 属性

是 (订阅)

生命周期方法

避免,除非是 ngOnChanges

其他公共方法

避免

私有属性和方法

不访问