测试带有子组件的组件

学习目标

  • 渲染带有或不带子组件的组件

  • 检查父组件及其子组件的正确连接

  • 使用虚拟组件替换子组件

  • 使用 ng-mocks 库来模拟依赖关系

展示型组件

到目前为止,我们已经测试了一个独立的组件,它只渲染了普通的 HTML 元素,没有包含子组件。这种低级组件是 Angular 应用程序的核心组成部分。

  • 它们直接呈现给用户看到并与之交互的内容。

  • 它们通常是高度通用和可重用的。

  • 它们通过输入属性进行控制,并使用输出属性进行反馈。

  • 它们的依赖性很少或几乎没有。

  • 它们易于理解,因此易于测试。

  • 测试这些组件的首选方式是单元测试。

这些组件被称为 展示型组件 ,因为它们直接使用 HTML 和 CSS 展示用户界面的一部分。展示型组件需要组合和连接在一起,形成一个工作的用户界面。

容器组件

这是 容器组件 的职责。这些高级组件将多个低级组件组合在一起。它们从不同的来源(如服务和状态管理器)获取数据,并将其分发给它们的子组件。

容器组件有几种类型的依赖关系。它们依赖于嵌套的子组件,还依赖于可注入对象。可注入对象是通过依赖注入提供的类、函数、对象等,比如服务。这些依赖关系使得测试容器组件变得复杂。

浅渲染 vs. 深渲染

有两种基本的测试带有子组件的组件的方式:

  • 使用 浅渲染 进行单元测试。子组件不会被渲染。

  • 使用 深渲染 进行集成测试。子组件会被渲染。

同样,这两种方法都是有效的,我们将进行讨论。

浅渲染 vs. 深渲染

在计数器示例应用程序中, HomeComponent 包含 CounterComponentServiceCounterComponentNgRxCounterComponent

模板 中:

<app-counter
  [startCount]="5"
  (countChange)="handleCountChange($event)"
></app-counter>
<!-- … -->
<app-service-counter></app-service-counter>
<!-- … -->
<app-ngrx-counter></app-ngrx-counter>

这些自定义的 app- 元素最终出现在 DOM 树中。它们成为子组件的 _宿主元素

仅检查连接

HomeComponent单元测试 不会渲染这些子组件。宿主元素被渲染,但保持为空。你可能会想,这样的测试有什么意义?它到底做了什么呢?

HomeComponent 的角度来看,其子组件的内部工作并不重要。我们需要测试模板是否包含这些子组件。此外,我们还需要检查 HomeComponent 及其子组件是否使用输入和输出正确地进行了连接。

特别是,HomeComponent 的单元测试检查是否存在 app-counter 元素,startCount 输入是否正确传递,以及 HomeComponent 是否处理了 countChange 事件。对于其他子组件,如 app-service-counterapp-ngrx-counter,也是同样的做法。

渲染子组件

HomeComponent 的集成测试 会渲染子组件。宿主元素将分别填充 CounterComponentServiceCounterComponentNgRxCounterComponent 的输出内容。这个集成测试实际上测试了这四个组件。

测试协同工作

我们需要确定测试嵌套组件的详细程度。如果它们有单独的单元测试,我们就不需要点击每个相应的增加按钮。毕竟,集成测试需要证明这四个组件可以协同工作,而不涉及子组件的细节。

单元测试

让我们先为 HomeComponent 编写一个单元测试。设置过程与 CounterComponent 的测试套件相似。我们使用 TestBed 来配置一个测试模块,并渲染要测试的组件。

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

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

    fixture = TestBed.createComponent(HomeComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('renders without errors', () => {
    expect(component).toBeTruthy();
  });
});
冒烟测试

这个测试套件包含一个 冒烟测试 规范。它检查组件实例的存在性,但并不对组件的具体行为进行断言。它只是验证组件能够在没有错误的情况下正常渲染。

如果冒烟测试失败,说明测试设置存在问题。

