Angular UI 单元测试
ABP Angular UI的测试与其他Angular应用程序一样. 所以, 这里的指南也适用于ABP. 也就是说, 我们想指出一些特定于ABP Angular应用程序的单元测试内容.
设置
在Angular中, 单元测试默认使用Karma和Jasmine. 虽然我们更喜欢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