7
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Angular コンポーネントのテスト(sample3 / Reactive form を利用したコンポーネントのテスト)

Last updated at Posted at 2018-03-24

目次:Angular コンポーネントのテスト


この章では、より柔軟なバリデーションが利用できる Reactive form のテスト方法を紹介します。

公式ドキュメント Reactive Forms
https://angular.io/guide/reactive-forms#reactive-forms

この記事は以下のテストコードを参考にしています。

Angular v5.2.8 template_integration_spec.ts
https://github.com/angular/angular/blob/5.2.8/packages/forms/test/reactive_integration_spec.ts

コンポーネントの作成

まずテストを書くためのコンポーネントを作成します。

$ ng generate component sample3

作成した空のコンポーネントに以下のようなテンプレートとプロパティを設定します。

// sample3.component.ts

import { Component, OnInit } from '@angular/core';
import {
  FormControl,
  FormGroup,
} from '@angular/forms';

@Component({
  selector: 'app-sample3',
  templateUrl: './sample3.component.html',
  styleUrls: ['./sample3.component.css']
})
export class Sample3Component implements OnInit {
  form: FormGroup;
  name: FormControl;

  works = [
    { id: 'employee', label: '会社員' },
    { id: 'student', label: '学生' },
    { id: 'other', label: 'その他' },
  ];

  constructor() {
    this.form = new FormGroup({
      name: new FormControl(''),
      agreement: new FormControl(false),
      gender: new FormControl('female'),
      work: new FormControl(this.works),
      note: new FormControl('note'),
    });
  }
  ...
}
// sample3.component.html

<form [formGroup]="form">
  <input name="name" type="text" formControlName="name">

  <input type="checkbox" name="agreement" formControlName="agreement">

  <input type="radio" name="gender" value="male" formControlName="gender"><input type="radio" name="gender" value="female" formControlName="gender"><select name="work" formControlName="work">
    <option *ngFor="let item of works" [ngValue]="item">{{item.label}}</option>
  </select>

  <textarea name="note" formControlName="note"></textarea>
</form>
// app.module.ts

+ import { ReactiveFormsModule } from '@angular/forms';

@NgModule({
  declarations: [
    ...
  ],
  imports: [
+    ReactiveFormsModule,
  ],
  ...

コンポーネントのテストをするための準備

テンプレートに記述した <form> には formGroup / formControl などのディレクティブが適用されます。
これらのディレクティブは ReactiveFormsModule で定義されているため、テスト環境でもモジュールの読み込みが必要です。

Angular v5.2.8 form_providers.ts
https://github.com/angular/angular/blob/5.2.8/packages/forms/src/form_providers.ts#L27-L38
https://github.com/angular/angular/blob/5.2.8/packages/forms/src/directives.ts#L70-L71

// sample3.component.spec.ts

+ import { ReactiveFormsModule } from '@angular/forms';

  ...

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [ Sample3Component ],
+      imports: [ ReactiveFormsModule ],
    })
    .compileComponents();
  }));

コンポーネントのプロパティがテンプレートに反映される事を検証

テンプレートに追加した <input> を使って検証してみます。

// sample3.component.spec.ts

import {
+  FormControl,
+  FormGroup,
+  ReactiveFormsModule
} from '@angular/forms';

+ import { By } from '@angular/platform-browser';

  ...

  beforeEach(() => {
    fixture = TestBed.createComponent(Sample3Component);
    component = fixture.componentInstance;
    fixture.detectChanges(); // ①
  });

  ...

+  it('input - コンポーネントのプロパティがテンプレートに反映される', () => {
+    component.form = new FormGroup ({ // ②
+      name: new FormControl('abc'),
+    });
+    fixture.detectChanges();
+
+    const element = fixture.debugElement.query(By.css('[name=name]')).nativeElement as HTMLInputElement;
+    expect(element.value).toBe('abc');
+  });

Template-driven forms とアプローチが少し違いますね。

Template-driven forms の場合は雛形のテストコードにあった fixture.detectChanges() を削除しましたが Reactive form ではその必要がないようです。(①)

