フロントエンドのテストって、UIがすぐ変わるから変更コストかかるよねとか、UIとロジックのどっちのテスト書けばいいのか分からんとか、面倒だと思われることが多いように感じます。ちょっとUI変更しただけであっちもこっちもテスト落ちた、うわーって私もよく思っていました。

lacolaco さんの 『DOMのテストがどんどん書きたくなるTesting Libraryの世界への招待』 で紹介されたライブラリがいい感じにテストを手助けしてくれるので、やや似た感じの内容となりますがこの記事であらためて紹介したいと思います。

当記事のサンプルコードは以下を前提としています。

  • @angular/core v16.2
  • @testing-library/angular v14.1
  • jasmine-core v4.6

面倒くさいテスト

私がよく書いていた「すぐに壊れる面倒くさいテスト」は以下のようなものです。

メソッドのコールをチェックするパターン

const spy = spyOn(TestBed.inject(ApiService), "post");

// コンポーネントインスタンスのメソッドを直接叩く
const fixture = TestBed.createComponent(SampleComponent);
fixture.componentInstance.add("FOO");

// コール回数などチェックする
expect(spy).toHaveBeenCalled("FOO");
expect(spy).toHaveBeenCalledTimes(1);

このテストは、インジェクトしたサービスを監視して、コールしたよね?引数渡したよね?と確認してまわるのが特徴です。「塾にお休みの電話したよね?日にち間違ってないよね?」とついつい聞いてしまう保護者パターンとも言えそうです。

このテストの脆さは メソッド・引数をちょっと変更しただけで壊れる ことにあります。サービスのリファクタリングが入ると修正前のコードと修正後のコードを見比べながらテストを直します。

内部チェックパターン

const fixture = TestBed.createComponent(SampleComponent);
const component = fixture.componentInstance;

component.remove({ id: 1 });

// コンポーネントインスタンスの変数をチェックする
expect(component.dataset).toEqual([]);
expect(component.message).toBe("データがありません");

このテストは、テスト対象の内部構造を熟知しているのが特徴です。ビューを介さず、内部の変数から状態変化を探ります。「ハンカチ持った?水筒は?忘れ物ないよね?」とついつい聞いてしまう第2の保護者パターンです。

このテストの脆さは テスト対象の内部構造を変更すると壊れる ことにあります。変数名を変えたり型を変えたりするだけで壊れてしまいます。

DOM構造依存パターン

const element = fixture.debugElement;

// DOMを検索してイベントを発生させる
element.query(By.css("button.primary")).nativeElement.click();
fixture.detectChanges();

// DOMを検索して状態をチェックする
expect(element.query(By.css("div div p")).nativeElement.textContent).toBe("保存されました");
expect(element.query(By.css("footer button")).nativeElement.disabled).toBe("disabled");

このテストは、DOMツリーをくまなく探すのが特徴です。タグやCSSクラスで要素を特定し、UIの状態変化をチェックします。我が子が迷子になった時「黄色のTシャツにグレーの短パン、靴は青いサンダルで、あっ、パンツの裏側に名前書いてます!」って説明できちゃう第3の保護者パターンです。

このテストの脆さは DOMの変更に対する弱さ です。下手するとボタンひとつ追加しただけで崩壊します。

面倒くさいテストの特徴

顕著なのは テスト対象を知りすぎているが故の壊れやすさ です。「保護者」をテスト用語でいうなら ホワイトボックステスト に傾倒しています。実装とテストを同時並行していると記憶が新鮮でテストもすらすら読めますが、時間が経過するとガチガチな密結合でプロダクションコードを読み直さないとテストコードも読めなくなり、テストを修正する面倒くささが加速します。

テストカバレッジを目標とするなど戦略としてホワイトボックステストを選択したのかと問われると、私の答えはNOです。

テストの目的

なぜ密結合なテストを書いてしまうのでしょうか?

  • ノーテストのプルリクエストだと怒られるから、てっとり早く テスト対象の挙動をなぞった テストコードを書いてしまう
  • 何をテストしていいのか分からない から、最初のローディング表示、ボタンのdisabled状態など目にしたものをテストに書いてしまう

テストを書いて安心したいことって何でしょうか?

  • ブラウザを通して確認した挙動が きちんと動き続けること を保証したい
  • その中でも、データが表示されない、登録ボタンが押せないなど コアな部分の破壊 を防ぎたい

つまりはUI変更の多いフロントエンドの世界で、うっかりミスによってユーザー動作を破壊しないよう、リグレッションテストを実現したいのだと気づきました。

であれば、完成品をガチガチにホワイトボックステストで縛っておくのではなく、レンダリング結果が変わらなければパスするよう ブラックボックステストを主軸に したほうが向いているのではないかと思いました。

Angular Testing Library

https://testing-library.com/docs/angular-testing-library/intro/

このライブラリを使いはじめたきっかけは同僚に流行ってるからと勧められたからです。あれこれ考える前に軽い気持ちで使い始めました。

簡単なコンポーネントをテスト対象にして、コードの変更によってテストがどのように影響を受けるのか見ていきましょう。

