单元测试 Angular - 组件测试

2025-06-04

单元测试 Angular - 组件测试

我们每天都会看到越来越多的人希望在应用程序中添加自动化测试,无论是单元测试、集成测试还是端到端测试。

这将是一系列基于为 Angular 编写单元测试及其一些核心概念的文章:组件、服务、管道和防护。

这些文章并非旨在全面介绍单元测试,而只是对单元测试进行简单的介绍。如需更详细的组件测试文档,Angular 有一个很棒的文档页面:https://angular.io/guide/testing

值得注意的是,本文将阐述我对测试的一些主观看法。测试本身就是一个非常固执己见的话题。我建议你仔细研究一下现有的所有测试策略,然后确定你认为最好的方法。

在本文中,我们将探讨测试组件,从简单组件到更复杂的组件,我们将涵盖以下内容:

  • 什么是单元测试?💡
  • 为什么要写单元测试?🤔
  • 好的,现在我们该如何编写单元测试?😄

我们将在使用 Angular CLI 生成的应用程序上使用 Angular 开箱即用的标准 Jasmine 和 Karma 测试设置。

💡 什么是单元测试?

单元测试是一种软件测试,用于验证代码的独立部分(单元)的正确性。

假设您有一个简单的加法函数:

function sum(...args) {
    return args.reduce((total, value) => total + value, 0);
}
Enter fullscreen mode Exit fullscreen mode

这个完整的函数可以被视为一个单元,因此您的测试将验证该单元是否正确。对该单元的快速测试可以是:

it('should sum a range of numbers correctly', () => {
    // Arrange
    const expectedValue = 55;
    const numsToTest = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

    // Act
    const total = sum(...numsToTest);

    // Assert
    expect(total).toBe(expectedValue);
});
Enter fullscreen mode Exit fullscreen mode

我们在这里介绍几个概念。
it(...args)用于设置单元测试的函数。它是测试运行器中相当常见的测试术语。

我们还介绍了 AAA 测试模式。该模式将测试分为三个部分。

第一部分是“安排”:在这里,您可以执行测试所需的任何设置。

第二部分是“执行”:在这里,您将获取代码来执行要测试的操作。

第三部分也是最后一部分是“断言”:在这里,您将验证单元是否按预期执行。

在上面的测试中,我们设置了函数正确执行时的预期值,并设置了用于测试函数的数据。

然后,我们sum()用之前准备好的测试数据调用该函数,并将结果存储在一个total变量中。

最后,我们检查是否total与预期值相同。

如果相同,测试就会通过,这要归功于我们使用了该expect()方法。

注意:.toBe()是一个匹配器函数。匹配器函数会检查传入的值是否expect()与期望结果匹配。Jasmine 内置了许多匹配器函数,您可以在此处查看:Jasmine Matchers

🤔但是为什么呢?

简单!对变化充满信心。

作为开发者,你会持续不断地修改代码库。但如果没有测试,你怎么知道你所做的更改不会破坏应用其他功能呢?

您可以尝试手动测试应用程序中每个可能的区域和场景。但这会占用您的开发时间,并最终降低您的生产力。

如果您可以简单地运行一个命令来检查应用程序的所有区域,以确保一切仍按预期运行,那么效率会更高。对吗?

这正是自动化单元测试想要实现的目标,尽管您在编写测试时会花费更多时间来开发功能或修复错误,但如果您需要更改功能或重构代码,那么将来您就可以节省这些时间。

另一个好处是,任何跟在你后面的开发人员都可以使用你编写的测试套件作为代码的文档。如果他们不明白如何在代码中使用某个类或方法,测试会向他们展示如何使用!

需要注意的是,这些好处来自于编写良好的测试。我们稍后会探讨好测试和坏测试之间的区别。

😄 让我们编写一个 Angular 组件测试

我们将把它分解为一系列步骤,涵盖以下测试场景:

  • 仅具有输入和输出的简单组件
  • 具有 DI 提供程序的复杂组件

让我们从一个只有输入和输出的简单组件开始。一个纯粹的展示组件

