测试复杂表单

学习目标

  • 在组件测试中填写和提交表单

  • 测试同步和异步字段验证和错误消息

  • 测试动态表单逻辑

  • 使用工具确保表单对所有人都是可访问的

表单是大型 Web 应用程序的核心。特别是企业应用程序围绕通过表单输入和编辑数据展开。因此,实现复杂表单是 Angular 框架的一个重要功能。

我们已经学习了如何在测试计数器组件时 填写表单字段。在此过程中,我们开发了 setFieldValue 测试辅助函数。

我们处理的简单表单旨在输入一个值。我们通过填写字段并提交表单来进行测试。现在我们将看一个更复杂的示例。

注册表单

我们将引入和测试一个虚构在线服务的注册表单

注册表单的功能包括:

  • 不同类型的输入字段:文本、单选按钮、复选框、下拉框

  • 同步和异步验证器的字段验证

  • 可访问的表单结构、字段标签和错误消息

  • 字段之间的动态关联

该表单包含四个部分:

  1. 计划选择:“个人”、“商业”或“教育及非营利”

  2. 登录凭据:用户名、电子邮件和密码

  3. 账单地址

  4. 服务条款和提交按钮

非实用性

请注意,此表单仅用于演示目的。虽然它遵循了验证和可访问性的最佳实践,但从设计和用户体验的角度来看,它并不实用。其中包括,对于新用户来说过于复杂。

客户端和服务器

与其他示例存储库不同,这个示例被分成了一个 客户端 目录和一个 服务器 目录:

  • The 客户端目录包含使用 Angular CLI 创建的标准 Angular 应用程序。

  • The 服务器目录包含一个简单的 Node.js 服务,模拟用户管理和账户创建。

同样,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 模板 使用formGroupformGroupNameformControlName指令分别将元素与表单组或控件关联起来。

只有一个控件的简化表单结构如下所示:

<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';

SignupServicesignup方法接受SignupData``并将其发送到服务器。出于安全原因,服务器再次验证数据。但在本指南中,我们将专注于前端。

表单验证和错误

同步验证器

几个表单控件具有同步验证器。requiredemailmaxLengthpattern等都是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-invalidaria-errormessage属性。

aria-invalid将控件标记为辅助技术(如屏幕阅读器)中的无效控件。aria-errormessage指向包含错误消息的另一个元素。

将控件与错误信息关联起来

在出现错误时,该指令将aria-errormessage设置为相应的app-control-errors元素的id。在上面的示例中,该id为name-errors。这样,屏幕阅读器用户可以快速找到关联的错误消息。

控件特定的错误消息仍然位于signup-form.component.html中。它们作为ng-template传递给ControlErrorsComponentControlErrorsComponent动态地渲染模板,并将errors对象作为变量传递进去:

<ng-template let-errors>
  <ng-container *ngIf="errors.required">
    Name must be given.
  </ng-container>
</ng-template>

您不需要理解这个特定实现的细节。在注册表单中,解决方案只是显示错误、避免重复并为可访问性设置ARIA属性的可能性之一。

从用户和测试的角度来看,实现错误消息的渲染方式并不重要——只要它们存在并且可访问即可。

实现细节

我们将在黑盒集成测试中测试SignupFormComponentControlErrorsComponentErrorMessageDirective的配合使用。对于这个测试,后两者将是无关紧要的实现细节。

测试设置

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 函数只是创建伪造数据并避免重复的一种方式。您可以提出其他解决方案,以达到相同的目的。

成功提交表单

我们需要测试的第一个情况是成功提交表单。如果用户填写了所有必填字段并且验证通过,我们期望该组件调用SignupServicesignup方法,并将输入的表单数据作为参数。

测试数据

第一步是定义有效的测试数据,我们可以填充到表单中。我们将其放在一个单独的文件中,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变量。它使用setFieldValuecheckField 元素测试助手

在规范(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)的执行速度。

fakeAsynctick

相反,我们将使用Angular的fakeAsynctick函数来模拟时间的流逝。它们是测试异步行为的强大组合。

fakeAsync冻结时间。它会钩入由定时器、间隔、Promises和Observables创建的异步任务的处理过程。它防止这些任务被执行。

模拟时间的流逝

fakeAsync创建的时间扭曲中,我们使用tick函数来模拟时间的流逝。计划的任务将被执行,我们可以测试它们的效果。

fakeAsynctick的特殊之处在于时间的流逝只是虚拟的。即使在模拟中经过一秒钟,规范仍然在几毫秒内完成。

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的方法,并使用用户输入作为参数。这些方法是isUsernameTakenisEmailTakengetPasswordStrength

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

当用户提交表单时,被测试的组件调用SignupServicesignup方法。

  • 成功情况下,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

下一部分测试错误消息,包含三个步骤:

  1. 读取aria-errormessage属性。期望它被设置。

  2. 找到aria-errormessage引用的元素。期望它存在。

  3. 读取文本内容。期望得到一个错误消息。

步骤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-coreHTML 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,并使用可用的测试引擎axehtmlcs。您可以将许多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等技术特别相关。

总之,您需要先了解可访问性和包容性设计,应用规则来设计和实现应用程序。然后手动和自动检查符合性。