版本
语言

Angular UI 单元测试

ABP Angular UI的测试与其他Angular应用程序一样. 所以, 这里的指南也适用于ABP. 也就是说, 我们想指出一些特定于ABP Angular应用程序的单元测试内容.

设置

在Angular中, 单元测试默认使用KarmaJasmine. 虽然我们更喜欢Jest, 但我们选择不偏离这些默认设置, 因此你下载的应用程序模板将预先配置Karma和Jasmine. 你可以在根目录中的 karma.conf.js 文件中找到Karma配置. 你什么都不用做. 添加一个spec文件并运行npm test即可.

基础

简化版的spec文件如下所示:

import { CoreTestingModule } from "@abp/ng.core/testing";
import { ThemeBasicTestingModule } from "@abp/ng.theme.basic/testing";
import { ThemeSharedTestingModule } from "@abp/ng.theme.shared/testing";
import { ComponentFixture, TestBed, waitForAsync } from "@angular/core/testing";
import { NgxValidateCoreModule } from "@ngx-validate/core";
import { MyComponent } from "./my.component";

describe("MyComponent", () => {
  let fixture: ComponentFixture<MyComponent>;

  beforeEach(
    waitForAsync(() => {
      TestBed.configureTestingModule({
        declarations: [MyComponent],
        imports: [
          CoreTestingModule.withConfig(),
          ThemeSharedTestingModule.withConfig(),
          ThemeBasicTestingModule.withConfig(),
          NgxValidateCoreModule,
        ],
        providers: [
          /* mock providers here */
        ],
      }).compileComponents();
    })
  );

  beforeEach(() => {
    fixture = TestBed.createComponent(MyComponent);
    fixture.detectChanges();
  });

  it("should be initiated", () => {
    expect(fixture.componentInstance).toBeTruthy();
  });
});

如果你看一下导入内容, 你会注意到我们已经准备了一些测试模块来取代内置的ABP模块. 这对于模拟某些特性是必要的, 否则这些特性会破坏你的测试. 请记住使用测试模块调用其withConfig静态方法.

提示

Angular测试库

虽然你可以使用Angular TestBed测试代码, 但你可以找到一个好的替代品Angular测试库.

上面的简单示例可以用Angular测试库编写, 如下所示:

import { CoreTestingModule } from "@abp/ng.core/testing";
import { ThemeBasicTestingModule } from "@abp/ng.theme.basic/testing";
import { ThemeSharedTestingModule } from "@abp/ng.theme.shared/testing";
import { ComponentFixture } from "@angular/core/testing";
import { NgxValidateCoreModule } from "@ngx-validate/core";
import { render } from "@testing-library/angular";
import { MyComponent } from "./my.component";

describe("MyComponent", () => {
  let fixture: ComponentFixture<MyComponent>;

  beforeEach(async () => {
    const result = await render(MyComponent, {
      imports: [
        CoreTestingModule.withConfig(),
        ThemeSharedTestingModule.withConfig(),
        ThemeBasicTestingModule.withConfig(),
        NgxValidateCoreModule,
      ],
      providers: [
        /* mock providers here */
      ],
    });

    fixture = result.fixture;
  });

  it("should be initiated", () => {
    expect(fixture.componentInstance).toBeTruthy();
  });
});

正如你所见, 二者非常相似. 当我们使用查询和触发事件时, 真正的区别就显现出来了.

// other imports
import { getByLabelText, screen } from "@testing-library/angular";
import userEvent from "@testing-library/user-event";

describe("MyComponent", () => {
  beforeEach(/* removed for sake of brevity */);

  it("should display advanced filters", () => {
    const filters = screen.getByTestId("author-filters");
    const nameInput = getByLabelText(filters, /name/i) as HTMLInputElement;
    expect(nameInput.offsetWidth).toBe(0);

    const advancedFiltersBtn = screen.getByRole("link", { name: /advanced/i });
    userEvent.click(advancedFiltersBtn);

    expect(nameInput.offsetWidth).toBeGreaterThan(0);

    userEvent.type(nameInput, "fooo{backspace}");
    expect(nameInput.value).toBe("foo");
  });
});

