Angular UI 单元测试

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

设置

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

基础

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

  1. import { CoreTestingModule } from "@abp/ng.core/testing";
  2. import { ThemeBasicTestingModule } from "@abp/ng.theme.basic/testing";
  3. import { ThemeSharedTestingModule } from "@abp/ng.theme.shared/testing";
  4. import { ComponentFixture, TestBed, waitForAsync } from "@angular/core/testing";
  5. import { NgxValidateCoreModule } from "@ngx-validate/core";
  6. import { MyComponent } from "./my.component";
  7. describe("MyComponent", () => {
  8. let fixture: ComponentFixture<MyComponent>;
  9. beforeEach(
  10. waitForAsync(() => {
  11. TestBed.configureTestingModule({
  12. declarations: [MyComponent],
  13. imports: [
  14. CoreTestingModule.withConfig(),
  15. ThemeSharedTestingModule.withConfig(),
  16. ThemeBasicTestingModule.withConfig(),
  17. NgxValidateCoreModule,
  18. ],
  19. providers: [
  20. /* mock providers here */
  21. ],
  22. }).compileComponents();
  23. })
  24. );
  25. beforeEach(() => {
  26. fixture = TestBed.createComponent(MyComponent);
  27. fixture.detectChanges();
  28. });
  29. it("should be initiated", () => {
  30. expect(fixture.componentInstance).toBeTruthy();
  31. });
  32. });

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

提示

Angular测试库

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

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

  1. import { CoreTestingModule } from "@abp/ng.core/testing";
  2. import { ThemeBasicTestingModule } from "@abp/ng.theme.basic/testing";
  3. import { ThemeSharedTestingModule } from "@abp/ng.theme.shared/testing";
  4. import { ComponentFixture } from "@angular/core/testing";
  5. import { NgxValidateCoreModule } from "@ngx-validate/core";
  6. import { render } from "@testing-library/angular";
  7. import { MyComponent } from "./my.component";
  8. describe("MyComponent", () => {
  9. let fixture: ComponentFixture<MyComponent>;
  10. beforeEach(async () => {
  11. const result = await render(MyComponent, {
  12. imports: [
  13. CoreTestingModule.withConfig(),
  14. ThemeSharedTestingModule.withConfig(),
  15. ThemeBasicTestingModule.withConfig(),
  16. NgxValidateCoreModule,
  17. ],
  18. providers: [
  19. /* mock providers here */
  20. ],
  21. });
  22. fixture = result.fixture;
  23. });
  24. it("should be initiated", () => {
  25. expect(fixture.componentInstance).toBeTruthy();
  26. });
  27. });

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

  1. // other imports
  2. import { getByLabelText, screen } from "@testing-library/angular";
  3. import userEvent from "@testing-library/user-event";
  4. describe("MyComponent", () => {
  5. beforeEach(/* removed for sake of brevity */);
  6. it("should display advanced filters", () => {
  7. const filters = screen.getByTestId("author-filters");
  8. const nameInput = getByLabelText(filters, /name/i) as HTMLInputElement;
  9. expect(nameInput.offsetWidth).toBe(0);
  10. const advancedFiltersBtn = screen.getByRole("link", { name: /advanced/i });
  11. userEvent.click(advancedFiltersBtn);
  12. expect(nameInput.offsetWidth).toBeGreaterThan(0);
  13. userEvent.type(nameInput, "fooo{backspace}");
  14. expect(nameInput.value).toBe("foo");
  15. });
  16. });

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

在每个Spec之后清除DOM

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

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

  1. // other imports
  2. import { clearPage } from "@abp/ng.core/testing";
  3. describe("MyComponent", () => {
  4. let fixture: ComponentFixture<MyComponent>;
  5. afterEach(() => clearPage(fixture));
  6. beforeEach(async () => {
  7. const result = await render(MyComponent, {
  8. /* removed for sake of brevity */
  9. });
  10. fixture = result.fixture;
  11. });
  12. // specs here
  13. });

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

等待

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

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

  1. // other imports
  2. import { wait } from "@abp/ng.core/testing";
  3. describe("MyComponent", () => {
  4. beforeEach(/* removed for sake of brevity */);
  5. it("should open a modal", async () => {
  6. const openModalBtn = screen.getByRole("button", { name: "Open Modal" });
  7. userEvent.click(openModalBtn);
  8. await wait(fixture);
  9. const modal = screen.getByRole("dialog");
  10. expect(modal).toBeTruthy();
  11. /* wait again after closing the modal */
  12. });
  13. });

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

