项目

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: [
          /* 在此处添加模拟提供者 */
        ],
      }).compileComponents();
    })
  );

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

  it("应该被初始化", () => {
    expect(fixture.componentInstance).toBeTruthy();
  });
});

如果您查看导入部分,您会注意到我们准备了一些测试模块来替换内置的 ABP 模块。这对于为某些功能提供模拟是必要的,否则这些功能可能会破坏您的测试。请记住 使用测试模块调用它们的 withConfig 静态方法

提示

Angular Testing Library

虽然您可以使用 Angular TestBed 测试代码,但您可能会发现 Angular Testing Library 是一个不错的选择。

上面的简单示例可以用 Angular Testing Library 重写如下:

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: [
        /* 在此处添加模拟提供者 */
      ],
    });

    fixture = result.fixture;
  });

  it("应该被初始化", () => {
    expect(fixture.componentInstance).toBeTruthy();
  });
});

如您所见,非常相似。真正的区别在于我们使用查询和触发事件时。

// 其他导入
import { getByLabelText, screen } from "@testing-library/angular";
import userEvent from "@testing-library/user-event";

describe("MyComponent", () => {
  beforeEach(/* 为简洁起见省略 */);

  it("应该显示高级过滤器", () => {
    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 Testing Library 中的查询遵循可维护测试的实践,用户事件包提供了 类似人类的 DOM 交互方式,并且该库总体上具有 清晰的 API,简化了组件测试。请在下面找到一些有用的链接:

每个 Spec 后清理 DOM

需要记住的一件事是,Karma 在真实的浏览器实例中运行测试。这意味着,您将能够看到测试代码的结果,但也会遇到附加到文档正文的组件带来的问题,即使您配置了 Karma 在每个测试后清理,这些组件也可能不会被清除。

我们准备了一个简单的函数,您可以在每次测试后使用它来清理任何残留的 DOM 元素。

// 其他导入
import { clearPage } from "@abp/ng.core/testing";

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

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

  beforeEach(async () => {
    const result = await render(MyComponent, {
      /* 为简洁起见省略 */
    });
    fixture = result.fixture;
  });

  // 此处是 spec
});

请确保使用它,因为否则 Karma 将无法移除对话框,并且您将拥有多个模态框、确认框等的副本。

等待

一些组件,特别是模态框,在检测周期外工作。换句话说,您不能在打开这些组件后立即访问它们插入的 DOM 元素。同样,插入的元素在关闭时也不会立即被销毁。

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

// 其他导入
import { wait } from "@abp/ng.core/testing";

describe("MyComponent", () => {
  beforeEach(/* 为简洁起见省略 */);

  it("应该打开一个模态框", async () => {
    const openModalBtn = screen.getByRole("button", { name: "打开模态框" });
    userEvent.click(openModalBtn);

    await wait(fixture);

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

    expect(modal).toBeTruthy();

    /* 关闭模态框后再次等待 */
  });
});

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: "美利坚合众国" }],
  totalCount: 1,
});

describe("国家", () => {
  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("应该显示高级过滤器", () => {
    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("应该有一个标题", () => {
    const heading = screen.getByRole("heading", { name: "国家" });
    expect(heading).toBeTruthy();
  });

  it("应该在表格中渲染列表", async () => {
    const table = await screen.findByTestId("country-table");

    const name = getByText(table, "美利坚合众国");
    expect(name).toBeTruthy();
  });

  it("应该显示编辑模态框", 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("应该显示创建模态框", 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("应该验证必填的名称字段", 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("应该删除一个国家", () => {
    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("您确定吗");
    expect(confirmText).toBeTruthy();

    const confirmBtn = screen.getByRole("button", { name: "是" });
    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": { /* 此处有几个选项 */ },
  "configurations": {
    "production": {
      "karmaConfig": "karma.conf.prod.js"
    }
  }
}

现在您可以将 karma.conf.js 复制为 karma.conf.prod.js,并在其中使用任何您喜欢的配置。请查看 Karma 配置文件文档 了解配置选项。

最后,别忘了使用以下命令运行您的 CI 测试:

npm test -- --prod

另请参阅

在本文档中