🖼️ 展示组件测试

我们先从一个非常简单的组件开始user-speak.component.ts,它有一个输入和一个输出。它会显示用户的姓名,并有两个按钮允许用户回复:

import { Component, Input, Output, EventEmitter } from '@angular/core';

@Component({
    selector: 'app-user-speak',
    template: `
        <div>Hello {{ name }}</div>
        <div>
            <button (click)="sayHello()">Say Hello</button>
            <button (click)="sayGoodbye()">Say Goodbye</button>
        </div>
    `
})
export class UserSpeakComponent {
    @Input() name: string;
    @Output() readonly speak = new EventEmitter<string>();

    constructor() {}

    sayHello() {
        this.speak.emit('Hello');
    }

    sayGoodbye() {
        this.speak.emit('Goodbye');
    }
}
Enter fullscreen mode Exit fullscreen mode

如果您使用 Angular CLI (强烈推荐!)生成组件,您将获得一个开箱即用的测试文件。如果没有,请创建一个user-speak.component.spec.ts

注意:.spec.ts非常重要。测试运行器通过它来找到您的测试!

然后在里面,确保它最初看起来像这样:

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

import { UserSpeakComponent } from './user-speak.component';

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

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

    beforeEach(() => {
        fixture = TestBed.createComponent(UserSpeakComponent);
        component = fixture.componentInstance;
        fixture.detectChanges();
    });

    it('should create', () => {
        expect(component).toBeTruthy();
    });
});
Enter fullscreen mode Exit fullscreen mode

让我们稍微解释一下这里发生的事情。

describe('UserSpeakComponent', () => ...)调用正在为我们的 User Speak 组件设置一个测试套件。它将包含我们希望为组件执行的所有测试。

这些beforeEach()调用指定了每次测试运行之前应该执行的代码。使用 Angular,我们必须告诉编译器如何正确地解释和编译我们的组件。这就是调用的作用所在TestBed.configureTestingModule。对于这个特定的组件测试,我们不会对此进行过多的详细介绍,但是,在本文的后面,我们将描述如何在组件中使用 DI 提供程序时对其进行修改。

有关更多信息,请查看Angular 测试文档

每次it()调用都会为测试运行器创建一个新的测试。

在上面的示例中,我们目前只有一个测试。该测试用于检查组件是否已成功创建。它几乎就像一次健全性检查,以确保我们已TestBed正确设置组件。

现在,我们知道 Component 类有一个constructor和 两个方法,sayHello以及sayGoodbye。由于构造函数为空,我们不需要测试这一点。但是,其他两个方法确实包含逻辑。

我们可以把这些方法视为需要测试的单元。因此,我们将为它们编写两个单元测试。

需要记住的是,当我们编写单元测试时,我们希望它们是独立的。本质上,这意味着它应该是完全独立的。如果我们仔细观察我们的方法,你会发现它们正在调用组件中 EventEmitteremit上的方法speak

我们的单元测试并不关心emit功能是否正常工作,相反,我们只想确保我们的方法emit能够适当地调用该方法:

it('should say hello', () => {
    // Arrange
    const sayHelloSpy = spyOn(component.speak, 'emit');
    // Act
    component.sayHello();
    // Assert
    expect(sayHelloSpy).toHaveBeenCalled();
    expect(sayHelloSpy).toHaveBeenCalledWith('Hello');
});

it('should say goodbye', () => {
    // Arrange
    const sayGoodbyeSpy = spyOn(component.speak, 'emit');
    // Act
    component.sayGoodbye();
    // Assert
    expect(sayGoodbyeSpy).toHaveBeenCalled();
    expect(sayGoodbyeSpy).toHaveBeenCalledWith('Goodbye');
});
Enter fullscreen mode Exit fullscreen mode

在这里,我们遇到了spyOn允许我们模拟emit调用的实际实现的函数,并创建一个Jasmine Spy,然后我们可以使用它来检查是否emit进行了调用以及传递给它的参数,从而允许我们单独检查我们的单元是否正确执行。