また Template-driven forms はフォームに持たせるそれぞれの項目を独立したプロパティとして持っていたため component.name = 'abc'; のように値を設定していました。
Reactive form では form プロパティにフォームの情報が集約されています。このためコンポーネントに値を設定するには component.form にアクセスします 。(②)

テンプレートの値を変更してコンポーネントのプロパティが変わる事を検証

// sample3.component.spec.ts

+  it('input - フォームの値の変更がコンポーネントに反映される', () => {
+    const element = fixture.debugElement.query(By.css('[name=name]')).nativeElement as HTMLInputElement;
+    element.value = 'def';
+    element.dispatchEvent(new Event('input'));
+
+    expect(component.form.value.name).toBe('def');
+  });

HTML要素の操作は Template-driven forms と同じです。
どちらのフォームを利用した場合でも、結果として表示されるフォームとその操作は同じという事ですね。

バリデーション

<input> に required バリデーションを追加して、それを検証するテストを書いてみましょう。

// sample3.component.ts

import {
  ...
+  Validators
} from '@angular/forms';

export class Sample3Component implements OnInit {
  ...
  constructor() {
    this.form = new FormGroup({
-      name: new FormControl('');
+      name: new FormControl('', [
+        Validators.required,
+      ]),
    ...
    });
  }
  ...
}

特別なコードは特に必要ありません。
テキストの入力イベントに連動してバリデーションが動作するため、フォームを参照してバリデーション結果を検証しています。

// sample3.component.spec.ts

import {
  async,
  ComponentFixture,
+  fakeAsync,
  TestBed,
+  tick
} from '@angular/core/testing';

  ...

+  it('input - バリデーション結果をフォームから参照', fakeAsync(() => {
+    fixture.detectChanges();
+    tick();
+
+    const element = fixture.debugElement.query(By.css('[name=name]')).nativeElement as HTMLInputElement;
+
+    element.value = '';
+    element.dispatchEvent(new Event('input'));
+    expect(component.form.valid).toBe(false);
+
+    element.value = 'abc';
+    element.dispatchEvent(new Event('input'));
+    expect(component.form.valid).toBe(true);
+  }));

フォームを参照せず、要素ごとにバリデーション結果を検証する事もできます。

// sample3.component.spec.ts

+  it('input - バリデーション結果を要素から参照', () => {
+    const element = fixture.debugElement.query(By.css('[name=name]')).nativeElement as HTMLInputElement;
+
+    element.value = '';
+    element.dispatchEvent(new Event('input'));
+    fixture.detectChanges();
+    expect(element.classList).toContain('ng-invalid');
+
+    element.value = 'abc';
+    element.dispatchEvent(new Event('input'));
+    fixture.detectChanges();
+    expect(element.classList).toContain('ng-valid');
+  });

カスタムバリデーション

Reactive Form ではビルトインのバリデーションの他に、カスタムバリデーションを指定する事もできます。
例として、空白を含まない事を保証するカスタムバリデーションを追加してみます。

// sample3.component.ts

import {
  ...
+  ValidatorFn,
+  AbstractControl,
} from '@angular/forms';

