# Angular UI 单元测试 ABP Angular UI的测试与其他Angular应用程序一样. 所以, [这里的指南](https://angular.io/guide/testing)也适用于ABP. 也就是说, 我们想指出一些**特定于ABP Angular应用程序的单元测试内容**. ## 设置 在Angular中, 单元测试默认使用[Karma](https://karma-runner.github.io/)和[Jasmine](https://jasmine.github.io). 虽然我们更喜欢Jest, 但我们选择不偏离这些默认设置, 因此**你下载的应用程序模板将预先配置Karma和Jasmine**. 你可以在根目录中的 _karma.conf.js_ 文件中找到Karma配置. 你什么都不用做. 添加一个spec文件并运行`npm test`即可. ## 基础 简化版的spec文件如下所示: ```js 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; 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测试库](https://testing-library.com/docs/angular-testing-library/intro). 上面的简单示例可以用Angular测试库编写, 如下所示: ```js 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; 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(); }); }); ``` 正如你所见, 二者非常相似. 当我们使用查询和触发事件时, 真正的区别就显现出来了. ```js // 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**简化组件测试. 下面提供一些有用的链接: - [查询](https://testing-library.com/docs/dom-testing-library/api-queries) - [用户事件](https://testing-library.com/docs/ecosystem-user-event) - [范例](https://github.com/testing-library/angular-testing-library/tree/main/apps/example-app/src/app/examples) ### 在每个Spec之后清除DOM 需要记住的一点是, Karma在真实的浏览器实例中运行测试. 这意味着, 你将能够看到测试代码的结果, 但也会遇到与文档正文连接的组件的问题, 这些组件可能无法在每次测试后都清除, 即使你配置了Karma也一样无法清除. 我们准备了一个简单的函数, 可以在每次测试后清除所有剩余的DOM元素. ```js // other imports import { clearPage } from "@abp/ng.core/testing"; describe("MyComponent", () => { let fixture: ComponentFixture; afterEach(() => clearPage(fixture)); beforeEach(async () => { const result = await render(MyComponent, { /* removed for sake of brevity */ }); fixture = result.fixture; }); // specs here }); ``` 请确保你使用它, 否则Karma将无法删除对话框, 并且你将有多个模态对话框、确认框等的副本. ### 等待 一些组件, 特别是在检测周期之外工作的模态对话框. 换句话说, 你无法在打开这些组件后立即访问这些组件插入的DOM元素. 同样, 插入的元素在关闭时也不会立即销毁. 为此, 我们准备了一个`wait`函数. ```js // 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`的超时通常表明某些不正确事情发生了. ## 测试示例 下面是一个测试示例. 它并没有涵盖所有内容, 但却能够对测试有一个更好的了解. ```js 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; 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_ 文件, 或者如下所示添加一个: ```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配置文档](http://karma-runner.github.io/5.2/config/configuration-file.html)配置选项. 最后, 不要忘记使用以下命令运行CI测试: ```sh npm test -- --prod ``` ## 另请参阅 - [ABP Community Video - Unit Testing with the Angular UI](https://community.abp.io/articles/unit-testing-with-the-angular-ui-p4l550q3)