如果我们ng test从命令行运行,我们将看到测试正确通过。太棒了。

🔧 重构

等等!两个方法本质上做同样的事情,会造成大量代码重复。让我们重构一下代码,让它更符合 DRY 原则:

import { Component, Input, Output, EventEmitter } from '@angular/core';

@Component({
    selector: 'app-user-speak',
    template: `
        <div>Hello {{ name }}</div>
        <div>
            <button (click)="saySomething('Hello')">Say Hello</button>
            <button (click)="saySomething('Goodbye')">Say Goodbye</button>
        </div>
    `
})
export class UserSpeakComponent {
    @Input() name: string;
    @Output() readonly speak = new EventEmitter<string>();

    constructor() {}

    saySomething(words: string) {
        this.speak.emit(words);
    }
}
Enter fullscreen mode Exit fullscreen mode

太棒了,这好多了。我们再跑一遍测试:ng test哦哦

!😱

测试失败!

我们的单元测试能够正确捕捉到我们改变的功能,并且可能破坏了一些以前正常工作的功能。💪

让我们更新测试以确保它们继续适用于我们的新逻辑:

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

import { UserSpeakComponent } from './user-speak.component';

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

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

    beforeEach(() => {
        fixture = TestBed.createComponent(UserSpeakComponent);
        component = fixture.componentInstance;
        fixture.detectChanges();
    });

    it('should create', () => {
        expect(component).toBeTruthy();
    });

    it('should say something', () => {
        // Arrange
        const saySomethingSpy = spyOn(component.speak, 'emit');

        // Act
        component.saySomething('something');

        // Assert
        expect(saySomethingSpy).toHaveBeenCalled();
        expect(saySomethingSpy).toHaveBeenCalledWith('something');
    });
});
Enter fullscreen mode Exit fullscreen mode

我们删除了之前的两个测试,并用一个新的测试进行了更新。这个测试确保传递给该saySomething方法的任何字符串都会传递给调用方emit,这样我们就可以测试“Say Hello”按钮和“Say Goodbye”按钮了。

太棒了!🚀

注意:关于在单元测试中测试 JSDOM 存在争议。我个人反对这种方法,因为我觉得它更像是集成测试而不是单元测试,应该与单元测试套件分开。

让我们继续:

🤯 复杂组件测试

现在我们已经了解了如何测试纯展示组件,让我们看一下如何测试注入了 DI 提供程序的组件。

有几种方法可以解决这个问题,因此我将展示我倾向于采用的方法。

让我们创建一个UserComponent注入了的UserService

import { Component, OnInit } from '@angular/core';
import { UserService } from '../user.service';

@Component({
    selector: 'app-user',
    template: `
        <app-user-speak
            [name]="user?.name"
            (speak)="onSpeak($event)"
        ></app-user-speak>
    `
})
export class UserComponent implements OnInit {
    user: User;

    constructor(public userService: UserService) {}

    ngOnInit(): void {
        this.user = this.userService.getUser();
    }

    onSpeak(words: string) {
        console.log(words);
    }
}
Enter fullscreen mode Exit fullscreen mode

相当简单,除了我们已经将UserServiceInjectable 注入到我们的组件中。

再次,让我们设置我们的初始测试文件user.component.spec.ts

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

import { UserComponent } from './user.component';

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

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

    beforeEach(() => {
        fixture = TestBed.createComponent(UserComponent);
        component = fixture.componentInstance;
        fixture.detectChanges();
    });

    it('should create', () => {
        expect(component).toBeTruthy();
    });
});
Enter fullscreen mode Exit fullscreen mode

如果我们现在运行ng test,它将会失败,因为我们缺少提供程序,UserService因此TestBed无法正确注入它以成功创建组件。

因此,我们必须编辑TestBed设置,以便能够正确创建组件。请记住,我们正在编写单元测试,因此只想单独运行这些测试,而不关心UserService方法是否正常工作。

