テックブログ

Angular Testing Library入門:getByRoleとuserEventで壊れにくいテスト

Angular Testing Library入門:getByRoleとuserEventで壊れにくいテスト

Testing Libraryは、コンポーネントの内部実装ではなく、ユーザーから見える振る舞いを中心にテストするための考え方とツール群です。特に getByRole のように、画面上の役割やアクセシビリティ情報を手がかりに要素を探すと、DOM構造やクラス名の変更に引っ張られにくくなります。Angular Testing Libraryは、この考え方をAngularで実践しやすくするための道具で、TestBedに寄りすぎた“実装詳細テスト”から離れやすいのが特徴です。userEvent を使えば、クリックや入力も人の操作に近い形で再現しやすくなり、壊れにくく読めるテストへ寄せやすくなります。

関連: AngularをJestに移行:Karma/Jasmineから最短手順(ESM/CIの詰まり対策)

1. Testing Libraryの思想(結論)

1-1. 実装詳細ではなく“振る舞い”をテストする

Testing Libraryの一番大事な考え方は、コンポーネントの内部実装ではなく、ユーザーから見える振る舞いをテストすることです。つまり、「このメソッドが呼ばれたか」よりも、「画面に何が表示され、何を操作できるか」を優先して確認します。

実装詳細に寄ったテストは、内部メソッド名の変更、DOMの細かい構造変更、クラス分割などで壊れやすくなります。一方、ユーザー視点のテストは、見た目や役割が同じなら内部実装が変わっても通りやすいです。これが「壊れにくいテスト」と言われる理由です。Angularでは特に、TestBedでコンポーネント内部へ直接触れやすいぶん、この考え方を意識しないと実装依存のテストが増えがちです。

たとえば「保存ボタンを押したら成功メッセージが出る」を確認したいなら、コンポーネントの private な状態を見るより、ボタンを押してメッセージが見えるかを見るほうが自然です。チェック観点としては、「このテストはユーザーが体験する結果を見ているか」を毎回問い直すと、実装詳細へ寄りすぎるのを防ぎやすくなります。

1-2. TestBedとの役割分担

Angular Testing Libraryは、TestBedを置き換えるものではなく、TestBedの上で“ユーザー視点の書き方”をしやすくする道具です。つまり、Angularの実行環境はTestBedが作り、その上でTesting Libraryが読みやすいテストの書き方を提供します。

TestBedは、DI、テンプレート、変更検知、Angular固有の実行環境を用意する役目です。一方で、Testing Libraryは renderscreen、クエリ群を通じて、「どう描画して」「どう探して」「どう操作するか」をシンプルにします。これにより、fixtureやnativeElementを細かく触るコードを減らしやすくなります。

実務では、「AngularのことはTestBedが面倒を見てくれている」「探し方と操作の流儀はTesting Libraryに寄せる」と整理すると分かりやすいです。TestBedに慣れている人ほど、全部をfixtureから触りたくなりますが、そこを少し手放して screen とユーザー操作へ寄せると、テストの意図が読みやすくなります。

2. 最小の書き方(render→screen)

2-1. renderでテスト対象を描画する

Angular Testing Libraryの最小形は、render でコンポーネントを描画し、その結果をテストすることです。TestBedを直接組み立てるより、描画までの流れを短く書きやすくなります。

render は、対象コンポーネントをAngularのテスト環境上に描画し、必要なら importsproviderscomponentInputs なども一緒に指定できます。これにより、「テストの前提条件」を1か所に集めやすくなります。特にStandaloneコンポーネントとの相性がよく、最小構成で描画しやすいのが利点です。

import { render, screen } from '@testing-library/angular';
import { ItemDetailComponent } from './item-detail.component';

it('商品未選択時のメッセージを表示する', async () => {
  await render(ItemDetailComponent, {
    componentInputs: {
      item: null
    }
  });

  expect(
    screen.getByText('商品を選択してください。')
  ).toBeTruthy();
});

このコードでは、render の段階で Input を渡し、その後は画面に出ている文言だけを見ています。注意点は、描画設定を増やしすぎて“全部入りrender”にしないことです。まずはこのテストに必要な依存だけ渡すほうが、遅くなりにくく、壊れにくくなります。

2-2. screenで要素を探す(getBy/findBy)

Testing Libraryでは、描画後の要素探索は screen を起点にするのが基本です。fixtureやquerySelectorを直接使うより、「ユーザーが見つけられる形」で要素を探しやすくなります。

screen.getBy... は、今すぐ存在しているはずの要素を探すときに使います。一方、非同期で後から出る要素には findBy... を使います。この違いを守るだけでも、「待つべきところで待てていない」テストを減らしやすくなります。Testing Libraryでは、探し方そのものがテスト設計の一部です。

