测试复杂表单
学习目标
-
在组件测试中填写和提交表单
-
测试同步和异步字段验证和错误消息
-
测试动态表单逻辑
-
使用工具确保表单对所有人都是可访问的
表单是大型 Web 应用程序的核心。特别是企业应用程序围绕通过表单输入和编辑数据展开。因此,实现复杂表单是 Angular 框架的一个重要功能。
我们已经学习了如何在测试计数器组件时 填写表单字段。在此过程中,我们开发了 setFieldValue 测试辅助函数。
我们处理的简单表单旨在输入一个值。我们通过填写字段并提交表单来进行测试。现在我们将看一个更复杂的示例。
注册表单 |
我们将引入和测试一个虚构在线服务的注册表单。
注册表单的功能包括:
-
不同类型的输入字段:文本、单选按钮、复选框、下拉框
-
同步和异步验证器的字段验证
-
可访问的表单结构、字段标签和错误消息
-
字段之间的动态关联
该表单包含四个部分:
-
计划选择:“个人”、“商业”或“教育及非营利”
-
登录凭据:用户名、电子邮件和密码
-
账单地址
-
服务条款和提交按钮
非实用性 |
请注意,此表单仅用于演示目的。虽然它遵循了验证和可访问性的最佳实践,但从设计和用户体验的角度来看,它并不实用。其中包括,对于新用户来说过于复杂。
客户端和服务器 |
与其他示例存储库不同,这个示例被分成了一个 客户端
目录和一个 服务器
目录:
同样,Node.js 服务仅用于演示目的。该服务将创建的用户帐户保存在内存中,并在停止时丢弃。请勿在生产环境中使用它。
尽管注册表单只有12个表单控件,并不特别大。但是,我们将要探索一些微妙的细节。
注册表单组件
表单逻辑位于 SignupFormComponent
中。该组件依赖于 SignupService
与后端服务进行通信。
您可能记得,在 Angular 中,有两种基本的表单方法:模板驱动表单和响应式表单。
虽然这两种方法在实践中看起来非常不同,但它们基于相同的基本概念:表单组(FormGroup
对象)和表单控件(FormControl
对象)。
响应式表单 |
SignupFormComponent
是一个响应式表单,它在组件类中显式创建组和控件。这样,更容易指定自定义验证器并设置动态字段关联。
与其他 Angular 核心概念一样,本指南假定您对响应式表单有基本的了解。请参考 官方的响应式表单指南以加深您的知识。
SignupFormComponent
类的重要部分如下所示:
@Component({
selector: 'app-signup-form',
templateUrl: './signup-form.component.html',
styleUrls: ['./signup-form.component.scss'],
})
export class SignupFormComponent {
/* … */
public form = this.formBuilder.group({
plan: ['personal', required],
username: [
null,
[required, pattern('[a-zA-Z0-9.]+'), maxLength(50)],
(control: AbstractControl) =>
this.validateUsername(control.value),
],
email: [
null,
[required, email, maxLength(100)],
(control: AbstractControl) =>
this.validateEmail(control.value),
],
password: [
null,
required,
() => this.validatePassword()
],
tos: [null, requiredTrue],
address: this.formBuilder.group({
name: [null, required],
addressLine1: [null],
addressLine2: [null, required],
city: [null, required],
postcode: [null, required],
region: [null],
country: [null, required],
}),
});
/* … */
constructor(
private signupService: SignupService,
private formBuilder: FormBuilder) {
/* … */
}
/* … */
}
Form groups and controls |
使用Angular的 FormBuilder,我们创建了form
属性,即最顶层的表单组。内部有另一个用于地址相关字段的表单组。
表单控件通过指定其初始值和验证器进行声明。例如,密码控件:
password: [
// The initial value (null means empty)
null,
// The synchronous validator
required,
// The asynchronous validator
() => this.validatePassword()
],
SignupFormComponent
模板 使用formGroup
、formGroupName
和formControlName
指令分别将元素与表单组或控件关联起来。
只有一个控件的简化表单结构如下所示:
<form [formGroup]="form">
<fieldset formGroupName="address">
<label>
Full name
<input type="text" formControlName="name" />
</label>
</fieldset>
</form>
表单提交 |
当表单填写正确且所有验证通过时,用户可以提交表单。它会生成一个由 SignupData
接口描述的对象:
export interface SignupData {
plan: Plan;
username: string;
email: string;
password: string;
tos: true;
address: {
name: string;
addressLine1?: string;
addressLine2: string;
city: string;
postcode: string;
region?: string;
country: string;
};
}
Plan
是一个字符串的联合类型:
export type Plan = 'personal' | 'business' | 'non-profit';
SignupService的
signup方法接受
SignupData``并将其发送到服务器。出于安全原因,服务器再次验证数据。但在本指南中,我们将专注于前端。
表单验证和错误
同步验证器 |
几个表单控件具有同步验证器。required
、email
、maxLength
、pattern
等都是Angular提供的内置同步验证器:
import { Validators } from '@angular/forms';
const {
email, maxLength, pattern, required, requiredTrue
} = Validators;
这些验证器接受控件值,大多数情况下是一个字符串,并返回一个包含可能的错误消息的ValidationErrors
对象。验证在客户端同步进行。
异步验证器 |
对于用户名、电子邮件和密码,存在自定义的异步验证器。它们检查用户名和电子邮件是否可用,以及密码是否足够强大。
异步验证器使用 SignupService
与后端服务进行通信。这些HTTP请求使验证变成了异步操作。
错误呈现 |
当验证器返回任何错误时,相应的错误消息会显示在表单控件下方。这个重复的任务被外包给另一个组件。
invalid && (touched || dirty) |
当表单控件无效且被触摸或被修改时,https://github.com/molily/angular-form-testing/tree/main/client/src/app/components/control-errors[ControlErrorsComponent
] 会显示错误。
-
touched表示用户已经聚焦到控件上,但又失去了焦点(触发了
blur
事件)。 -
dirty表示用户已经修改了值。
例如,对于 name
控件,input
元素和ControlErrorsComponent
之间的交互如下所示:
<label>
Full name
<input
type="text"
formControlName="name"
aria-required="true"
appErrorMessage="name-errors"
/>
</label>
<!-- … -->
<app-control-errors controlName="name" id="name-errors">
<ng-template let-errors>
<ng-container *ngIf="errors.required">
Name must be given.
</ng-container>
</ng-template>
</app-control-errors>
ARIA 属性 |
appErrorMessage
属性会激活 ErrorMessageDirective
。当表单控件无效且被触摸或被修改时,该指令会添加aria-invalid
和aria-errormessage
属性。
aria-invalid
将控件标记为辅助技术(如屏幕阅读器)中的无效控件。aria-errormessage
指向包含错误消息的另一个元素。
将控件与错误信息关联起来 |
在出现错误时,该指令将aria-errormessage
设置为相应的app-control-errors
元素的id。在上面的示例中,该id为name-errors
。这样,屏幕阅读器用户可以快速找到关联的错误消息。
控件特定的错误消息仍然位于signup-form.component.html
中。它们作为ng-template
传递给ControlErrorsComponent
。ControlErrorsComponent
动态地渲染模板,并将errors
对象作为变量传递进去:
<ng-template let-errors>
<ng-container *ngIf="errors.required">
Name must be given.
</ng-container>
</ng-template>
您不需要理解这个特定实现的细节。在注册表单中,解决方案只是显示错误、避免重复并为可访问性设置ARIA属性的可能性之一。
从用户和测试的角度来看,实现错误消息的渲染方式并不重要——只要它们存在并且可访问即可。
实现细节 |
我们将在黑盒集成测试中测试SignupFormComponent
与ControlErrorsComponent
和ErrorMessageDirective
的配合使用。对于这个测试,后两者将是无关紧要的实现细节。
测试设置
在 signup-form.component.spec.ts
中编写各个规范之前,我们需要设置测试套件。让我们从测试模块配置开始。
await TestBed.configureTestingModule({
imports: [ReactiveFormsModule],
declarations: [
SignupFormComponent,
ControlErrorsComponent,
ErrorMessageDirective
],
providers: [
{ provide: SignupService, useValue: signupService }
],
}).compileComponents();
被测试的组件包含一个响应式表单。这就是为什么我们导入了ReactiveFormsModule
:
imports: [ReactiveFormsModule],
深度渲染 |
如描述的那样,我们正在编写一个集成测试,所以我们声明组件及其子组件:
declarations: [
SignupFormComponent,
ControlErrorsComponent,
ErrorMessageDirective
],
虚拟服务 |
SignupFormComponent
依赖于SignupService
。当测试运行时,我们不希望向后端发送HTTP请求,因此我们 将该服务替换为一个虚拟实例。
providers: [
{ provide: SignupService, useValue: signupService }
],
一个可能的SignupService
虚拟实例如下所示:
const signupService:
Pick<SignupService, keyof SignupService> = {
isUsernameTaken() {
return of(false);
},
isEmailTaken() {
return of(false);
},
getPasswordStrength() {
return of(strongPassword);
},
signup() {
return of({ success: true });
},
};
这个虚拟实例实现了成功的情况:用户名和电子邮件可用,密码足够强大,并且表单提交成功。
由于我们还将测试其他错误情况,我们需要动态创建SignupService
的虚拟实例。此外,我们还需要使用Jasmine的spy来验证Service方法是否被正确调用。
createSpyObj
|
这就是Jasmine的createSpyObj
的用途(参见 虚拟服务依赖)。
const signupService = jasmine.createSpyObj<SignupService>(
'SignupService',
{
// Successful responses per default
isUsernameTaken: of(false),
isEmailTaken: of(false),
getPasswordStrength: of(strongPassword),
signup: of({ success: true }),
}
);
设置函数 |
与测试模块配置一起,我们将这段代码放入一个设置函数中。为了调整SignupService
虚拟实例的行为,我们允许传递方法的返回值。
describe('SignupFormComponent', () => {
let fixture: ComponentFixture<SignupFormComponent>;
let signupService: jasmine.SpyObj<SignupService>;
const setup = async (
signupServiceReturnValues?:
jasmine.SpyObjMethodNames<SignupService>,
) => {
signupService = jasmine.createSpyObj<SignupService>(
'SignupService',
{
// Successful responses per default
isUsernameTaken: of(false),
isEmailTaken: of(false),
getPasswordStrength: of(strongPassword),
signup: of({ success: true }),
// Overwrite with given return values
...signupServiceReturnValues,
}
);
await TestBed.configureTestingModule({
imports: [ReactiveFormsModule],
declarations: [
SignupFormComponent,
ControlErrorsComponent,
ErrorMessageDirective
],
providers: [
{ provide: SignupService, useValue: signupService }
],
}).compileComponents();
fixture = TestBed.createComponent(SignupFormComponent);
fixture.detectChanges();
};
/* … */
});
在所有接下来的规范中,我们将首先调用 setup
函数。如果我们简单地编写async setup()
,则SignupService
虚拟实例将返回成功的响应。
我们可以传递一个带有不同返回值的对象来模拟失败。例如,在测试用户名已被使用时:
await setup({
// Let the API return that the username is taken
isUsernameTaken: of(true),
});
这样的 setup
函数只是创建伪造数据并避免重复的一种方式。您可以提出其他解决方案,以达到相同的目的。
成功提交表单
我们需要测试的第一个情况是成功提交表单。如果用户填写了所有必填字段并且验证通过,我们期望该组件调用SignupService
的signup
方法,并将输入的表单数据作为参数。
测试数据 |
第一步是定义有效的测试数据,我们可以填充到表单中。我们将其放在一个单独的文件中,https://github.com/molily/angular-form-testing/blob/main/client/src/app/spec-helpers/signup-data.spec-helper.ts[signup-data.spec-helper.ts]:
export const username = 'quickBrownFox';
export const password = 'dog lazy the over jumps fox brown quick the';
export const email = 'quick.brown.fox@example.org';
export const name = 'Mr. Fox';
export const addressLine1 = '';
export const addressLine2 = 'Under the Tree 1';
export const city = 'Farmtown';
export const postcode = '123456';
export const region = 'Upper South';
export const country = 'Luggnagg';
export const signupData: SignupData = {
plan: 'personal',
username,
email,
password,
address: {
name, addressLine1, addressLine2,
city, postcode, region, country
},
tos: true,
};
在 signup-form.component.html模板中,所有的字段元素都需要用测试ID标记,以便我们可以通过程序找到它们并输入值。
例如,用户名输入框使用测试ID username
,电子邮件输入框使用 email
,依此类推。
回到signup-form.component.spec.ts
中,我们创建一个新的规范(spec),并调用设置函数(setup function)。
it('submits the form successfully', async () => {
await setup();
/* … */
});
填写表单 |
接下来,我们用有效的值填写所有必填字段。由于我们需要在接下来的几个规范(spec)中都进行这样的操作,让我们创建一个可重复使用的函数。
const fillForm = () => {
setFieldValue(fixture, 'username', username);
setFieldValue(fixture, 'email', email);
setFieldValue(fixture, 'password', password);
setFieldValue(fixture, 'name', name);
setFieldValue(fixture, 'addressLine1', addressLine1);
setFieldValue(fixture, 'addressLine2', addressLine2);
setFieldValue(fixture, 'city', city);
setFieldValue(fixture, 'postcode', postcode);
setFieldValue(fixture, 'region', region);
setFieldValue(fixture, 'country', country);
checkField(fixture, 'tos', true);
};
fillForm
函数位于describe
的作用域内,因此它可以访问fixture
变量。它使用setFieldValue
和checkField
元素测试助手。
在规范(spec)中,我们调用fillForm
函数:
it('submits the form successfully', async () => {
await setup();
fillForm();
/* … */
});
接下来,让我们尝试立即提交表单。被测试的表单在form
元素上监听 ngSubmit
事件,这实际上是一个原生的submit
事件。
提交表单 |
我们通过其测试ID找到 form
元素,并模拟一个submit
事件(参见 触发事件处理程序)。
然后,我们期望signup
spy已经被调用,并使用输入的数据作为参数。
it('submits the form successfully', async () => {
await setup();
fillForm();
findEl(fixture, 'form').triggerEventHandler('submit', {});
expect(signupService.signup).toHaveBeenCalledWith(signupData);
});
如果我们运行这个规范(spec),我们会发现它失败了:
Expected spy SignupService.signup to have been called with:
[ Object({ plan: 'personal', … }) ]
but it was never called.
规范(spec)失败是因为尽管我们正确填写了所有字段,但表单仍处于无效状态。
异步验证器 |
造成问题的是用户名、电子邮件和密码的异步验证器。当用户停止在这些字段中输入时,它们会等待一秒钟,然后向服务器发送请求。
在实际生产环境中,HTTP请求需要额外的时间,但我们的伪造SignupService
立即返回响应。
一秒的防抖 |
这种减少请求量的技术称为防抖(debouncing)。例如,输入用户名"fox"应该发送一个带有"fox"的请求,而不是连续发送三个带有"f"、"fo"、"fox"的请求。
上述规范在填写字段后立即提交表单。在这个时间点上,异步验证器已经被调用,但尚未返回值。它们仍在等待防抖期过去。
因此,测试需要等待一秒钟以等待异步验证器。一种简单的方法是编写一个使用 setTimeout(() ⇒ { /* … */}, 1000)
的异步测试。但这会减慢我们的规范(spec)的执行速度。
fakeAsync 和 tick
|
相反,我们将使用Angular的fakeAsync
和tick
函数来模拟时间的流逝。它们是测试异步行为的强大组合。
fakeAsync
冻结时间。它会钩入由定时器、间隔、Promises和Observables创建的异步任务的处理过程。它防止这些任务被执行。
模拟时间的流逝 |
在fakeAsync
创建的时间扭曲中,我们使用tick
函数来模拟时间的流逝。计划的任务将被执行,我们可以测试它们的效果。
fakeAsync
和tick
的特殊之处在于时间的流逝只是虚拟的。即使在模拟中经过一秒钟,规范仍然在几毫秒内完成。
fakeAsync
包装了规范函数,该函数由于 setup
调用而也是一个异步
函数。在填写表单后,我们使用 tick(1000)
来模拟等待的时间。
it('submits the form successfully', fakeAsync(async () => {
await setup();
fillForm();
// Wait for async validators
tick(1000);
findEl(fixture, 'form').triggerEventHandler('submit', {});
expect(signupService.signup).toHaveBeenCalledWith(signupData);
}));
这个规范(spec)通过了!现在我们应该添加一些期望来测试细节。
首先,我们期望异步验证器调用SignupService
的方法,并使用用户输入作为参数。这些方法是isUsernameTaken
、isEmailTaken
和getPasswordStrength
。
it('submits the form successfully', fakeAsync(async () => {
await setup();
fillForm();
// Wait for async validators
tick(1000);
findEl(fixture, 'form').triggerEventHandler('submit', {});
expect(signupService.isUsernameTaken).toHaveBeenCalledWith(username);
expect(signupService.isEmailTaken).toHaveBeenCalledWith(email);
expect(signupService.getPasswordStrength).toHaveBeenCalledWith(password);
expect(signupService.signup).toHaveBeenCalledWith(signupData);
}));
提交按钮 |
接下来,我们要确保提交按钮最初处于禁用状态。在成功验证后,按钮将启用。(提交按钮使用测试ID submit
。)
状态消息 |
此外,当表单成功提交时,需要出现状态消息"Sign-up successful!"。(状态消息使用测试ID status
。)
这将带我们进入最终的规范(spec):
it('submits the form successfully', fakeAsync(async () => {
await setup();
fillForm();
fixture.detectChanges();
expect(findEl(fixture, 'submit').properties.disabled).toBe(true);
// Wait for async validators
tick(1000);
fixture.detectChanges();
expect(findEl(fixture, 'submit').properties.disabled).toBe(false);
findEl(fixture, 'form').triggerEventHandler('submit', {});
fixture.detectChanges();
expectText(fixture, 'status', 'Sign-up successful!');
expect(signupService.isUsernameTaken).toHaveBeenCalledWith(username);
expect(signupService.isEmailTaken).toHaveBeenCalledWith(email);
expect(signupService.getPasswordStrength).toHaveBeenCalledWith(password);
expect(signupService.signup).toHaveBeenCalledWith(signupData);
}));
因为我们正在测试DOM的变化,所以我们必须在每个 Act 阶段之后调用detectChanges
。
无效的表单
现在我们已经测试了成功提交表单的情况,让我们来检查无效表单的处理。如果我们没有填写任何字段,但提交表单会发生什么?
我们为这种情况创建一个新的规范(spec):
it('does not submit an invalid form', fakeAsync(async () => {
await setup();
// Wait for async validators
tick(1000);
findEl(fixture, 'form').triggerEventHandler('submit', {});
expect(signupService.isUsernameTaken).not.toHaveBeenCalled();
expect(signupService.isEmailTaken).not.toHaveBeenCalled();
expect(signupService.getPasswordStrength).not.toHaveBeenCalled();
expect(signupService.signup).not.toHaveBeenCalled();
}));
这个规范(spec)比之前的规范要简单。我们等待一秒钟,然后在不输入数据的情况下提交表单。最后,我们期望没有调用任何SignupService
方法。
表单提交失败
我们已经测试了成功提交表单的情况。现在让我们测试表单提交失败的情况。
失败原因 |
尽管输入正确,提交可能因为以下几个原因而失败:
-
网络不可用
-
后端处理了请求,但返回了错误:
-
服务器端验证失败
-
请求的结构与预期不符
-
服务器代码有错误、崩溃或冻结
Observable |
当用户提交表单时,被测试的组件调用SignupService
的signup
方法。
-
在成功情况下,
signup
方法返回一个发出值为{ success: true }
的Observable,并完成。表单显示状态消息"Sign-up successful!"。 -
在错误情况下,Observable会失败并抛出一个错误。表单显示状态消息"Sign-up error"。
让我们在一个新的规范(spec)中测试后一种情况。其结构类似于成功提交的规范。但我们配置伪造的signup
方法返回一个失败的Observable。
import { throwError } from 'rxjs';
it('handles signup failure', fakeAsync(async () => {
await setup({
// Let the API report a failure
signup: throwError(new Error('Validation failed')),
});
/* … */
});
我们填写表单,等待验证器完成,然后提交表单。
fillForm();
// Wait for async validators
tick(1000);
findEl(fixture, 'form').triggerEventHandler('submit', {});
fixture.detectChanges();
状态消息 |
最后,我们期望出现"Sign-up error"的状态消息。此外,我们还验证相关的SignupService
方法是否已被调用。
expectText(fixture, 'status', 'Sign-up error');
expect(signupService.isUsernameTaken).toHaveBeenCalledWith(username);
expect(signupService.getPasswordStrength).toHaveBeenCalledWith(password);
expect(signupService.signup).toHaveBeenCalledWith(signupData);
完整的规范(spec)如下:
it('handles signup failure', fakeAsync(async () => {
await setup({
// Let the API report a failure
signup: throwError(new Error('Validation failed')),
});
fillForm();
// Wait for async validators
tick(1000);
findEl(fixture, 'form').triggerEventHandler('submit', {});
fixture.detectChanges();
expectText(fixture, 'status', 'Sign-up error');
expect(signupService.isUsernameTaken).toHaveBeenCalledWith(username);
expect(signupService.getPasswordStrength).toHaveBeenCalledWith(password);
expect(signupService.signup).toHaveBeenCalledWith(signupData);
}));
必填字段
一个关键的表单逻辑是某些字段是必填的,并且用户界面清楚地传达了这个事实。让我们编写一个规范(spec)来检查是否将必填字段标记为必填。
要求 |
要求如下:
-
必填字段具有
aria-required
属性。 -
必填但无效的字段具有
aria-errormessage
属性。它包含另一个元素的id。 -
该元素包含错误消息 “… must be given”(“Terms of Services”复选框的文本为“Please accept the Terms and Services”)。
我们的规范(spec)需要验证所有必填字段,因此我们编制了一个它们各自的测试ID列表:
const requiredFields = [
'username',
'email',
'name',
'addressLine2',
'city',
'postcode',
'country',
'tos',
];
invalid && (touched || dirty) |
在检查字段之前,我们需要触发表单错误的显示。如 表单验证和错误 中所述,当字段无效且已触摸(touched)或已修改(dirty)时,错误消息会显示出来。
幸运的是,空的但是必填的字段已经是无效的。输入文本会使它们变为dirty,但也是有效的。
标记为已触摸(touched) |
因此,我们需要触摸(touch)这些字段。如果一个字段被聚焦并再次失去焦点,Angular将认为它已被触摸(touched)。在幕后,Angular会监听blur事件。
在我们的规范(spec)中,我们使用dispatchFakeEvent
测试助手来模拟blur
事件。让我们将调用放在一个可重用的函数中:
const markFieldAsTouched = (element: DebugElement) => {
dispatchFakeEvent(element.nativeElement, 'blur');
};
我们现在可以编写规范(spec)的 安排(Arrange) 和 执行(Act)阶段:
it('marks fields as required', async () => {
await setup();
// Mark required fields as touched
requiredFields.forEach((testId) => {
markFieldAsTouched(findEl(fixture, testId));
});
fixture.detectChanges();
/* … */
});
一个forEach
循环遍历必填字段的测试ID,找到相应的元素并将字段标记为已触摸。然后我们调用detectChanges
,以便错误消息出现。
aria-required
|
接下来是 断言(Assert) 阶段。再次遍历必填字段,逐个检查它们。让我们从aria-required
属性开始。
requiredFields.forEach((testId) => {
const el = findEl(fixture, testId);
// Check aria-required attribute
expect(el.attributes['aria-required']).toBe(
'true',
`${testId} must be marked as aria-required`,
);
/* … */
});
findEl
函数返回一个具有attributes
属性的DebugElement
。该属性包含模板设置的所有属性。我们期望 aria-required="true"
属性存在。
aria-errormessage
|
下一部分测试错误消息,包含三个步骤:
-
读取
aria-errormessage
属性。期望它被设置。 -
找到
aria-errormessage
引用的元素。期望它存在。 -
读取文本内容。期望得到一个错误消息。
步骤1如下所示:
// Check aria-errormessage attribute
const errormessageId = el.attributes['aria-errormessage'];
if (!errormessageId) {
throw new Error(`Error message id for ${testId} not present`);
}
通常情况下,我们会使用Jasmine的expectation,如 expect(errormessageId).toBeDefined()
。但是,errormessageId
的类型是string | null
,而我们需要一个 string
类型的值来进行后续的操作。
类型断言 |
我们需要一个TypeScript的类型断言,将null
的情况排除,并将类型缩小为 string
类型。如果该属性不存在或为空,我们会抛出异常。这将使测试失败,并显示给定的错误信息,并确保errormessageId
在规范(spec)的其余部分是一个字符串类型。
第2步找到错误消息元素:
// Check element with error message
const errormessageEl = document.getElementById(errormessageId);
if (!errormessageEl) {
throw new Error(`Error message element for ${testId} not found`);
}
我们使用原生的DOM方法document.getElementById
来找到元素。errormessageEl
的类型是HTMLElement | null
,所以我们排除了null
的情况,以便使用errormessageEl
。
错误消息 |
最后,我们确保该元素包含一个错误消息,并对“Terms and Services”消息进行特殊处理。
if (errormessageId === 'tos-errors') {
expect(errormessageEl.textContent).toContain(
'Please accept the Terms and Services',
);
} else {
expect(errormessageEl.textContent).toContain('must be given');
}
完整的规范(spec)如下所示:
it('marks fields as required', async () => {
await setup();
// Mark required fields as touched
requiredFields.forEach((testId) => {
markFieldAsTouched(findEl(fixture, testId));
});
fixture.detectChanges();
requiredFields.forEach((testId) => {
const el = findEl(fixture, testId);
// Check aria-required attribute
expect(el.attributes['aria-required']).toBe(
'true',
`${testId} must be marked as aria-required`,
);
// Check aria-errormessage attribute
const errormessageId = el.attributes['aria-errormessage'];
if (!errormessageId) {
throw new Error(
`Error message id for ${testId} not present`
);
}
// Check element with error message
const errormessageEl = document.getElementById(errormessageId);
if (!errormessageEl) {
throw new Error(
`Error message element for ${testId} not found`
);
}
if (errormessageId === 'tos-errors') {
expect(errormessageEl.textContent).toContain(
'Please accept the Terms and Services',
);
} else {
expect(errormessageEl.textContent).toContain('must be given');
}
});
});
异步验证器
注册表单中的用户名、电子邮件和密码都有异步验证器。它们是异步的,因为它们等待一秒钟并进行HTTP请求。在底层,它们使用RxJS Observables来实现。
异步验证失败 |
我们已经覆盖了“正常路径”,其中输入的用户名和电子邮件都可用,密码也足够强大。我们需要为错误情况编写三个规范(spec):用户名或电子邮件已被使用,以及密码太弱。
这些验证器调用了SignupService
的方法。默认情况下,SignupService
的fake返回成功的响应。
const setup = async (
signupServiceReturnValues?:
jasmine.SpyObjMethodNames<SignupService>,
) => {
signupService = jasmine.createSpyObj<SignupService>(
'SignupService',
{
// Successful responses per default
isUsernameTaken: of(false),
isEmailTaken: of(false),
getPasswordStrength: of(strongPassword),
signup: of({ success: true }),
// Overwrite with given return values
...signupServiceReturnValues,
}
);
/* … */
};
setup
函数允许我们覆盖虚拟行为。它接受一个包含SignupService
方法返回值的对象。
我们添加三个规范(spec),以相应地配置虚拟行为:
it('fails if the username is taken', fakeAsync(async () => {
await setup({
// Let the API return that the username is taken
isUsernameTaken: of(true),
});
/* … */
}));
it('fails if the email is taken', fakeAsync(async () => {
await setup({
// Let the API return that the email is taken
isEmailTaken: of(true),
});
/* … */
}));
it('fails if the password is too weak', fakeAsync(async () => {
await setup({
// Let the API return that the password is weak
getPasswordStrength: of(weakPassword),
});
/* … */
}));
其余的部分对于所有三个规范(spec)都是相同的。下面是第一个规范(spec):
it('fails if the username is taken', fakeAsync(async () => {
await setup({
// Let the API return that the username is taken
isUsernameTaken: of(true),
});
fillForm();
// Wait for async validators
tick(1000);
fixture.detectChanges();
expect(findEl(fixture, 'submit').properties.disabled).toBe(true);
findEl(fixture, 'form').triggerEventHandler('submit', {});
expect(signupService.isUsernameTaken).toHaveBeenCalledWith(username);
expect(signupService.isEmailTaken).toHaveBeenCalledWith(email);
expect(signupService.getPasswordStrength).toHaveBeenCalledWith(password);
expect(signupService.signup).not.toHaveBeenCalled();
}));
我们填写表单,等待异步验证器并尝试提交表单。
我们期望这三个异步验证器调用相应的SignupService
方法。
用户名验证失败,所以我们期望组件阻止表单提交。signup
方法不应该被调用。
如上所述,另外两个规范(spec) it('fails if the email is taken', /* … /)
和 it('fails if the password is too weak', / … */)
除了虚拟设置之外,看起来是相同的。
动态字段关系
注册表单有一组固定的字段。但是addressLine1
字段取决于plan
字段的值:
-
如果选择的计划是“个人”,则该字段是可选的,标签显示为“地址行1”。
-
如果选择的计划是“商业”,则该字段是必需的,标签显示为“公司”。
-
如果选择的计划是“教育与非营利”,则该字段是必需的,标签显示为“组织”。
在组件类中的实现如下所示:
this.plan.valueChanges.subscribe((plan: Plan) => {
if (plan !== this.PERSONAL) {
this.addressLine1.setValidators(required);
} else {
this.addressLine1.setValidators(null);
}
this.addressLine1.updateValueAndValidity();
});
我们监听plan
控件的值变化。根据选择,我们要么给addressLine1
控件添加 必需(required)
验证器,要么移除所有验证器。
最后,我们需要告诉Angular重新验证字段值,现在验证器已经改变。
让我们编写一个规范(spec)来确保对于某些计划,addressLine1
是必需的。
it('requires address line 1 for business and non-profit plans', async () => {
await setup();
/* … */
});
首先,我们需要检查初始状态:选择了“个人”计划,addressLine1
是可选的。
我们通过查看addressLine1
字段元素的属性来进行检查:ng-invalid
类和aria-required
属性必须不存在。
// Initial state (personal plan)
const addressLine1El = findEl(fixture, 'addressLine1');
expect('ng-invalid' in addressLine1El.classes).toBe(false);
expect('aria-required' in addressLine1El.attributes).toBe(false);
从这个基线出发,让我们将计划从“个人”更改为“商业”。我们使用checkField
规范助手来激活相应的单选按钮。
// Change plan to business
checkField(fixture, 'plan-business', true);
fixture.detectChanges();
为了看到更改的效果,我们需要告诉Angular更新DOM。然后我们期望ng-invalid
类和aria-required
存在。
expect(addressLine1El.attributes['aria-required']).toBe('true');
expect(addressLine1El.classes['ng-invalid']).toBe(true);
我们对“教育和非营利”计划执行相同的检查。
// Change plan to non-profit
checkField(fixture, 'plan-non-profit', true);
fixture.detectChanges();
expect(addressLine1El.attributes['aria-required']).toBe('true');
expect(addressLine1El.classes['ng-invalid']).toBe(true);
就是这样!这就是完整的规格说明:
it('requires address line 1 for business and non-profit plans', async () => {
await setup();
// Initial state (personal plan)
const addressLine1El = findEl(fixture, 'addressLine1');
expect('ng-invalid' in addressLine1El.classes).toBe(false);
expect('aria-required' in addressLine1El.attributes).toBe(false);
// Change plan to business
checkField(fixture, 'plan-business', true);
fixture.detectChanges();
expect(addressLine1El.attributes['aria-required']).toBe('true');
expect(addressLine1El.classes['ng-invalid']).toBe(true);
// Change plan to non-profit
checkField(fixture, 'plan-non-profit', true);
fixture.detectChanges();
expect(addressLine1El.attributes['aria-required']).toBe('true');
expect(addressLine1El.classes['ng-invalid']).toBe(true);
});
当测试 必填字段时,我们已经检查了aria-required
属性的存在。为了一致性,我们在此规格中也检查aria-required
。
作为第二个指标,我们检查ng-invalid
类的存在。这个类是Angular自己在无效的表单字段上设置的,而不需要我们通过模板添加它。请注意,仅有该类的存在并不意味着无效状态已经以视觉方式传达。
或者,我们可以检查错误消息的存在,就像在必填字段规格中所做的那样。
密码类型切换
密码类型切换 注册表单的另一个小功能是密码类型切换器。此按钮切换输入密码的可见性。在幕后,它会将输入类型从 password
更改为 text
,反之亦然。
组件类将可见性存储在布尔属性中:
public showPassword = false;
在模板中,输入类型取决于属性(简化的代码):
<input [type]="showPassword ? 'text' : 'password'" />
最后,按钮切换布尔值(简化的代码):
<button
type="button"
(click)="showPassword = !showPassword"
>
{{ showPassword ? '🔒 Hide password' : '👁️ Show password' }}
</button>
为了测试这个特性,我们创建一个新的规范:
it('toggles the password display', async () => {
await setup();
/* … */
});
最初,该字段具有 password
类型,因此输入的文本被模糊化。让我们测试这个基线。
首先,我们在字段中输入密码。这并不是严格必要的,但可以使测试更加真实,也更容易进行调试。(密码字段具有测试 id password
。)
setFieldValue(fixture, 'password', 'top secret');
我们再次通过测试 id 查找输入元素,以检查其 type
属性。
const passwordEl = findEl(fixture, 'password');
expect(passwordEl.attributes.type).toBe('password');
现在我们第一次单击切换按钮。我们让 Angular 更新 DOM,并再次检查输入类型。
click(fixture, 'show-password');
fixture.detectChanges();
expect(passwordEl.attributes.type).toBe('text');
我们期望类型已经从 password
更改为 text
。密码现在可见。
通过第二次单击切换按钮,类型将切换回 password
。
click(fixture, 'show-password');
fixture.detectChanges();
expect(passwordEl.attributes.type).toBe('password');
这是整个规范:
it('toggles the password display', async () => {
await setup();
setFieldValue(fixture, 'password', 'top secret');
const passwordEl = findEl(fixture, 'password');
expect(passwordEl.attributes.type).toBe('password');
click(fixture, 'show-password');
fixture.detectChanges();
expect(passwordEl.attributes.type).toBe('text');
click(fixture, 'show-password');
fixture.detectChanges();
expect(passwordEl.attributes.type).toBe('password');
});
测试表单的可访问性
Web可访问性意味着所有人都可以使用网站,无论他们的身体或精神能力或网络访问技术如何。它是一个称为包容性设计的更大努力的一部分,这是创建考虑到具有不同能力和需求的人的信息系统的过程。
设计Web表单是一个可用性和可访问性的挑战。Web表单经常对残疾人和辅助技术用户构成障碍。
可访问表单 |
注册表单有几个可访问性特征,其中包括:
-
该表单结构良好,具有标题,
字段集(fieldset)
和图例(legend)
元素。 -
所有字段都有适当的标签,例如“用户名(必填)”。
-
某些字段有其他描述。这些描述与
aria-describedby
属性链接。 -
必填字段标有
aria-required="true"
。 -
无效字段标有
aria-invalid="true"
。错误消息与aria-errormessage
属性链接。 -
提交表单时,使用
role="status"
的状态消息通知结果。 -
结构和样式清楚地传达了当前焦点以及有效状态。
自动化可访问性测试 |
还有许多我们没有提到的可访问性要求和最佳实践。由于本指南的主要目的不是创建可访问的表单,因此让我们探讨如何以自动化方式测试可访问性。
我们已经在SignupFormComponent
的集成测试中测试了上述一些功能。让我们使用适当的工具测试可访问性,而不是手动编写更多的可访问性需求规范。
pa11y
在本指南中,我们将看看pa11y,这是一个Node.js程序,用于检查Web页面的可访问性。
在Chrome中进行测试 |
pa11y启动并远程控制Chrome或Chromium浏览器。浏览器导航到测试页面。然后,pa11y注入了axe-core和HTML CodeSniffer两个可访问性测试引擎。
这些引擎检查是否符合Web内容可访问性指南(WCAG),这是Web可访问性的权威技术标准。
CLI vs. CI |
pa11y有两种操作模式:针对一个Web页面的命令行界面(CLI)和针对多个Web页面的持续集成(CI)模式。
要快速测试您的Angular应用程序的单个页面,请使用命令行界面。定期测试整个应用程序时,请使用持续集成模式。
要使用命令行界面,请将pa11y安装为全局npm模块:
npm install -g pa11y
测试单个页面 |
这将安装全局命令pa11y
。要在本地Angular开发服务器上测试页面,请运行:
pa11y http://localhost:4200/
对于注册表单,pa11y没有报告任何错误:
Welcome to Pa11y
> Running Pa11y on URL http://localhost:4200/
No issues found!
错误报告 |
如果表单字段中有一个没有正确的标签,pa11y将会报错:
• Error: This textinput element does not have a name available to
an accessibility API. Valid names are: label element,
title undefined, aria-label undefined, aria-labelledby undefined.
├── WCAG2AA.Principle4.Guideline4_1.4_1_2.H91.InputText.Name
├── html > body > app-root > main > app-signup-form > form > fieldset:nth-child(2) > div:nth-child(2) > p > span > input
└── <input _ngcontent-srr-c42="" type="text" formcontrolname="username" …
• Error: This form field should be labelled in some way.
Use the label element (either with a "for" attribute or
wrapped around the form field), or "title", "aria-label"
or "aria-labelledby" attributes as appropriate.
├── WCAG2AA.Principle1.Guideline1_3.1_3_1.F68
├── html > body > app-root > main > app-signup-form > form > fieldset:nth-child(2) > div:nth-child(2) > p > span > input
└── <input _ngcontent-srr-c42="" type="text" formcontrolname="username" …
每个错误消息都包含了违反的WCAG规则、违反元素的DOM路径和HTML代码。
pa11y-ci
为了在开发和构建服务器上进行全面的测试运行,我们将在连续集成模式下设置pa11y。
在您的Angular项目目录中,安装pa11y-ci
包:
npm install pa11y-ci
pa11y-ci期望在项目目录中存在名为 .pa11yci
的配置文件。创建该文件并粘贴以下JSON内容:
{
"defaults": {
"runner": [
"axe",
"htmlcs"
]
},
"urls": [
"http://localhost:4200"
]
}
测试多个URL |
这个配置告诉pa11y检查URL http://localhost:4200,并使用可用的测试引擎axe
和htmlcs
。您可以将许多URL添加到urls
数组中。
现在我们可以使用以下命令运行pa11y-ci:
npx pa11y-ci
对于注册表单,我们得到以下输出:
Running Pa11y on 1 URLs:
> http://localhost:4200 - 0 errors
✔ 1/1 URLs passed
启动服务器并运行pa11y-ci
上述配置期望开发服务器已在http://localhost:4200上运行。在开发和构建服务器上都可以启动Angular服务器、运行可访问性测试,然后再停止服务器,这非常有用。
我们可以使用另一个方便的Node.js包start-server-and-test
来实现这一点。
npm install start-server-and-test
start-server-and-test
首先运行一个npm脚本,该脚本应该启动一个HTTP服务器。然后等待服务器启动。一旦给定的URL可用,它就会运行另一个npm脚本。
在我们的情况下,第一个脚本是start
,它是ng serve
的别名。我们需要创建第二个脚本来运行pa11y-ci
。
npm脚本 |
我们编辑package.json,并添加两个脚本:
{
"scripts": {
"a11y": "start-server-and-test start http-get://localhost:4200/ pa11y-ci",
"pa11y-ci": "pa11y-ci"
},
}
现在,npm run a11y
会启动Angular开发服务器,然后运行pa11y-ci,最后停止服务器。审核结果将写入标准输出。
表格可访问性:总结
pa11y是一套功能强大的工具,有许多选项。我们仅仅触及了它的一些特点。
自动化可访问性测试是单元测试、集成测试和端到端测试的有价值的补充。您应该对您的Angular应用程序的页面运行像pa11y这样的可访问性测试器。确保复杂表单的可访问性特别有帮助。
请记住,自动化测试只会指出可以以编程方式检测到的某些可访问性障碍。
Web内容可访问性指南(Web Content Accessibility Guidelines — WCAG)从抽象到具体地建立了原则(principles)、指南(guidelines)和成功标准(success criteria)。后者是一些实际规则,其中一些可以自动检查。
WCAG的成功标准伴随着HTML、CSS、JavaScript等技术。对于JavaScript Web应用程序,ARIA等技术特别相关。
总之,您需要先了解可访问性和包容性设计,应用规则来设计和实现应用程序。然后手动和自动检查符合性。