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

目次：[Angular コンポーネントのテスト](https://qiita.com/ringtail003/items/cdd7c5427c4836857b7e) 

---

この章では、より柔軟なバリデーションが利用できる 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

# コンポーネントの作成

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

```shell
$ ng generate component sample3
```

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

```TypeScript
// 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'),
    });
  }
  ...
}
```

```html
// 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

```diff
// sample3.component.spec.ts

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

  ...

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

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

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

```diff
// 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` にアクセスします 。（②）

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

```diff
// 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 バリデーションを追加して、それを検証するテストを書いてみましょう。

```diff
// 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,
+      ]),
    ...
    });
  }
  ...
}
```

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

```diff
// 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);
+  }));
```

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

```diff
// 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 ではビルトインのバリデーションの他に、カスタムバリデーションを指定する事もできます。
例として、空白を含まない事を保証するカスタムバリデーションを追加してみます。

```diff
// 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要素を操作しバリデーション結果を検証します。

```diff
// 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

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


```diff
// sample3.component.html

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

...

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

```diff
// sample3.component.ts

export class Sample3Component implements OnInit {
+   submitted = false;

  ...
+   submit() {
+     this.submitted = true;
+   }
}
```

```diff
// 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

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

```diff
// sample3.component.html

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

...

+  <div name="submitted" *ngIf="submitted">保存されました</div> <!--①-->
</form>
```

```diff
// 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]

```TypeScript
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]

```TypeScript
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]

```TypeScript
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

```TypeScript
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

```TypeScript
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 / サービスが注入されたコンポーネントのテスト](https://qiita.com/ringtail003/items/72b821b7bfbae9fd875b)