未知的自定义元素

从 Angular 9 开始,这个测试规范通过了,但是在控制台上产生了一系列警告信息:

'app-counter' is not a known element:
1. If 'app-counter' is an Angular component, then verify that it is part of this module.
2. If 'app-counter' is a Web Component then add 'CUSTOM_ELEMENTS_SCHEMA' to the '@NgModule.schemas' of this component to suppress this message.

我们在 app-service-counterapp-ngrx-counter 中也得到了同样的警告。还有另一个警告信息:

无法绑定到 'startCount',因为它不是 'app-counter' 的已知属性。

这些警告的含义是什么呢?Angular 无法识别自定义元素 app-counterapp-service-counterapp-ngrx-counter,因为我们没有声明与这些选择器匹配的组件。警告指向了两个解决方案:

  1. 要么在测试模块中声明子组件,将测试转为集成测试

  2. 要么告诉 Angular 忽略这些未知元素,将测试保持为单元测试

忽略子元素

由于我们计划编写的是单元测试,我们选择了第二个解决方案。

在配置测试模块时,我们可以指定 schemas 来告诉 Angular 如何处理那些未被指令或组件处理的元素。

警告建议使用 CUSTOM_ELEMENTS_SCHEMA,但是这些元素并不是 Web 组件。我们希望 Angular 简单地忽略这些元素。因此,我们使用 NO_ERRORS_SCHEMA,这是一个“允许任何元素上的任何属性”的模式。