import { render, screen } from '@testing-library/angular';
import { SaveMessageComponent } from './save-message.component';

it('保存後にメッセージを表示する', async () => {
  await render(SaveMessageComponent);

  expect(screen.getByRole('button', { name: '保存' })).toBeTruthy();

  // 保存後に非同期で出る想定
  expect(await screen.findByText('保存しました')).toBeTruthy();
});

このコードでは、最初からあるボタンには getByRole を使い、後から出るメッセージには findByText を使っています。注意点は、「とりあえず全部findByにする」ことです。同期・非同期を区別せずに書くと、テストの意図がぼやけやすいので、「今あるはずか、後で出るはずか」を先に決めてからクエリを選ぶのが大切です。

3. クエリ戦略:role優先で壊れにくくする

3-1. getByRole/getByLabelText/getByTextの順

Testing Libraryで要素を探すときは、roleを最優先にし、それが難しければlabel、最後にtextを見るという順番にすると、壊れにくいテストになりやすいです。これはアクセシビリティにも沿った探し方です。

getByRole は、ボタン、リンク、見出し、テキストボックスのような「役割」で探せるため、見た目が少し変わっても意図が崩れにくいです。フォーム入力なら getByLabelText が自然です。getByText は便利ですが、装飾や文言変更の影響を受けやすい場面もあります。つまり、roleベースは“ユーザーが何として認識するか”に近いため、構造変更へ強いです。

実務では、「まずroleで探せないか」を最初に考えるだけで、querySelectorやCSS依存をかなり減らせます。チェックリストとしては、「ボタンはgetByRole」「入力欄はgetByLabelText」「補助的な文言確認だけgetByText」と決めると、チームで書き方も揃えやすいです。

3-2. data-testidを使う基準

data-testid は便利ですが、最初の選択肢ではなく、roleやlabelで表現しづらいときの最後の手段として使うのが基本です。なんでもtestidで探すと、結局は実装依存へ戻りやすくなります。

roleやlabelで探せるなら、そのほうがユーザー視点に近く、アクセシビリティとも整合が取れます。一方で、純粋に装飾用の要素、アクセシブルネームが取りづらい複雑な構造、同種の要素が大量に並ぶ場面などでは、testidが実務的な助けになることがあります。つまり、testidは悪ではなく、“使いどころを絞る”のが大事です。

判断基準としては、「ユーザーの言葉や役割で自然に探せるか」を先に考えるとよいです。もしそれが難しいなら、data-testid="loading-spinner" のように、意味が分かる名前で置くと読みやすくなります。逆に、すべての要素へ機械的にtestidを振ると、HTMLの保守コストだけが増えやすいです。

4. userEventでユーザー操作を再現する

4-1. click/type/clearの基本

ユーザー操作を再現するときは、fireEvent よりも userEvent を優先すると、実際の操作に近いテストを書きやすくなります。特に入力系では差が出やすいです。

userEvent は、単にイベントを1個飛ばすのではなく、人が操作したときに近い形でイベント列を発生させます。そのため、クリック、入力、削除などの挙動が現実に寄りやすく、実装依存のテストを減らしやすいです。Angularでも、「inputへ値を直接代入してdetectChanges」より、ユーザーが打ち込んだ流れを再現するほうが、振る舞いテストとして自然です。

import { render, screen } from '@testing-library/angular';
import userEvent from '@testing-library/user-event';
import { ItemFormComponent } from './item-form.component';

it('商品名を入力して追加できる', async () => {
  const user = userEvent.setup();

  await render(ItemFormComponent);

  const input = screen.getByRole('textbox', { name: '商品名' });
  const button = screen.getByRole('button', { name: '追加' });

  await user.type(input, 'Angular Book');
  await user.click(button);

  expect(screen.getByText('Angular Book')).toBeTruthy();
});

このコードでは、ユーザーが商品名を入力して追加ボタンを押す流れを、そのまま再現しています。注意点は、userEvent は非同期になることが多いため、await を付け忘れないことです。付け忘れると、たまたま通るけれどCIで落ちるようなテストになりやすいです。

4-2. 非同期UIの待ち方(findBy/waitFor)

非同期で変わるUIは、findBywaitFor を役割で使い分けると安定しやすいです。待ち方が雑だと、Testing Libraryでもflakyは普通に起きます。

後から表示される要素を待つなら、まずは findBy を優先します。これは「その要素が出てくるまで待つ」という意図が明確だからです。一方、要素そのものではなく、複数条件の成立や関数呼び出しのような期待を待つなら waitFor が向いています。全部をwaitForで包むより、待ちたい対象が要素ならfindByのほうが読みやすいです。