Angular测试库中的查询遵循可维护测试, 用户事件库提供了与DOM的类人交互, 并且该库通常有清晰的API简化组件测试. 下面提供一些有用的链接:

在每个Spec之后清除DOM

需要记住的一点是, Karma在真实的浏览器实例中运行测试. 这意味着, 你将能够看到测试代码的结果, 但也会遇到与文档正文连接的组件的问题, 这些组件可能无法在每次测试后都清除, 即使你配置了Karma也一样无法清除.

我们准备了一个简单的函数, 可以在每次测试后清除所有剩余的DOM元素.

// other imports
import { clearPage } from "@abp/ng.core/testing";

describe("MyComponent", () => {
  let fixture: ComponentFixture<MyComponent>;

  afterEach(() => clearPage(fixture));

  beforeEach(async () => {
    const result = await render(MyComponent, {
      /* removed for sake of brevity */
    });
    fixture = result.fixture;
  });

  // specs here
});

请确保你使用它, 否则Karma将无法删除对话框, 并且你将有多个模态对话框、确认框等的副本.

等待

一些组件, 特别是在检测周期之外工作的模态对话框. 换句话说, 你无法在打开这些组件后立即访问这些组件插入的DOM元素. 同样, 插入的元素在关闭时也不会立即销毁.

为此, 我们准备了一个wait函数.

// other imports
import { wait } from "@abp/ng.core/testing";

describe("MyComponent", () => {
  beforeEach(/* removed for sake of brevity */);

  it("should open a modal", async () => {
    const openModalBtn = screen.getByRole("button", { name: "Open Modal" });
    userEvent.click(openModalBtn);

    await wait(fixture);

    const modal = screen.getByRole("dialog");

    expect(modal).toBeTruthy();

    /* wait again after closing the modal */
  });
});

wait函数接受第二个参数, 即超时(默认值为0). 但是尽量不要使用它. 使用大于0的超时通常表明某些不正确事情发生了.

测试示例

下面是一个测试示例. 它并没有涵盖所有内容, 但却能够对测试有一个更好的了解.

import { clearPage, CoreTestingModule, wait } from "@abp/ng.core/testing";
import { ThemeBasicTestingModule } from "@abp/ng.theme.basic/testing";
import { ThemeSharedTestingModule } from "@abp/ng.theme.shared/testing";
import { ComponentFixture } from "@angular/core/testing";
import {
  NgbCollapseModule,
  NgbDatepickerModule,
  NgbDropdownModule,
} from "@ng-bootstrap/ng-bootstrap";
import { NgxValidateCoreModule } from "@ngx-validate/core";
import { CountryService } from "@proxy/countries";
import {
  findByText,
  getByLabelText,
  getByRole,
  getByText,
  queryByRole,
  render,
  screen,
} from "@testing-library/angular";
import userEvent from "@testing-library/user-event";
import { BehaviorSubject, of } from "rxjs";
import { CountryComponent } from "./country.component";

const list$ = new BehaviorSubject({
  items: [{ id: "ID_US", name: "United States of America" }],
  totalCount: 1,
});