await TestBed.configureTestingModule({
  declarations: [HomeComponent],
  schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();

加上这个配置后,我们的冒烟测试通过了。

现在让我们编写一个更有意义的规范!我们从嵌套的 app-counter 开始。这是我们需要覆盖的代码:

<app-counter
  [startCount]="5"
  (countChange)="handleCountChange($event)"
></app-counter>
子元素存在性测试

首先,我们需要测试独立计数器 app-counter 的存在。我们创建一个新的规范来完成这个目的:

it('renders an independent counter', () => {
  /* … */
});

为了验证在 DOM 中存在一个 app-counter 元素,我们使用最顶层的 DebugElement 的熟悉的 query 方法。

const { debugElement } = fixture;
const counter = debugElement.query(By.css('app-counter'));

这段代码使用了 app-counter 类型选择器来查找元素。你可能会想,为什么不使用测试 ID 和 findEl 辅助函数呢?

通过元素类型查找

在这种罕见的情况下,我们需要强制使用 app-counter 元素,因为这是 CounterComponent 的选择器。

使用测试 ID 会使元素类型变得任意。这在其他情况下可以使测试更加健壮。然而,在测试子组件存在性时,正是元素类型调用了子组件。

我们的测试还缺少一个期望值。query 方法返回一个 DebugElementnull。我们只需期望返回值为真值:

it('renders an independent counter', () => {
  const { debugElement } = fixture;
  const counter = debugElement.query(By.css('app-counter'));
  expect(counter).toBeTruthy();
});

查找子组件是一个常见的任务。这样重复的模式是测试辅助函数的好候选对象。不是因为它是很多代码,而是因为代码具有特定的含义,我们希望传达出来。

debugElement.query(By.css('app-counter')) 并不特别描述性。读者需要花一点时间才能意识到这段代码试图找到一个嵌套的组件。

findComponent

因此,让我们引入一个名为 findComponent 的辅助函数。

export function findComponent<T>(
  fixture: ComponentFixture<T>,
  selector: string,
): DebugElement {
  return fixture.debugElement.query(By.css(selector));
}

我们的规范现在如下所示:

it('renders an independent counter', () => {
  const counter = findComponent(fixture, 'app-counter');
  expect(counter).toBeTruthy();
});
检查输入

接下来,我们需要测试的是 startCount 输入。特别是 HomeComponent 模板中的属性绑定 [startCount]="5"。让我们创建一个新的规范:

it('passes a start count', () => {
  const counter = findComponent(fixture, 'app-counter');
  /* … */
});
properties

我们如何读取输入值?每个 DebugElement 都有一个 properties 对象,其中包含DOM属性及其值。此外,它还包含特定的属性绑定(类型为 { [key: string]: any })。

在使用浅层渲染的单元测试中,properties 包含子组件的输入。首先,我们找到 app-counter 以获取相应的 DebugElement。然后,我们检查输入值 properties.startCount

it('passes a start count', () => {
  const counter = findComponent(fixture, 'app-counter');
  expect(counter.properties.startCount).toBe(5);
});

那非常简单!最后但同样重要的是,我们需要测试输出事件。

输出事件

HomeComponent 的角度来看,对输出事件的反应就像处理 app-counter 元素上的事件一样。模板使用了熟悉的 (event)="handler($event)" 语法:

<app-counter
  [startCount]="5"
  (countChange)="handleCountChange($event)"
></app-counter>

handleCountChange 方法在组件类中定义。它只是调用 console.log 来证明子组件和父组件之间的通信成功了:

export class HomeComponent {
  public handleCountChange(count: number): void {
    console.log('countChange event from CounterComponent', count);
  }
}

让我们添加一个新的规范来测试输出(Output):

it('listens for count changes', () => {
  /* … */
});

该规范需要执行两个步骤:

  1. Act: 找到子组件,并让 countChange 输出(Output)发出一个值。

  2. Assert: 检查 console.log 是否已被调用。

从父组件的角度来看,countChange 只是一个事件。浅渲染意味着没有 CounterComponent 实例,也没有名为 countChangeEventEmitter。Angular只会看到一个元素 app-counter ,带有一个事件处理器 (countChange)="handleCountChange($event)"

模拟输出(Output)

在这种设置中,我们可以使用已知的 triggerEventHandler 方法来模拟输出(Output)。

it('listens for count changes', () => {
  /* … */
  const counter = findComponent(fixture, 'app-counter');
  const count = 5;
  counter.triggerEventHandler('countChange', 5);
  /* … */
});

该规范找到 app-counter 元素并触发 countChange 事件处理程序。

第二个 triggerEventHandler 参数 5 不是我们从DOM事件(如 click)中所了解的事件对象。它是一个输出(Output)将会发出的值。countChange 输出的类型是 EventEmitter<number>,因此我们在测试中使用固定的数字 5

输出效果

在内部,triggerEventHandler 使用 $event5 运行 handleCountChange($event)handleCountChange 调用 console.log。这是我们需要测试的可观察效果。

如何验证是否已调用 console.log?我们可以使用Jasmine的 spyOn对现有方法进行监听

spyOn(console, 'log');

在整个测试运行期间,这将使用spy覆盖 console.log。我们需要在测试规范的开始处,在 Arrange 阶段设置spy。

it('listens for count changes', () => {
  spyOn(console, 'log');
  const counter = findComponent(fixture, 'app-counter');
  const count = 5;
  counter.triggerEventHandler('countChange', count);
  /* … */
});

断言(Assert) 阶段,我们期望该spy已被调用,并传入特定的文本和Output所发出的数字。

it('listens for count changes', () => {
  spyOn(console, 'log');
  const counter = findComponent(fixture, 'app-counter');
  const count = 5;
  counter.triggerEventHandler('countChange', count);
  expect(console.log).toHaveBeenCalledWith(
    'countChange event from CounterComponent',
    count,
  );
});

至此我们已经测试了 CounterComponent 子组件。HomeComponent 还会以类似以下方式渲染 ServiceCounterComponent 和NgRxCounterComponent

<app-service-counter></app-service-counter>
<!-- … -->
<app-ngrx-counter></app-ngrx-counter>
子组件存在性

由于它们没有输入或输出,我们只需测试它们是否在模板中被提及。我们添加两个额外的规范,分别检查这些 app-service-counterapp-ngrx-counter 元素的存在。

it('renders a service counter', () => {
  const serviceCounter = findComponent(fixture, 'app-service-counter');
  expect(serviceCounter).toBeTruthy();
});

it('renders a NgRx counter', () => {
  const ngrxCounter = findComponent(fixture, 'app-ngrx-counter');
  expect(ngrxCounter).toBeTruthy();
});

就是这样了!我们使用浅渲染编写了一个单元测试,证明了 HomeComponent 正确嵌入了几个子组件。

请注意,这只是一种可能的测试方法。和全面的集成测试相比,设置工作很少。规范可以使用Angular的 DebugElement 抽象来测试存在性以及输入和输出。

单元测试的可信度

然而,单元测试对于 HomeComponent 在生产环境中的正常工作并没有太多的信心。我们已经指示Angular忽略了元素 app-counterapp-service-counterapp-ngrx-counter

如果HomeComponent 使用了错误的元素名称,并且测试复制了该错误,那么测试将错误地通过。我们需要将涉及的组件一起渲染,以便发现错误。

模拟子组件

在天真的单元测试和集成测试之间有一种中间地带。我们可以使用 的子组件来渲染,而不是使用空的自定义元素。

假组件具有相同的选择器、输入和输出,但没有依赖项,也不需要渲染任何内容。当测试带有子组件的组件时,我们可以用假组件替换子组件。

让我们将CounterComponent简化为一个什么都不做但提供相同公共API的空壳子:

@Component({
  selector: 'app-counter',
  template: '',
})
class FakeCounterComponent implements Partial<CounterComponent> {
  @Input()
  public startCount = 0;

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

这个伪造的组件缺少模板和任何逻辑,但具有相同的选择器、输入和输出。

相同的公共API

还记得 伪造依赖关系的规则 吗?我们需要确保伪造的组件与原始组件相似。FakeCounterComponent 实现 Partial<CounterComponent> 要求该类实现 CounterComponent 的一个子集。TypeScript 会强制要求给定的属性和方法具有与原始类相同的类型。

声明伪造组件

在我们的测试套件中,我们将 FakeCounterComponent 放置在 describe 块之前。下一步是将该组件添加到测试模块中:

TestBed.configureTestingModule({
  declarations: [HomeComponent, FakeCounterComponent],
  schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();

当 Angular 遇到 app-counter 元素时,它会实例化并挂载一个 FakeCounterComponent。由于伪造的模板也是空的,因此该元素保持为空。startCount 输入属性被设置,并且父级 HomeComponent 订阅 countChange 输出。

现在,由于子组件被渲染,我们需要调整测试套件。我们不再搜索 app-counter 元素并检查其属性,而是显式搜索 FakeCounterComponent 实例。

到目前为止,我们使用了 DebugElementquery 方法来查找嵌套元素。例如:

const element = fixture.debugElement.query(By.css('…'));

我们的辅助函数 findElfindComponent 也使用了这种模式。

通过指令查找

现在我们想要找到一个嵌套的组件。我们可以使用 query 方法和 By.directive 这个断言函数:

const counterEl = fixture.debugElement.query(
  By.directive(FakeCounterComponent)
);

By.directive 可以找到各种类型的指令,组件也是指令的一种。

query 方法会返回一个 DebugElement,如果没有找到匹配的元素,则返回 null。正如我们所了解的,DebugElement 总是包装了一个原生的 DOM 元素。当我们查询 FakeCounterComponent 时,我们会得到一个包装了 app-counter 元素的 DebugElement,就像 By.css('app-counter') 一样。

获取子组件实例

不同的是,现在我们可以通过 componentInstance 属性访问渲染的 FakeCounterComponent 的实例:

const counterEl = fixture.debugElement.query(
  By.directive(FakeCounterComponent)
);
const counter: CounterComponent = counterEl.componentInstance;

由于 Angular 不知道组件的类型,componentInstance 的类型是 any。因此,我们添加了一个显式的类型注释。

子组件的存在

有了对子组件实例的访问权限,我们可以对其进行断言。首先,我们验证其是否存在。

it('renders an independent counter', () => {
  const counterEl = fixture.debugElement.query(
    By.directive(FakeCounterComponent)
  );
  const counter: CounterComponent = counterEl.componentInstance;

  expect(counter).toBeTruthy();
});

这是一个冒烟测试,如果没有找到 FakeCounterComponent 的实例,它将会提前失败。query 会返回 null,并且 counterEl.componentInstance 会因为 TypeError: counterEl is null 而失败。

检查输入

第二个规范检查输入。输入是组件实例的一个属性,因此 counter.startCount 可以给出 startCount 输入的值。

it('passes a start count', () => {
  const counterEl = fixture.debugElement.query(
    By.directive(FakeCounterComponent)
  );
  const counter: CounterComponent = counterEl.componentInstance;

  expect(counter.startCount).toBe(5);
});

第三个规范检查输出的处理:如果计数器发出一个值,HomeComponent 将其传递给 console.log

触发输出

正如之前提到的,输出是组件实例上的 EventEmitter 属性。之前我们使用 triggerEventHandler 抽象来模拟输出事件。现在我们可以直接访问输出,并调用其 emit 方法,就像子组件中的代码一样。

it('listens for count changes', () => {
  const counterEl = fixture.debugElement.query(
    By.directive(FakeCounterComponent)
  );
  const counter: CounterComponent = counterEl.componentInstance;

  spyOn(console, 'log');
  const count = 5;
  counter.countChange.emit(5);
  expect(console.log).toHaveBeenCalledWith(
    'countChange event from CounterComponent',
    count,
  );
});

我们完成了!这是对 CounterComponent 子组件进行验证的 HomeComponent 测试套件。为了减少重复和噪音,我们将查询部分移到 beforeEach 块中。

@Component({
  selector: 'app-counter',
  template: '',
})
class FakeCounterComponent implements Partial<CounterComponent> {
  @Input()
  public startCount = 0;

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

describe('HomeComponent (faking a child Component)', () => {
  let fixture: ComponentFixture<HomeComponent>;
  let component: HomeComponent;
  let counter: FakeCounterComponent;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [HomeComponent, FakeCounterComponent],
      schemas: [NO_ERRORS_SCHEMA],
    }).compileComponents();

    fixture = TestBed.createComponent(HomeComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();

    const counterEl = fixture.debugElement.query(
      By.directive(FakeCounterComponent)
    );
    counter = counterEl.componentInstance;
  });

  it('renders an independent counter', () => {
    expect(counter).toBeTruthy();
  });

  it('passes a start count', () => {
    expect(counter.startCount).toBe(5);
  });

  it('listens for count changes', () => {
    spyOn(console, 'log');
    const count = 5;
    counter.countChange.emit(count);
    expect(console.log).toHaveBeenCalledWith(
      'countChange event from CounterComponent',
      count,
    );
  });
});

让我们总结一下使用这种方式测试 HomeComponent 所获得的收益。

我们用一个行为相同的伪造组件替代了一个组件依赖项,就 HomeComponent 来说,它们的行为是一样的。伪造的子组件被渲染,但模板可能是空的。

原始的子组件 CounterComponent 仅被导入用于创建衍生的伪造组件。我们的测试仍然是一个快速、简短的单元测试。

优势

与其搜索名为 app-counter 的元素,我们搜索组件实例。这样更加稳健。主机元素的存在是一个很好的指示,但更重要的是组件是否已被渲染到该元素中。

使用组件实例比使用 DebugElement 抽象更直观。我们可以读取组件的属性来了解输入和输出。基本的 JavaScript 和 Angular 知识足以编写针对这种实例的规范。

手动伪造的缺点

我们简单的伪造子组件方法有其缺点。我们手动创建了伪造组件。这是繁琐和耗时的,同时也存在风险。伪造组件只与原始组件部分相关。

例如,如果原始组件更改了其选择器 app-counter,测试应该失败,并提醒我们调整模板。然而,由于我们没有继承组件元数据 { selector: 'app-counter', … },而是在测试中复制了它,因此测试会通过而不是失败。

在下一章中,我们将解决这些缺点。

使用 ng-mocks 伪造子组件

我们手动创建了一个组件伪造。这是一个重要的练习,用于理解如何伪造组件,但它不能产生一个稳健、多用途的伪造组件。在本指南中,我们无法讨论创建严密伪造组件所需的所有要点和细节。

相反,我们将使用一个成熟的解决方案: ng-mocks 是一个功能丰富的库,用于使用伪造依赖项测试组件。 (请记住,本指南使用总称“伪造(fake)”而其他文章和工具使用“模拟(mock)”或“存根(stub)”等术语。)

从原始组件创建伪造组件

ng-mocks 帮助创建伪造组件以替代子组件,是其中的一项功能。MockComponent 函数接受原始组件并返回一个类似原始组件的伪造组件。

我们不再创建 FakeCounterComponent,而是调用 MockComponent(CounterComponent) 并将伪造组件添加到测试模块中。

import { MockComponent } from 'ng-mocks';
beforeEach(async () => {
  await TestBed.configureTestingModule({
    declarations: [HomeComponent, MockComponent(CounterComponent)],
    schemas: [NO_ERRORS_SCHEMA],
  }).compileComponents();
});

然后,我们可以查询渲染的 DOM 来查找 CounterComponent 的实例。找到的实例实际上是由 ng-mocks 创建的伪造组件。 尽管如此,我们仍然可以声明类型 CounterComponent

describe('HomeComponent with ng-mocks', () => {
  let fixture: ComponentFixture<HomeComponent>;
  let component: HomeComponent;
  // Original type!
  let counter: CounterComponent;

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

    fixture = TestBed.createComponent(HomeComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();

    const counterEl = fixture.debugElement.query(
      // Original class!
      By.directive(CounterComponent)
    );
    counter = counterEl.componentInstance;
  });

  /* … */
});

从 TypeScript 的角度来看,伪造组件符合 CounterComponent 类型。TypeScript 使用结构类型系统,检查是否满足所有类型要求。

类型等价性

对于 CounterComponent 适用的每个命题也适用于伪造组件。伪造组件具有与原始组件相同的所有属性和方法。这就是为什么我们可以安全地用伪造组件替换原始组件,并在测试中将伪造组件视为相同的原始组件。

完整代码:

describe('HomeComponent with ng-mocks', () => {
  let fixture: ComponentFixture<HomeComponent>;
  let component: HomeComponent;
  let counter: CounterComponent;

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

    fixture = TestBed.createComponent(HomeComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();

    const counterEl = fixture.debugElement.query(
      By.directive(CounterComponent)
    );
    counter = counterEl.componentInstance;
  });

  it('renders an independent counter', () => {
    expect(counter).toBeTruthy();
  });

  it('passes a start count', () => {
    expect(counter.startCount).toBe(5);
  });

  it('listens for count changes', () => {
    spyOn(console, 'log');
    const count = 5;
    counter.countChange.emit(count);
    expect(console.log).toHaveBeenCalledWith(
      'countChange event from CounterComponent',
      count,
    );
  });
});

我们消除了手动创建的 FakeCounterComponent。我们使用 MockComponent(CounterComponent) 来创建伪造组件,并使用原始的 CounterComponent 类。测试本身并没有改变。

这只是 ng-mocks 的一个简单示例。该库不仅帮助处理嵌套组件,还提供了高级的辅助方法来设置 Angular 测试环境。ng-mocks 可以替代传统的 TestBed.configureTestingModule 设置,并帮助伪造模块、组件、指令、管道和服务。