最初にテストコードから紹介します。

describe("components.HerosComponent", () => {
  it("ヒーローを追加し削除する", async () => {

    // レンダリング
    await render(`<app-heros></app-heros>`, {
      imports: [HerosComponent],
      providers: [
        { provide: HerosService, useClass: MockService }
      ]
    });

    // テキストが存在する事のチェック
    expect(screen.getByText("Hero1")).toBeTruthy();

    // "追加"ボタンをクリック
    fireEvent.input(screen.getByPlaceholderText("ヒーローの名前"), { target: { value: "New Hero" } })
    fireEvent.click(screen.getByRole("button", { name: "追加" }));
    expect(screen.getByText("New Hero")).toBeTruthy();

    // "削除"ボタンをクリック
    fireEvent.click(screen.getAllByRole("button", { name: "削除" })[0]);
    expect(screen.queryByText("Hero1")).toBeNull();
  });
});

class MockService { ... }

Angular Testing Libraryでは専用のオブジェクトや関数を通してコンポーネントにアプローチします。

  • render() でコンポーネントを生成します。
  • screen でレンダリングされた結果にアクセスします。
  • getByPlaceholderText("テキスト")queryByText("テキスト") などクエリを介してDOMを検索します。
  • fireEvent でクリックなどユーザー操作を発生させます。

最初の実装

コンポーネントの実装は以下の通りです。コードの全体像は本題に関連しないので一部抜粋しています。

<ul *ngFor="let hero of heros">
  <li>
    <span>{{ hero.name }}</span>
    <button (click)="remove(hero)">削除</button>
  </li>
</ul>

<input type="text" placeholder="ヒーローの名前" #newHeroName>
<button (click)="add(newHeroName.value)">追加</button>
export class HerosComponent implements OnInit {
  private readonly service = inject(HerosService);
  heros: Hero[] = [];

  ngOnInit(): void {
    this.service.heros$.subscribe(heros => this.heros = heros);
    this.service.fetch();
  }
  ...

ロジックはサービスに切り出してsignalsで実装することにしました。

export class HerosService {
  private readonly heros = signal<Hero[]>([]);

  get heros$(): Observable<Hero[]> {
    return toObservable(this.heros);
  }

  fetch(): void {
    this.heros.update(() => [
      { id: 1, name: "Dr. Nice" },
      { id: 2, name: "Bombasto" },
      { id: 3, name: "Celeritas" },
    ]);
  }
  ...

コンポーネントの変更

Observable大好き派が現れたため、signalsを書き直すことになりました。

export class HerosService {
-  private readonly heros = signal<Hero[]>([]);

  fetch(): Observable<Hero[]> {
-    this.heros.update(() => [
+    return of([
      { id: 1, name: "Dr. Nice" },
      { id: 2, name: "Bombasto" },
      { id: 3, name: "Celeritas" },
    ]);
  ...

この変更により、コンポーネントでも呼び出しが変わります。

export class HerosComponent {
  private readonly service = inject(HerosService);
  heros: Hero[] = [];

  ngOnInit(): void {
-    this.service.heros$.subscribe(heros => this.heros = heros);
-    this.service.fetch();

+    this.service.fetch().subscribe(heros => this.heros = heros);
  }
  ...

コンポーネントのメソッド名もリファクタリングが入りました。

export class HerosComponent {

-  add(newHeroName: string): void { }
+  addHero(newHeroName: string): void { }

-  remove(hero: Hero): void { }
+  removeHero(hero: Hero): void { }

ビューの変更

テーブルレイアウト大好き派が現れたため、ビューの変更と、さらにコンポーネント分割が入りました。

<table>
  <tbody>
    <tr *ngFor="let hero of heros">
      <td>
        <app-heros-item 
          [hero]="hero"
          (remove)="removeHero(hero)"
        ></app-heros-item>
      </td>
    </tr>
  </tbody>
</table>

テストはどこで影響を受けるのか

テストコードを並べて開いて比較してみてください。

サービスのモックオブジェクトに多少の変更があるもののdescribe内のテストコードは変化がありません。

なぜなら、今回の一連の変更はコンポーネントの内部実装やDOM構造が変わっても ユーザーがブラウザや支援技術を介してアプローチする要素 が変わらないからです。Angular Testing Libraryはそこにフォーカスしてテストするため、結果的にブラックボックステストを実践していることになります。

// ユーザーが見ているテキスト
screen.getByText("Hero1");

// ユーザーが見ている入力欄
screen.getByPlaceholderText("ヒーローの名前"), { target: { value: "New Hero" } };

// ユーザーが見ているボタン
screen.getByRole("button", { name: "追加" });
screen.getAllByRole("button", { name: "削除" });

このUI変更への強さがいい感じの手助けとなり、あっちもこっちもテスト落ちたー!という状況が改善しています。

おわりに

Angular Testing Libraryはテストランナーに依存しないためJestでも書くことができます。任意のファイルだけ対象にして小さくスタートすることもできるので、ぜひ試してみてください。

みなさんの「あっちもこっちもテスト落ちた!」の改善の手助けになれば幸いです。