使用 Angular 测试库进行良好的测试实践
由 Mux 主办的 DEV 全球展示挑战赛:展示你的项目!
Angular Testing Library它提供了一些实用函数,让我们能够像用户一样与 Angular 组件进行交互。这提高了测试的可维护性,让我们更有信心确保组件能够按预期运行,并且改善了可访问性,从而更好地服务于用户。除了这些好处之外,您还会发现以这种方式编写测试非常有趣。
这Angular Testing Library
Angular 测试库是@testing-library系列的一部分,该系列的核心是🦑 DOM 测试库
。 我们通过提供统一的 API,鼓励在多个框架和库中采用良好的测试实践。
这些测试可以使用您喜欢的任何测试运行器编写。
我们鼓励:
- 可维护的测试:我们不想测试实现细节。
- 对我们组件的信心:您与组件的交互方式与您的最终用户相同。
- 无障碍访问:我们希望组件具有包容性。
测试越能模拟软件的实际使用方式,就能给你带来越多的信心。
入门
首先,第一步是安装@testing-library/angular,之后就可以开始了。
npm install --save-dev @testing-library/angular
在这篇文章中,我们将通过编写反馈表单的测试来进行介绍,从非常简单的测试开始,并在此基础上不断扩展。
我们将要测试的表单包含一个必填的姓名栏、一个必填的评分栏(评分值必须在 0 到 10 之间)、一个摘要栏和一个用于选择 T 恤尺码的下拉框。表单当然少不了提交按钮,所以我们也把它添加进去。
表单的代码如下所示。
export class FeedbackComponent {
@Input() shirtSizes: string[] = []
@Output() submitForm = new EventEmitter<Feedback>()
form = this.formBuilder.group({
name: ['', [Validators.required]],
rating: ['', [Validators.required, Validators.min(0), Validators.max(10)]],
description: [''],
shirtSize: ['', [Validators.required]],
})
nameControl = this.form.get('name')
ratingControl = this.form.get('rating')
shirtSizeControl = this.form.get('shirtSize')
constructor(private formBuilder: FormBuilder) {}
submit() {
if (this.form.valid) {
this.submitForm.emit(this.form.value)
}
}
}
<form [formGroup]="form" (ngSubmit)="submit()">
<legend>Feedback form</legend>
<mat-form-field>
<mat-label>Name</mat-label>
<input matInput type="text" formControlName="name" />
<mat-error *ngIf="nameControl.hasError('required')">
Name is required
</mat-error>
</mat-form-field>
<mat-form-field>
<mat-label>Rating</mat-label>
<input matInput type="number" formControlName="rating" />
<mat-error *ngIf="ratingControl.hasError('required')">
Rating is required
</mat-error>
<mat-error
*ngIf="ratingControl.hasError('min') || ratingControl.hasError('max')"
>
Rating must be between 0 and 10
</mat-error>
</mat-form-field>
<mat-form-field>
<mat-label>Description</mat-label>
<textarea matInput formControlName="description"></textarea>
</mat-form-field>
<mat-form-field>
<mat-label>T-shirt size</mat-label>
<mat-select placeholder="Select" formControlName="shirtSize">
<mat-option *ngFor="let size of shirtSizes" [value]="size"
>{{ size }}</mat-option
>
</mat-select>
<mat-error *ngIf="shirtSizeControl.hasError('required')">
T-shirt size is required
</mat-error>
</mat-form-field>
<button type="submit" mat-stroked-button color="primary">
Submit your feedback
</button>
</form>
我们的第一次测试
为了测试反馈组件,我们必须能够渲染该组件,这可以通过使用该render函数来实现。
该render函数将待测组件作为第一个参数,并有一个可选的第二个参数,其中包含更多选项(详见[RenderOptions此处],我们稍后会介绍)。
import { render } from '@testing-library/angular'
it('should render the form' async () => {
const component = await render(FeedbackComponent)
})
测试一个简单组件的设置无需更多。
在这种情况下,它会抛出异常,因为我们使用了响应式表单和一些 Angular Material 组件。
要解决这个问题,我们必须提供缺失的两个模块。要提供这些模块,我们使用 ` imports<form>` 属性RenderOptions,类似于 `<form>`TestBed.configureTestingModule的工作方式。
it('should render the form', async () => {
const component = await render(FeedbackComponent, {
imports: [ReactiveFormsModule, MaterialModule],
})
})
现在,这个测试有效了。
查询
该render函数返回一个RenderResult包含用于测试组件的实用函数的对象。
你会注意到,我们测试组件的方式与最终用户测试组件的方式类似。
我们不测试实现细节,而是Angular Testing Library提供一个 API,让我们能够通过组件的 DOM 节点从外部测试组件。
为了验证最终用户看到的节点,我们使用查询,这些查询在渲染的组件上可用。
查询会在组件中查找给定的文本(以 a
string或 b 的RegExp形式),该组件是查询的第一个参数。可选的第二个参数是TextMatch。
在我们的测试中,为了验证表单是否以正确的标题呈现,我们可以使用查询getByText。
it('should render the form', async () => {
const component = await render(FeedbackComponent, {
imports: [ReactiveFormsModule, MaterialModule],
})
component.getByText(/Feedback form/i)
})
在上面的代码片段中,你没有看到断言。这是因为当查询无法在文档中找到给定的文本时,getBy`and`查询会抛出错误。如果我们不想抛出错误,可以使用 ` and`查询。getAllByAngular Testing LibraryqueryByqueryAllBy
错误信息将以语法高亮显示的方式打印出组件的 DOM 元素。
设置@Input()和@Output()属性
组件渲染完成后,下一步是提供所需的@Input()属性@Output()。
我们可以使用 ` componentProperties<component>`来分配这些属性RenderOptions。
对于反馈组件,我们将 `<component>` 设置shirtSizes为一个 T 恤尺码集合,并将 `<component>` 设置submitForm为一个监听器。稍后,我们可以使用此监听器来验证表单提交。
it('form should display error messages and submit if valid', async () => {
const submitSpy = jasmine.createSpy('submit')
const component = await render(FeedbackComponent, {
imports: [ReactiveFormsModule, MaterialModule],
componentProperties: {
shirtSizes: ['XS', 'S', 'M', 'L', 'XL', 'XXL'],
submitForm: {
// because the output is an `EventEmitter` we must mock `emit`
// the component uses `output.emit` to interact with the parent component
emit: submitSpy,
} as any,
},
})
})
完成这一步后,该组件就可以开始编写测试了。
活动
到目前为止,我们已经了解了如何使用查询函数来断言已渲染的组件,但我们还需要一种方法来与这些组件进行交互。
我们可以通过触发事件来实现这一点。
与查询函数一样,这些事件也可以在已渲染的组件上使用。
如需查看所有支持的事件列表,请参阅源代码。本文仅涵盖测试反馈组件所需的事件,但所有事件都具有类似的 API。
事件的第一个参数始终是目标 DOM 节点,第二个参数(可选)用于提供事件的额外信息。例如,按下了哪个鼠标按钮,或者输入事件的文本。
detectChanges()值得注意的是:事件触发后会通过调用来运行变更检测周期。
点击元素
要点击某个元素,我们使用component.click()函数。
it('form should display error messages and submit if valid', async () => {
const submitSpy = jasmine.createSpy('submit')
const component = await render(FeedbackComponent, {
imports: [ReactiveFormsModule, MaterialModule],
componentProperties: {
shirtSizes: ['XS', 'S', 'M', 'L', 'XL', 'XXL'],
submitForm: {
emit: submitSpy,
} as any,
},
})
// first, get the submit button
const submit = component.getByText(/Submit your feedback/i)
// secondly, click on the button
component.click(submit)
expect(submitSpy).not.toHaveBeenCalled()
})
由于我们现在可以点击提交按钮,因此我们也可以验证该表单尚未提交,因为它目前无效。
我们可以使用第二个参数来触发右键单击:
component.click(submit, { button: 2 })
填写输入字段
为了使表单有效,我们必须能够填写所有字段。
我们可以通过使用各种事件来实现这一点。
it('form should display error messages and submit if valid', async () => {
const submitSpy = jasmine.createSpy('submit')
const component = await render(FeedbackComponent, {
imports: [ReactiveFormsModule, MaterialModule],
componentProperties: {
shirtSizes: ['XS', 'S', 'M', 'L', 'XL', 'XXL'],
submitForm: {
emit: submitSpy,
} as any,
},
})
// use queries to find form fields
const name = component.getByLabelText(/name/i)
const rating = component.getByLabelText(/rating/i)
const description = component.getByLabelText(/description/i)
const shirtSize = component.getByLabelText(/t-shirt size/i)
const submit = component.getByText(/submit your feedback/i)
const inputValues = {
name: 'Tim',
rating: 7,
description: 'I really like @testing-library ♥',
shirtSize: 'M',
}
component.click(submit)
expect(submitSpy).not.toHaveBeenCalled()
// fill in the name field with the `input` event
// the second argument sets the value of the target, similar to the JavaScript API
component.input(name, {
target: {
value: inputValues.name,
},
})
// an easier way to accomplish the same result is to use the `type` event
component.type(rating, inputValues.rating)
component.type(description, inputValues.description)
// to select a value from the select component, we first have to click on the selectbox before clicking on the select option
component.click(shirtSize)
// because the select options aren't rendered in the component, we have to look for them outside our component
// we use `getByText` (exported from `@testing-library/angular`) to search for our select option on the document's body
component.click(getByText(document.body, 'L'))
// an easier way to select options is to use the `selectOptions` event
component.selectOptions(shirtSize, inputValues.shirtSize)
component.click(submit)
// our form is valid, so now we can verify it has been called with the form value
expect(submitSpy).toHaveBeenCalledWith(inputValues)
})
和之前一样,我们可以使用查询语句获取表单字段。
这次我们通过字段对应的标签来获取,这样做的好处是可以创建易于访问的表单。
他们
getByLabelText也在queryByLabelText寻找aria-labels一种元素
填写表单字段有两种方法,第一种是通过input事件,第二种是通过type事件。
使用事件时,input我们只需触发JavaScriptinput事件来设置表单值;使用第二个参数时,我们将事件的值赋给测试用例中的值。事件type是一个新引入的(用户)事件(在 v7.2 版本中引入)。除了在表单字段中写入文本之外,type事件还会模拟最终用户在与表单交互以填写表单字段时执行的相同事件。这意味着它还会触发其他事件,例如keydown和keyup。
因为我们使用的是 Angular Material 的 select 元素,所以无法通过input事件设置其值。
因此,我们必须先点击 select 元素,然后才能通过点击选项来选择下拉列表项。
这里,我们可以使用selectOptionsv7.4 版本中引入的新的用户事件来选择 select 元素中的选项。
该selectOptions用户事件不仅适用于 Angular Material,也适用于原生 select 元素。selectOptions由于这是一个用户事件,因此它还会触发其他事件来模拟最终用户与 select 元素的交互。
如您所见,我们有两种不同类型的事件:JavaScript 事件和用户事件。
二者的区别在于,JavaScript 事件只会触发一个事件,而不会触发其他事件。
而用户事件则会触发多个事件,以模拟与组件交互时的真实行为。
目前,只有 `onCreate()`type和selectOptions`onCreate()` 被实现为用户事件。
无效控件
目前我们已经有了一个可以正常工作的反馈组件,但是如何测试验证消息呢?
我们已经了解了如何使用 `get_validate()` 函数验证渲染后的组件queries,也了解了如何通过触发事件与组件交互,这意味着我们已经掌握了测试反馈表单中无效控件的所有工具。
如果我们将输入字段清空,应该会看到一条验证信息。
信息如下所示。
component.type(name, '')
component.getByText('Name is required')
expect(name.getAttribute('aria-invalid')).toBe('true')
component.type(name, 'Bob')
expect(component.queryByText('Name is required')).toBeNull()
expect(name.getAttribute('aria-invalid')).toBe('false')
component.type(rating, 15)
component.queryByText('Rating must be between 0 and 10')
expect(rating.getAttribute('aria-invalid')).toBe('true')
component.type(rating, inputValues.rating)
expect(rating.getAttribute('aria-invalid')).toBe('false')
因为查询返回的是 DOM 节点,所以我们可以使用 DOM 节点来验证控件是否有效。
使用容器和组件
当前的测试涵盖了我们的反馈组件,它只是一个组件而已。
在某些情况下,这样做或许是好事,但更多时候,我认为这些测试毫无价值。我更倾向于
测试容器组件。因为一个容器由一个或多个组件构成,所以这些组件也会在容器测试过程中被测试到。否则,你通常会重复执行相同的测试两次,维护工作量也会翻倍。
为了简化操作,我们直接将表单组件包裹在一个容器中。容器内注入了一个服务来提供T恤尺码信息,该服务还包含一个提交函数。
@Component({
selector: 'feedback-container',
template: `
<feedback-form
[shirtSizes]="service.shirtSizes$ | async"
(submitForm)="service.submit($event)"
></feedback-form>
`,
})
export class FeedbackContainer {
constructor(public service: FeedbackService) {}
}
在测试中FeedbackContainer,我们现在需要声明FeedbackComponent并提供一个存根版本FeedbackService。
为此,我们可以使用与类似的 API,TestBed.configureTestingModule并使用上的declarations和属性。providersRenderOptions
除了设置之外,我们的测试看起来都一样。
在下面的测试中,我选择用更简洁的方式编写测试,我发现这种方式对于较大的表单非常有用。
it('form should display error messages and submit if valid (container)', async () => {
const submitSpy = jasmine.createSpy('submit')
const component = await render(FeedbackContainer, {
declarations: [FeedbackComponent],
imports: [ReactiveFormsModule, MaterialModule],
providers: [
{
provide: FeedbackService,
useValue: {
shirtSizes$: of(['XS', 'S', 'M', 'L', 'XL', 'XXL']),
submit: submitSpy,
},
},
],
})
const submit = component.getByText('Submit your feedback')
const inputValues = [
{ value: 'Tim', label: /name/i, name: 'name' },
{ value: 7, label: /rating/i, name: 'rating' },
{
value: 'I really like @testing-library ♥',
label: /description/i,
name: 'description',
},
{ value: 'M', label: /T-shirt size/i, name: 'shirtSize' },
]
inputValues.forEach(({ value, label }) => {
const control = component.getByLabelText(label)
if (control.tagName === 'MAT-SELECT') {
component.selectOptions(control, value as string)
} else {
component.type(control, value)
}
})
component.click(submit)
expect(submitSpy).toHaveBeenCalledWith(
inputValues.reduce((form, { value, name }) => {
form[name] = value
return form
}, {}),
)
})
编写测试的几个技巧
使用 Cypress 进行端到端测试🐅 Cypress Testing Library
因为它隶属于@testing-library,所以在使用 Cypress 时可以使用类似的 API。
该库导出了DOM Testing Library与 Cypress 命令相同的实用函数。
更多信息和示例可在@testing-library/cypress找到。
用于@testing-library/jest-dom使断言更易于人类阅读。
这仅适用于使用 Jest 作为测试运行器的情况。
该库提供了一些实用的 Jest 匹配器,例如 `#include` toBeValid()、toBeVisible()`#include` toHaveFormValues()、`#include` 等。
更多信息和示例可在@testing-library/jest-dom找到。
宁愿只做一次测试,也不愿做多次测试。
正如您在本文中使用的代码片段所看到的,它们都属于同一个测试。
这违背了一个常见的原则,即一个测试中应该只有一个断言。
我通常会在一个测试中使用一个调度语句(arrange)和多个执行语句(act)和断言语句(assert)。
“设想一下手动测试人员的测试用例工作流程,并尝试让你的每个测试用例都包含该工作流程的所有部分。这通常会导致多个操作和断言,这没关系。”
有关此做法的更多信息,请参阅Kent C. Dodds的文章《编写更少、更长的测试》。
不要使用beforeEach
beforeEach在某些测试用例中,使用 `__init__`可能很有用,但在大多数情况下,我更喜欢使用简单的setup函数。
我觉得它更易读,而且如果你想在不同的测试中使用不同的设置,它也更灵活,例如:
it('should show the dashboard for an admin', () => {
const { component, handleClick } = setup({ name: 'Tim', roles: ['admin'] })
...
})
it('should show the dashboard for an employee', () => {
const { component, handleClick } = setup({ name: 'Alicia', roles: ['employee'] })
...
})
function setup(user, handleClick = jest.fn()) {
const component = render(DashboardComponent, {
componentProperties: {
user,
handleClick,
},
})
return {
component,
handleClick,
}
}
示例代码
本文中的代码可在GitHub上找到。
一旦你了解了queries如何触发事件,就可以开始测试你的组件了。
本文中的测试用例与其他测试用例的唯一区别在于如何使用函数来设置组件,你可以在代码仓库render中找到更多示例Angular Testing Library。
- 单组分
- 嵌套组件
@Input()和@Output()- 形式
- 用棱角材料成型
- 带有提供程序的组件
- 使用 NgRx
- 使用 NgRx
MockStore - 测试指令
- 路由导航
- 注入令牌作为依赖项
- 如果您要查找的内容不在此列表中,请创建一个问题。