describe("Country", () => {
  let fixture: ComponentFixture<CountryComponent>;

  afterEach(() => clearPage(fixture));

  beforeEach(async () => {
    const result = await render(CountryComponent, {
      imports: [
        CoreTestingModule.withConfig(),
        ThemeSharedTestingModule.withConfig(),
        ThemeBasicTestingModule.withConfig(),
        NgxValidateCoreModule,
        NgbCollapseModule,
        NgbDatepickerModule,
        NgbDropdownModule,
      ],
      providers: [
        {
          provide: CountryService,
          useValue: {
            getList: () => list$,
          },
        },
      ],
    });

    fixture = result.fixture;
  });

  it("should display advanced filters", () => {
    const filters = screen.getByTestId("country-filters");
    const nameInput = getByLabelText(filters, /name/i) as HTMLInputElement;
    expect(nameInput.offsetWidth).toBe(0);

    const advancedFiltersBtn = screen.getByRole("link", { name: /advanced/i });
    userEvent.click(advancedFiltersBtn);

    expect(nameInput.offsetWidth).toBeGreaterThan(0);

    userEvent.type(nameInput, "fooo{backspace}");
    expect(nameInput.value).toBe("foo");

    userEvent.click(advancedFiltersBtn);
    expect(nameInput.offsetWidth).toBe(0);
  });

  it("should have a heading", () => {
    const heading = screen.getByRole("heading", { name: "Countries" });
    expect(heading).toBeTruthy();
  });

  it("should render list in table", async () => {
    const table = await screen.findByTestId("country-table");

    const name = getByText(table, "United States of America");
    expect(name).toBeTruthy();
  });

  it("should display edit modal", async () => {
    const actionsBtn = screen.queryByRole("button", { name: /actions/i });
    userEvent.click(actionsBtn);

    const editBtn = screen.getByRole("button", { name: /edit/i });
    userEvent.click(editBtn);

    await wait(fixture);

    const modal = screen.getByRole("dialog");
    const modalHeading = queryByRole(modal, "heading", { name: /edit/i });
    expect(modalHeading).toBeTruthy();

    const closeBtn = getByText(modal, "×");
    userEvent.click(closeBtn);

    await wait(fixture);

    expect(screen.queryByRole("dialog")).toBeFalsy();
  });

  it("should display create modal", async () => {
    const newBtn = screen.getByRole("button", { name: /new/i });
    userEvent.click(newBtn);

    await wait(fixture);

    const modal = screen.getByRole("dialog");
    const modalHeading = queryByRole(modal, "heading", { name: /new/i });

    expect(modalHeading).toBeTruthy();
  });

  it("should validate required name field", async () => {
    const newBtn = screen.getByRole("button", { name: /new/i });
    userEvent.click(newBtn);

    await wait(fixture);

    const modal = screen.getByRole("dialog");
    const nameInput = getByRole(modal, "textbox", {
      name: /^name/i,
    }) as HTMLInputElement;

    userEvent.type(nameInput, "x");
    userEvent.type(nameInput, "{backspace}");

    const nameError = await findByText(modal, /required/i);
    expect(nameError).toBeTruthy();
  });

  it("should delete a country", () => {
    const getSpy = spyOn(fixture.componentInstance.list, "get");
    const deleteSpy = jasmine.createSpy().and.returnValue(of(null));
    fixture.componentInstance.service.delete = deleteSpy;

    const actionsBtn = screen.queryByRole("button", { name: /actions/i });
    userEvent.click(actionsBtn);

    const deleteBtn = screen.getByRole("button", { name: /delete/i });
    userEvent.click(deleteBtn);

    const confirmText = screen.getByText("AreYouSure");
    expect(confirmText).toBeTruthy();

    const confirmBtn = screen.getByRole("button", { name: "Yes" });
    userEvent.click(confirmBtn);

    expect(deleteSpy).toHaveBeenCalledWith(list$.value.items[0].id);
    expect(getSpy).toHaveBeenCalledTimes(1);
  });
});

CI配置

你的CI环境需要不同的配置. 要为单元测试设置新的配置, 请在测试项目中找到 angular.json 文件, 或者如下所示添加一个:

// angular.json

"test": {
  "builder": "@angular-devkit/build-angular:karma",
  "options": { /* several options here */ },
  "configurations": {
    "production": {
      "karmaConfig": "karma.conf.prod.js"
    }
  }
}

现在你可以复制 karma.conf.js 作为 karma.conf.prod.js 并在其中使用你喜欢的任何配置. 请查看Karma配置文档配置选项.

最后, 不要忘记使用以下命令运行CI测试:

npm test -- --prod

另请参阅

本页对您有帮助吗?
请进行选择。
感谢您的宝贵意见!

请注意,虽然我们无法回复反馈意见,但我们的团队会根据您的意见改进体验。

在本文档中
Mastering ABP Framework Book
掌握 ABP 框架

本书将帮助你全面了解框架和现代Web应用程序开发技术。