import { render, screen, waitFor } from '@testing-library/angular';
import userEvent from '@testing-library/user-event';
import { SaveButtonComponent } from './save-button.component';

it('保存後に完了メッセージを表示する', async () => {
  const user = userEvent.setup();

  await render(SaveButtonComponent);

  await user.click(screen.getByRole('button', { name: '保存' }));

  expect(await screen.findByText('保存しました')).toBeTruthy();
});

it('保存処理が呼ばれるまで待つ', async () => {
  const saveSpy = jest.fn();
  const user = userEvent.setup();

  await render(SaveButtonComponent, {
    componentProperties: {
      onSave: saveSpy
    }
  });

  await user.click(screen.getByRole('button', { name: '保存' }));

  await waitFor(() => {
    expect(saveSpy).toHaveBeenCalled();
  });
});

この例では、画面に現れるメッセージには findByText を使い、関数呼び出しの確認には waitFor を使っています。注意点は、なんでもwaitForに押し込まないことです。waitForは便利ですが、曖昧に使うと「何を待っているか」が見えにくくなるので、まずはfindByで表現できないかを考えると整理しやすいです。

5. DI/Router/HTTPの扱い(最低限)

5-1. providersで依存を差し替える

Angular Testing Libraryでも、依存の差し替えはprovidersで行うのが基本です。ユーザー視点のテストをしつつも、外部依存まで本物でつなぐ必要はありません。

コンポーネントテストで確認したいのは、多くの場合「Serviceの中身」ではなく、「Serviceの結果を受けて画面がどう変わるか」です。そのため、Serviceはモックへ差し替えて、画面の振る舞いに集中したほうがテストの責務がはっきりします。Testing Libraryはユーザー視点を重視しますが、DIやprovidersの考え方はAngularそのものです。

import { render, screen } from '@testing-library/angular';
import { ItemListPageComponent } from './item-list-page.component';
import { ItemService } from './item.service';

it('モックServiceの一覧を表示する', async () => {
  const mockItemService = {
    getItems: () => [
      { id: 1, name: 'Mock Item', description: 'モックです' }
    ]
  };

  await render(ItemListPageComponent, {
    providers: [
      { provide: ItemService, useValue: mockItemService }
    ]
  });

  expect(screen.getByText('Mock Item')).toBeTruthy();
});

このコードでは、Serviceだけを差し替えて画面表示を確認しています。注意点は、モックを増やしすぎて「何をテストしているか」が曖昧になることです。コンポーネントテストでは、外部依存はモックしつつ、画面の振る舞いは本物として見る、というバランスを保つと読みやすくなります。

5-2. RouterやHTTPが絡むときの割り切り

RouterやHTTPが絡むときは、全部を本物でつなごうとせず、テストの責務に応じて割り切ることが大切です。ここを欲張ると、Angular Testing Libraryでも重くて壊れやすいテストになりがちです。

たとえば、画面遷移ボタンの存在やクリック後の表示変化を見たいだけなら、Router全体の統合テストにしなくても十分な場合があります。HTTPも同様で、コンポーネントの表示を見たいならServiceモックで足りることが多いです。一方で、Service自体のHTTP処理を見たいなら、それは別のServiceテストへ寄せたほうが責務がきれいです。

実務では、「このテストはUIの振る舞いを見るのか」「ルーティングや通信まで含めて見るのか」を先に決めると迷いにくいです。最低限の割り切りとしては、「コンポーネントテストは画面中心、HTTPはService側で、Routerは必要最小限の設定だけ」という考え方が扱いやすいです。全部を一つのテストで確認しようとするほど、壊れやすくなります。

6. まとめ

Angular Testing Libraryの価値は、TestBedの知識を捨てることではなく、ユーザー視点のテストへ寄せやすくすることにあります。render で描画し、screen で探し、getByRole を優先し、userEvent で操作する流れに揃えるだけでも、実装詳細へ寄りすぎたテストをかなり減らせます。

特に重要なのは、roleベースで探すことと、同期・非同期の待ち方を分けることです。ボタンや入力欄をroleやlabelで探せると、DOM構造やクラス名に依存しにくくなります。また、後から出る要素には findBy、条件待ちは waitFor と使い分けると、flakyも減らしやすくなります。

AngularらしいDIやprovidersはそのまま活かしつつ、テストの書き方だけをユーザー視点へ寄せるのが、Angular Testing Libraryの実務的な使い方です。壊れにくいテストは、複雑な道具を増やすことではなく、「ユーザーが何を見て、何を操作するか」をテストコードにそのまま書ける状態から始まります。

7. 参考リンク