mirror of https://github.com/abpframework/abp.git
committed by
GitHub
1 changed files with 380 additions and 0 deletions
@ -0,0 +1,380 @@ |
|||||
|
# 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<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测试库](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<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(); |
||||
|
}); |
||||
|
}); |
||||
|
``` |
||||
|
|
||||
|
正如你所见, 二者非常相似. 当我们使用查询和触发事件时, 真正的区别就显现出来了. |
||||
|
|
||||
|
```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<MyComponent>; |
||||
|
|
||||
|
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<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_ 文件, 或者如下所示添加一个: |
||||
|
|
||||
|
```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) |
||||
Loading…
Reference in new issue