测试示例

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

  1. import { clearPage, CoreTestingModule, wait } from "@abp/ng.core/testing";
  2. import { ThemeBasicTestingModule } from "@abp/ng.theme.basic/testing";
  3. import { ThemeSharedTestingModule } from "@abp/ng.theme.shared/testing";
  4. import { ComponentFixture } from "@angular/core/testing";
  5. import {
  6. NgbCollapseModule,
  7. NgbDatepickerModule,
  8. NgbDropdownModule,
  9. } from "@ng-bootstrap/ng-bootstrap";
  10. import { NgxValidateCoreModule } from "@ngx-validate/core";
  11. import { CountryService } from "@proxy/countries";
  12. import {
  13. findByText,
  14. getByLabelText,
  15. getByRole,
  16. getByText,
  17. queryByRole,
  18. render,
  19. screen,
  20. } from "@testing-library/angular";
  21. import userEvent from "@testing-library/user-event";
  22. import { BehaviorSubject, of } from "rxjs";
  23. import { CountryComponent } from "./country.component";
  24. const list$ = new BehaviorSubject({
  25. items: [{ id: "ID_US", name: "United States of America" }],
  26. totalCount: 1,
  27. });
  28. describe("Country", () => {
  29. let fixture: ComponentFixture<CountryComponent>;
  30. afterEach(() => clearPage(fixture));
  31. beforeEach(async () => {
  32. const result = await render(CountryComponent, {
  33. imports: [
  34. CoreTestingModule.withConfig(),
  35. ThemeSharedTestingModule.withConfig(),
  36. ThemeBasicTestingModule.withConfig(),
  37. NgxValidateCoreModule,
  38. NgbCollapseModule,
  39. NgbDatepickerModule,
  40. NgbDropdownModule,
  41. ],
  42. providers: [
  43. {
  44. provide: CountryService,
  45. useValue: {
  46. getList: () => list$,
  47. },
  48. },
  49. ],
  50. });
  51. fixture = result.fixture;
  52. });
  53. it("should display advanced filters", () => {
  54. const filters = screen.getByTestId("country-filters");
  55. const nameInput = getByLabelText(filters, /name/i) as HTMLInputElement;
  56. expect(nameInput.offsetWidth).toBe(0);
  57. const advancedFiltersBtn = screen.getByRole("link", { name: /advanced/i });
  58. userEvent.click(advancedFiltersBtn);
  59. expect(nameInput.offsetWidth).toBeGreaterThan(0);
  60. userEvent.type(nameInput, "fooo{backspace}");
  61. expect(nameInput.value).toBe("foo");
  62. userEvent.click(advancedFiltersBtn);
  63. expect(nameInput.offsetWidth).toBe(0);
  64. });
  65. it("should have a heading", () => {
  66. const heading = screen.getByRole("heading", { name: "Countries" });
  67. expect(heading).toBeTruthy();
  68. });
  69. it("should render list in table", async () => {
  70. const table = await screen.findByTestId("country-table");
  71. const name = getByText(table, "United States of America");
  72. expect(name).toBeTruthy();
  73. });
  74. it("should display edit modal", async () => {
  75. const actionsBtn = screen.queryByRole("button", { name: /actions/i });
  76. userEvent.click(actionsBtn);
  77. const editBtn = screen.getByRole("button", { name: /edit/i });
  78. userEvent.click(editBtn);
  79. await wait(fixture);
  80. const modal = screen.getByRole("dialog");
  81. const modalHeading = queryByRole(modal, "heading", { name: /edit/i });
  82. expect(modalHeading).toBeTruthy();
  83. const closeBtn = getByText(modal, "×");
  84. userEvent.click(closeBtn);
  85. await wait(fixture);
  86. expect(screen.queryByRole("dialog")).toBeFalsy();
  87. });
  88. it("should display create modal", async () => {
  89. const newBtn = screen.getByRole("button", { name: /new/i });
  90. userEvent.click(newBtn);
  91. await wait(fixture);
  92. const modal = screen.getByRole("dialog");
  93. const modalHeading = queryByRole(modal, "heading", { name: /new/i });
  94. expect(modalHeading).toBeTruthy();
  95. });
  96. it("should validate required name field", async () => {
  97. const newBtn = screen.getByRole("button", { name: /new/i });
  98. userEvent.click(newBtn);
  99. await wait(fixture);
  100. const modal = screen.getByRole("dialog");
  101. const nameInput = getByRole(modal, "textbox", {
  102. name: /^name/i,
  103. }) as HTMLInputElement;
  104. userEvent.type(nameInput, "x");
  105. userEvent.type(nameInput, "{backspace}");
  106. const nameError = await findByText(modal, /required/i);
  107. expect(nameError).toBeTruthy();
  108. });
  109. it("should delete a country", () => {
  110. const getSpy = spyOn(fixture.componentInstance.list, "get");
  111. const deleteSpy = jasmine.createSpy().and.returnValue(of(null));
  112. fixture.componentInstance.service.delete = deleteSpy;
  113. const actionsBtn = screen.queryByRole("button", { name: /actions/i });
  114. userEvent.click(actionsBtn);
  115. const deleteBtn = screen.getByRole("button", { name: /delete/i });
  116. userEvent.click(deleteBtn);
  117. const confirmText = screen.getByText("AreYouSure");
  118. expect(confirmText).toBeTruthy();
  119. const confirmBtn = screen.getByRole("button", { name: "Yes" });
  120. userEvent.click(confirmBtn);
  121. expect(deleteSpy).toHaveBeenCalledWith(list$.value.items[0].id);
  122. expect(getSpy).toHaveBeenCalledTimes(1);
  123. });
  124. });

CI配置

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

  1. // angular.json
  2. "test": {
  3. "builder": "@angular-devkit/build-angular:karma",
  4. "options": { /* several options here */ },
  5. "configurations": {
  6. "production": {
  7. "karmaConfig": "karma.conf.prod.js"
  8. }
  9. }
  10. }

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

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

  1. npm test -- --prod

另请参阅