constructor() {
  this.form = new FormGroup({
    name: new FormControl('', [
      Validators.required,
+      this.notContainsWhiteSpaceValidator(),
    ]),
    ...
  });

+  notContainsWhiteSpaceValidator(): ValidatorFn {
+    return (control: AbstractControl): { [key: string]: any } => {
+      if (control.value.match(/[\\\s?]+/)) {
+        return { 'containsWhiteSpace': { value: control.value } };
+      }
+
+      return null;
+    };
+  }

ビルトインのバリデーションと同じように、HTML要素を操作しバリデーション結果を検証します。

// sample3.component.spec.ts

it('input - バリデーション結果をフォームから参照', fakeAsync(() => {
  fixture.detectChanges();
  tick();

  const element = fixture.debugElement.query(By.css('[name=name]')).nativeElement as HTMLInputElement;
  
  element.value = '';
  element.dispatchEvent(new Event('input'));
  expect(component.form.valid).toBe(false);

+  element.value = 'a bc';
+  element.dispatchEvent(new Event('input'));
+  expect(component.form.valid).toBe(false);

  element.value = 'abc';
  element.dispatchEvent(new Event('input'));
  expect(component.form.valid).toBe(true);
}));

patchValue によるアプローチ

フォームのプロパティに値をセットする方法として form.patchValue によるアプローチを利用する事もできます。テストケースを列挙して一度に検証する場合などはシンプルに記述できて役立つかもしれません。

// sample3.component.spec.ts

it('input - カスタムバリデーション結果をフォームから参照(patchValueによるアプローチ)', () => {
  [
    { expect: true, name: 'abc' },
    { expect: false, name: 'a bc' }, // 半角スペース
    { expect: false, name: 'a bc' }, // 全角スペース
  ].forEach(pattern => {
    component.form.patchValue({ name: pattern.name });
    expect(component.form.valid).toBe(pattern.expect);
  });
});

Submit

次にフォームに保存ボタンを追加してみましょう。
フォームの要素の必須入力が満たされた時にだけ保存ボタンが押せるようにします。

// sample3.component.html

- <form [formGroup]="form">
+ <form [formGroup]="form" (ngSubmit)="submit()">

...

+   <button type="submit" [disabled]="form.invalid">保存</button>
</form>
// sample3.component.ts

export class Sample3Component implements OnInit {
+   submitted = false;

  ...
+   submit() {
+     this.submitted = true;
+   }
}
// sample3.component.spec.ts

+ it('submit', () => {
+  const button = fixture.debugElement.query(By.css('[type=submit]')).nativeElement as HTMLButtonElement;
+  const input = fixture.debugElement.query(By.css('[name=name]')).nativeElement as HTMLInputElement;
+
+  expect(component.submitted).toBe(false);
+  expect(button.disabled).toBe(true);
+
+  input.value = 'abc';
+  input.dispatchEvent(new Event('input'));
+  fixture.detectChanges();
+
+  expect(button.disabled).toBe(false);
+
+  button.click();
+
+  expect(component.submitted).toBe(true);
+});

ngIf

保存処理が完了した時にメッセージを表示するようにしてみます。

// sample3.component.html

<form [formGroup]="form" (ngSubmit)="submit()">

...

+  <div name="submitted" *ngIf="submitted">保存されました</div> <!--①-->
</form>
// sample3.component.spec.ts

it('submit', () => {
  const button = fixture.debugElement.query(By.css('[type=submit]')).nativeElement as HTMLButtonElement;
  const input = fixture.debugElement.query(By.css('[name=name]')).nativeElement as HTMLInputElement;

  expect(component.submitted).toBe(false);
  expect(button.disabled).toBe(true);
+  expect(fixture.debugElement.query(By.css('[name=submitted]'))).toBeNull(); // ②

  input.value = 'abc';
  input.dispatchEvent(new Event('input'));
  fixture.detectChanges();

  expect(button.disabled).toBe(false);

  button.click();
+  fixture.detectChanges();

  expect(component.submitted).toBe(true);
+  expect(fixture.debugElement.query(By.css('[name=submitted]'))).not.toBeNull(); // ③
});

メッセージを表示するためのHTML要素は ngIf を利用しています。(①)
このため submitted の値が偽と評価される間は、このHTML要素はテンプレートに存在しません。
debugElement.query() で検索するとnullが返却されます。(②)

保存ボタンがクリックされ submitted の値が真と評価されるとテンプレートに要素が追加されます。
debugElement.query() で検索すると HTMLElement が返却されます。(③)

HTML要素ごとのサンプルコード

input [type=text]

it('input - コンポーネントのプロパティがテンプレートに反映される', () => {
  component.form = new FormGroup ({
    name: new FormControl('abc'),
  });
  fixture.detectChanges();

  const element = fixture.debugElement.query(By.css('[name=name]')).nativeElement as HTMLInputElement;
  expect(element.value).toBe('abc');
});

it('input - フォームの値の変更がコンポーネントに反映される', () => {
  const element = fixture.debugElement.query(By.css('[name=name]')).nativeElement as HTMLInputElement;
  element.value = 'def';
  element.dispatchEvent(new Event('input'));

  expect(component.form.value.name).toBe('def');
});

it('input - バリデーション結果をフォームから参照', fakeAsync(() => {
  fixture.detectChanges();
  tick();

  const element = fixture.debugElement.query(By.css('[name=name]')).nativeElement as HTMLInputElement;

  element.value = '';
  element.dispatchEvent(new Event('input'));
  expect(component.form.valid).toBe(false);
}));

it('input - バリデーション結果を要素から参照', () => {
  const element = fixture.debugElement.query(By.css('[name=name]')).nativeElement as HTMLInputElement;

  element.value = '';
  element.dispatchEvent(new Event('input'));
  fixture.detectChanges();
  expect(element.classList).toContain('ng-invalid');
});

input [type=radio]

it('radio - コンポーネントのプロパティがテンプレートに反映される', () => {
  component.form = new FormGroup ({
    gender: new FormControl('male'),
  });
  fixture.detectChanges();

  const element = fixture.debugElement.query(By.css('[name=gender][value=male]')).nativeElement as HTMLInputElement;
  expect(element.checked).toBe(true);
});

it('radio フォームの値の変更がコンポーネントに反映される', () => {
  const element = fixture.debugElement.query(By.css('[name=gender][value=male]')).nativeElement as HTMLInputElement;
  element.click();

  expect(component.form.value.gender).toBe('male');
  expect(getForm().value.gender).toBe('male');
});

input [type=checkbox]

it('checkbox - コンポーネントのプロパティがテンプレートに反映される', () => {
  component.form = new FormGroup ({
    agreement: new FormControl(true),
  });
  fixture.detectChanges();

  const element = fixture.debugElement.query(By.css('[name=agreement]')).nativeElement as HTMLInputElement;
  expect(element.checked).toBe(true);
});

it('checkbox - フォームの値の変更がコンポーネントに反映される', () => {
  const element = fixture.debugElement.query(By.css('[name=agreement]')).nativeElement as HTMLInputElement;
  element.click();

  expect(component.form.value.agreement).toBe(true);
  expect(getForm().value.agreement).toBe(true);
});

select

it('select - コンポーネントのプロパティがテンプレートに反映される', () => {
  component.form = new FormGroup ({
    work: new FormControl(component.works[1]),
  });
  fixture.detectChanges();

  const element = fixture.debugElement.queryAll(By.css('[name=work] option'))[1].nativeElement as HTMLOptionElement;
  expect(element.selected).toBe(true);
});

it('select - フォームの値の変更がコンポーネントに反映される', () => {
  const select = fixture.debugElement.query(By.css('[name=work]')).nativeElement;
  const option = fixture.debugElement.queryAll(By.css('[name=work] option'))[2].nativeElement;

  select.value = option.value;
  select.dispatchEvent(new Event('change'));
  expect(component.form.value.work.label).toBe('その他');
  expect(getForm().value.work.label).toBe('その他');
});

textarea

it('textarea - コンポーネントのプロパティがテンプレートに反映される', () => {
  component.form = new FormGroup ({
    note: new FormControl('ABC'),
  });
  fixture.detectChanges();

  const element = fixture.debugElement.query(By.css('[name=note]')).nativeElement as HTMLTextAreaElement;
  expect(element.value).toBe('ABC');
});

it('textarea - フォームの値の変更がコンポーネントに反映される', () => {
  const element = fixture.debugElement.query(By.css('[name=note]')).nativeElement as HTMLTextAreaElement;
  element.value = 'DEF';
  element.dispatchEvent(new Event('input'));

  expect(component.form.value.note).toBe('DEF');
  expect(getForm().value.note).toBe('DEF');
});

次の記事:sample4 / サービスが注入されたコンポーネントのテスト

7
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?