TestBed也无法理解app-user-speak我们 HTML 中的组件。这是因为我们还没有将它添加到声明模块中。不过,是时候提出一些争议了。我的观点是,我们的测试不需要知道这个组件的组成,我们只需要测试组件中的 TypeScript,而不是 HTML,因此我们将使用一种称为“浅渲染”的技术,它会告诉 Angular 编译器忽略 HTML 中的问题。

为了做到这一点,我们必须编辑它,TestBed.configureTestingModule使其看起来像这样:

TestBed.configureTestingModule({
    declarations: [UserComponent],
    schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
Enter fullscreen mode Exit fullscreen mode

这样就能解决app-user-speak未声明的问题了。但我们仍然需要修复缺少提供程序的UserService错误。我们将在单元测试中使用一种称为“Mocking”的技术,创建一个模拟对象,并将其注入到组件中,而不是注入真实的 UserService。

创建 Mock / Spy 对象的方法有很多种。Jasmine 内置了一些选项,您可以点击此处了解详情。

我们将采取稍微不同的方法:

TestBed.configureTestingModule({
    declarations: [UserComponent],
    providers: [
        {
            provide: UserService,
            useValue: {
                getUser: () => ({ name: 'Test' })
            }
        }
    ],
    schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
Enter fullscreen mode Exit fullscreen mode

我们现在感兴趣的部分是我们的providers数组。这里我们告诉编译器将这里定义的值作为 UserService 提供。我们设置一个新的对象并定义我们想要模拟的方法,在本例中getUser,我们将告诉它返回一个特定的对象,而不是让真正的 UserService 执行从数据库获取用户或类似操作的逻辑。

我对此的想法是,您与之交互的每个公共 API 都应该经过测试,因此您的单元测试不需要确保 API 正常工作,但是,您要确保您的代码能够正确地使用 API 返回的内容。

现在让我们编写测试来检查我们是否在我们的方法中获取用户ngOnInit

it('should fetch the user', () => {
    // Arrange
    const fetchUserSpy = spyOn(
        component.userService,
        'getUser'
    ).and.returnValue({ name: 'Test' });

    // Act
    component.ngOnInit();

    // Assert
    expect(fetchUserSpy).toHaveBeenCalled();
});
Enter fullscreen mode Exit fullscreen mode

这里我们简单地创建了一个间谍程序来确保调用getUser是在方法体中进行的ngOnInit。完美!

我们还利用.and.returnValue()语法来告诉 Jasmine,当调用该 API 时应该返回什么ngOnInit()。这使我们能够通过强制返回错误或不完整的对象来检查边缘情况和错误情况。

让我们将ngOnInit()方法修改如下,以允许它处理错误:

ngOnInit(): void {
    try {
      this.user = this.userService.getUser();
    } catch (error) {
      this.user = null;
    }
  }
Enter fullscreen mode Exit fullscreen mode

现在让我们编写一个新的测试,告诉 Jasmine 抛出一个错误,让我们检查我们的代码是否正确处理错误情况:

it('should handle error when fetching user', () => {
    // Arrange
    const fetchUserSpy = spyOn(component.userService, 'getUser').and.throwError(
        'Error'
    );

    // Act
    component.ngOnInit();

    // Assert
    expect(fetchUserSpy).toHaveBeenCalled();
    expect(fetchUserSpy).toThrowError();
    expect(component.user).toBe(null);
});
Enter fullscreen mode Exit fullscreen mode

完美!🔥🔥现在我们还可以确保我们的代码能够正确处理 Error 情况!


本文是对使用 Jasmine 和 Karma 对 Angular 组件进行单元测试的简短介绍(非全面)。我之后会发布更多关于 Angular 单元测试的文章,涵盖测试服务、数据服务、管道和防护装置。

如果您有任何疑问,请随时在下面提问或在 Twitter 上联系我:@FerryColum

文章来源:https://dev.to/coly010/unit-testing-angular-component-testing-2g47
PREV
通过这些问题破解你的 MERN 面试 MongoDB NodeJs ReactJS
NEXT
复合模式 - 设计模式与前端的结合