RevComm Tech Blog

コミュニケーションを再発明し 人が人を想う社会を創る

フロントエンド開発において信頼性の高いテストを記述するための方法や考え方の紹介

はじめに

RevComm Advent Calendar 2025 1日目の記事です。

qiita.com

昨今では AI コーディングエージェントが話題です。AI コーディングエージェントを活用することで、フロントエンド開発においても生産性の大きな改善が期待できます。

AI コーディングエージェントの活用を広めていくためには、信頼性の高いテストコードがあるとより積極的かつ安全に活用や導入を進めていきやすいのではないかと考えています。

そこで、この記事ではフロントエンド開発において信頼性の高いテストコードを記述するための方法論などについて説明できればと思います。

テストダブルの使用について

前提: テストダブルとは?

大雑把に要約すると、テストにおいて本物のオブジェクトの代わりとして機能してくれるオブジェクトのことを指します。

テストダブルには、スタブ、モック、スパイ、フェイクなど、様々な種類があります。以下の Wikipedia ページに分類が解説されていますが、これらの定義を参考にテストダブルについて紹介します (これらの種別の用法や意味には正確な定義や標準が存在するわけではなく、チームや文脈などによって意味が異なる場合もあると思うため、各用語の意味については参考程度にとどめていただければと思います)

en.wikipedia.org

ja.wikipedia.org

テストダブルの分類

フェイク

まず比較的イメージしやすいと思われるフェイクから紹介します。

https://en.wikipedia.org/wiki/Test_double からフェイクの定義を引用します。

Fake — a relatively full-function implementation that is better suited to testing than the production version; e.g. an in-memory database instead of a database server)

置き換え対象の本物のオブジェクトの振る舞いを模倣したオブジェクトがフェイクであると考えられそうです。

例えば、ユーザーの永続化に関する責務を定義した UserRepository インターフェースがあったとします。これに対して、ProductionUserRepository は本番コードでの利用が想定された UserRepository の実装であり、REST API を使用してオブジェクトを永続化します。

class ProductionUserRepository implements UserRepository {
  constructor(client) {
    this.#client = client;
  }
  
  async get(id) {
    try {
      const response = await this.#client.getUserById(id);
      return this.#makeUserFromResponse(response);
    } catch (error) {
      if (isNotFoundError(error)) return Promise.reject(new UserNotFoundError(id));
      throw error;
    }
  }
  
  async add(user) {
    await this.#client.updateUser(user);
  }
  
  #makeUserFromResponse() {
    // 省略...
  }
}

これに対して以下はフェイクの実装例です。データはインメモリで管理され永続化は行われないものの、外から見た際には ProductionUserRepository と概ね同じように動作をします。

class FakeUserRepository implements UserRepository {
  #userById = new Map();
  get(id) {
    const maybeUser = this.#userById.get(id);
    if (maybeUser == null) return Promise.reject(new UserNotFoundError(id));
    return Promise.resolve(maybeUser);
  }
  
  add(user) {
    this.#userById.set(user.id, user);
    return Promise.resolve();
  }
}

依存性の注入 (DI) と併用することにより、実際にコードを動作させる際は ProductionUserRepository を利用し、テストコードの実行時はフェイク実装である FakeUserRepository の方を利用するなど、柔軟に実装を切り替えることができます。

{
  // 本番コード
  const service = new UserService(new ProductionUserRepository(client));
  // ...
}

{
  // テストコード
  const service = new UserService(new FakeUserRepository());
  // ...
}

フェイクを実装する際は、本番向けの実装とフェイク実装とで共通のテストコードを実行しておくと、それぞれの振る舞いを維持することができ、より高い信頼性が期待できます。後述する Google のソフトウェアエンジニアリング においてもこの手法は推奨されており、https://en.wikipedia.org/wiki/Test_double においては Verified fake と呼ばれています。

フェイクには他のテストダブルと比較して信頼性が高いというメリットがあります。しかし、デメリットとして、後述するスタブやモックなどと比較すると実装コストが高くなりがちであり、またメンテナンスも必要です。このフェイクの実装やメンテナンス作業というのはまさに AI コーディングエージェントが得意としている分野であると考えられるため、もしフェイクの利用を進める場合はぜひ活用を検討してみると良さそうです。

スタブ

https://en.wikipedia.org/wiki/Test_double から定義を引用します。

Stub — provides static input

定義に基づいて考えると、スタブは以下のように実装することができます。ライブラリーなどを使用せずとも比較的容易に実装が可能で、フェイクと比較すると、実装がかなり簡単です。

class StubUserRepository implements UserRepository {
  get(id) {
    return Promise.resolve(new User(id, "foobar"));
  }
  
  add(_user) {
    return Promise.resolve(); // NOOP
  }
}

JavaScript は動的言語であり、例えば Jest の jest.spyOn() を使うと特定のメソッドのみをスタブに置き換えることも容易に行えます。

jest.spyOn(localStorage, 'getItem').mockImplementation(() => 'foo');

スタブは実装がとても容易であるというメリットがありますが、先に紹介したフェイクと比較すると信頼性においては大きく劣るというデメリットがあります。乱用はし過ぎずに適度な利用がおすすめであると考えます。

モック

https://en.wikipedia.org/wiki/Test_double から定義を引用します。

Mock — verifies output via expectations defined before the test runs

上記のモックの定義に従うと、Sinon.JS における mock() が定義に近いと思います。

// ...
const mock = sinon.mock(userRepository);

// Expectations
mock.expects('add').once().withArgs(user);

const userService = new UserService(userRepository);
await userService.add({ id: '1', name: 'foobar' });

mock.verify();

このように JavaScript においては、Sinon.JS や testdouble.js などのライブラリーを使うと比較的簡単にモックを実装できます。依存しているオブジェクト間でのコミュニケーションを検証することで、意図した副作用が発生していることをテストすることができます。例えば、特定のメソッドがある順番に従って呼び出されていることを検証したいケースにおいて利用ができます。ただし、テスト対象がどの順番で一連のメソッドを呼び出すかは実装の詳細であり、大抵の場合、過度にモックを乱用したり依存することは望ましくないと考えられます。

モックは便利な仕組みではあると思いますが、契約による設計が言語レベルでサポートされているような稀なケースを除いて、モックに過度に依存しすぎるのは避けた方が良いと筆者は考えています。特定のメソッドが呼ばれたかどうかを検証するのではなく、それによって引き起こされる状態遷移などを検証した方がテストの信頼性が高まると思います。

スパイ

https://en.wikipedia.org/wiki/Test_double から定義を引用します。

Spy — supports setting the output of a call before a test runs and verifying input parameters after the test runs

スタブやモックなどとの違いが少しややこしいですが、テストの実行後に入力パラメーターの検証が行えるという点が大きな違いであると思います。

スパイについても JavaScript では jest.fn()vi.fn() , jest.spyOn() などを利用すると容易に実装できます。

const stubUserRepository: UserRepository = {
  get: jest.fn((id) => new User(id, "foobar")), // スタブ
  add: jest.fn(), // スパイ
};

doSomethingWithUserRepository(stubUserRepository);

expect(stubUserRepository.add).toHaveBeenCalledWith(new User(id, "foobar")); // 入力値の検証

どれを使えばいいの?

テストダブルの使い分けについては基準が難しいところではありますが、まず前提として、テストダブルを使わなくともテストを書くことが可能なのであれば、それがコードが意図した通りに動作していることを保証するための最も信頼性の高い方法であると思います。ただし、現実には外部の REST API や サービスなどに対してテストから直接接続する場合、意図せぬ副作用が発生してしまったり、テストの実行時間が大きく増加してしまうことなども考えられます。そのような場合はテストダブルの使用を検討すると良いでしょう。

参考までに、Google のソフトウェアエンジニアリング という本の13章においてテストダブルの使用に関して非常に詳しく解説されています。

この本ではまず「忠実性」の考えについて紹介されています。「忠実性」とはテストダブルが置き換え対象の本物のオブジェクトの挙動にどれくらい近いかを表す指標であると説明されています。

前提としてテストダブルを使用せずとも十分にテストが可能である際は、テストダブルを使用せずに本物のオブジェクトを使用することがこの本でも推奨されています。それがコード上に存在するバグを正しく検出してくれる可能性が高いケースが多いと考えられるためです。

もしテストダブルの使用が必要である際はフェイクの使用が推奨されています。フェイクは本物のオブジェクトの挙動を模倣したものであり、スタブやモックなどと比較して忠実性が高いためです。逆にスタブやモックを過度に用いることは、フェイクを使用した場合と比較してテスト対象の実装の詳細への依存度が高くなってしまいがちであり、脆いテストコードができてしまうリスクがあると説明されています。

jest.mock()/vi.mock() の使用はできるだけ避ける

これらの前提に基づいて、まずは Jest における jest.mock() や Vitest における vi.mock() について考えてみます。これらの API は先ほどのテストダブルの定義に照らし合わせた場合、スタブに該当するものであると考えられます。そのため、フェイクと比較すると忠実度が低く、乱用しすぎると信頼性が低いテストコードができてしまう原因になってしまう可能性が考えられます。

jest.fn() や 後述する jest.spyOn() などを使用してスタブを実装する場合においても信頼性の低いテストコードができてしまうケースは考えられますが、jest.mock()vi.mock() に関しては実装の詳細へ強く依存したテストコードがより一層容易に記述できてしまうため、特に注意が必要であると考えます。

例えば、apis/getUser モジュールを介して REST API を実行し、ユーザー情報を取得するフックがあったとします。

// src/hooks/useUser.ts
import { getUser } from 'apis/getUser';

export function useUser(id) {
  const [user, setUser] = useState(null);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState(null);
  useEffect(() => {
    if (isLoading) return;
    setIsLoading(true);
    getUser(id)
      .then((user) => setUser(user))
      .catch((error) => setError(error))
      .finally(() => setIsLoading(false));
  }, [id]);
  return { error, user, isLoading };
}

jest.mock() を使うことにより、非常に簡単にスタブをセットアップすることができます。

// src/hooks/__tests__/useUser.spec.tsx
import { renderHook, waitFor } from '@testing-library/react';
import { useUser } from '@/hooks/useUser';

jest.mock('apis/getUser', () => {
  return {
    getUser: (_id) => Promise.resolve(dummyUser),
  };
});

test('useUser()', async () => {
  const { result } = renderHook(() => useUser(dummyUser.id));
  expect(result.current.isLoading).toBe(true);
  await waitFor(() => expect(result.current.user).toEqual(dummyUser));
  expect(result.current.isLoading).toBe(false);
});

jest.mock() を使うことにより、見かけ上はシンプルにテストを記述することができました。ではこれの何が問題なのでしょうか?

例として、ここで apis/getUser モジュールを apis/users/get にリネームしたとします。それに伴い、src/hooks/useUser.tsimport も修正する必要があります。

 // src/hooks/useUser.ts
- import { getUser } from 'apis/getUser';
+ import { getUser } from 'apis/users/get';

 export function useUser(id) {
   const [user, setUser] = useState(null);
   const [isLoading, setIsLoading] = useState(false);
   const [error, setError] = useState(null);
   useEffect(() => {
     if (isLoading) return;
     setIsLoading(true);
     getUser(id)
       .then((user) => setUser(user))
       .catch((error) => setError(error))
       .finally(() => setIsLoading(false));
   }, [id]);
   return { error, user, isLoading };
 }

ソースコードは適切に修正されており、プロダクションコードは意図通りに機能し続けます。

しかし、この状態で src/hooks/__tests__/useUser.spec.tsx を実行すると、テストは失敗してしまいます。apis/getUser.ts から apis/users/get.ts へのリネームは適切に行われており、getUser() の振る舞いも変わってはいないので、本来であればこの状況でテストが失敗してしまうことは望ましくありません。これはテストコードが脆い状態に陥ってしまっており、信頼性が低下してしまっていることを示唆しています。

この問題が発生してしまうのは、src/hooks/__tests__/useUser.spec.tsx が テスト対象である src/hooks/useUser.ts モジュールの実装の詳細である「モジュール間の依存関係」に強く依存してしまっていることが原因です。

jest.mock('apis/getUser', () => {
  return {
    getUser: (_id) => Promise.resolve(dummyUser),
  };
});

モジュール間の依存関係というのは、実装の詳細の中でもかなり詳細度の高いものであると考えられるため、これにテストコードが依存してしまうことは望ましくないと考えます。

改善案

Testing Library (詳細は後述します) がコンポーネントの実装の詳細への強い依存を避けることでテストコードの信頼性を高めることを重視していることと同様に、この問題においてもテストコードがテスト対象の実装の詳細へ強く依存しすぎてしまうことを避けることで改善することができます。具体的に2つの解決策について紹介します。

1. ユーザーの取得に関する責務を抽象化する (フェイクを使用した改善例)

useUser() において重要なのは、何かしらの手段によってユーザー情報を取得し、それに関する状態管理を行うことです。どのようにしてユーザーを取得するかについては実装の詳細にあたり、重要ではないと考えられます。

そこで、このユーザー情報の永続化に関する責務を表現する interface を用意します。

export interface UserRepository {
  get(id: UserID): Promise<User>;
  add(user: User): Promise<void>;
}

UserRepositoryの実装は Context を介して注入するように変更します。

// src/hooks/useUser.ts
import { useContext } from 'react';

export function useUser(id) {
  const [user, setUser] = useState(null);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState(null);
  const userRepository = useContext(UserRepositoryContext);
  
  useEffect(() => {
    if (isLoading) return;
    setIsLoading(true);
    userRepository.get(id)
      .then((user) => setUser(user))
      .catch((error) => setError(error))
      .finally(() => setIsLoading(false));
  }, [id]);
  return { error, user, isLoading };
}

こうすることで、テスト時はフェイク実装によって代用することが可能です

// src/hooks/__tests__/useUser.spec.tsx
import { renderHook, waitFor } from '@testing-library/react';
import { useUser } from '@/hooks/useUser';
import { FakeUserRepository } from '@/repositories/user.fake.ts';

test('useUser()', async () => {
  const userRepository = new FakeUserRepository();
  await userRepository.add(dummyUser);
  const { result } = renderHook(() => useUser(dummyUser.id), {
    wrapper: ({ children }) => (
      <UserRepositoryContext.Provider value={userRepository}>
        {children}
      </UserRepositoryContext.Provider>
    ),
  });
  expect(result.current.isLoading).toBe(true);
  await waitFor(() => expect(result.current.user).toEqual(dummyUser));
  expect(result.current.isLoading).toBe(false);
});

このように interface によって責務を抽象化し、依存注入によって依存関係を取り扱うパターンはフレームワークとして Angular などを採用されている場合は比較的一般的なパターンではないかと思われます。しかし、そうではない場合はここまでやらなくても十分なケースも多いと思われるため、最終的にはプロジェクトの規模やチームの方針などに応じて決めると良いと思います。

ここではもう一つの方法として msw を使った方法についても紹介します。

2. mswを使う

msw とは HTTP に関するスタブライブラリーです。

github.com

元の例におけるsrc/hooks/__tests__/useUser.spec.tsxsrc/hooks/useUser.tsapis/users/get.ts に依存しているということを前提に記述されていました。モジュール間の依存関係というのは、実装の詳細の中でもかなり詳細度が高いものであると考えられ、テストコードがその詳細に依存することによって信頼性が低下してしまっています。msw を使うことによって、テストコードが「このコードは apis/users/get.ts モジュールに依存し、それを使って HTTP リクエストを送信している」という強い前提への依存から「このコードは何らかの方法で HTTP リクエストを送信している」という前提への依存へ緩めることができます。

// src/hooks/__tests__/useUser.spec.tsx
import { renderHook, waitFor } from '@testing-library/react';
import { useUser } from '@/hooks/useUser';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';

const server = setupServer(
  http.get('/api/users/:id', () => HttpResponse.json(dummyUser)),
);
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => {
  server.resetHandlers();
});
afterAll(() => server.close());

test('useUser()', async () => {
  const { result } = renderHook(() => useUser(dummyUser.id));
  expect(result.current.isLoading).toBe(true);
  await waitFor(() => expect(result.current.user).toEqual(dummyUser));
  expect(result.current.isLoading).toBe(false);
});

jest.mock()を使用した例と比較して記述量は増えるものの、大きなメリットとして、useUser() の実装を TanStack Query などを使用して書き換えたとしても、そのままテストが動作してくれます (ただし、renderHook を呼ぶ際の Provider の指定は必要です)

msw を使用する際は意図せぬ API リクエストの送信を検知できるよう、onUnhandledRequesterrorの設定をオススメします。

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));

msw を使うことにより、jest.mock() を使用した場合と比較して、テストコードがテスト対象コードの実装の詳細へ強く依存しすぎてしまう状況を緩和することができました。

ただし、この msw を使用した例においてもテストコードは依然として「テスト対象が何らかの方法で HTTP リクエストを送信する」という実装の詳細へ依存した状態です。1つ目のフェイクを使用した改善例と比較すると実装の詳細への依存度は高い状態であると考えられます。しかし、あまりにも抽象化することを意識しすぎてしまうと、今度は過度な抽象化を招いてしまうリスクも考えられます。フロントエンド開発においては、大抵の場合はこの msw を使用してスタブをセットアップする解決策で十分なケースが多いのではないかと考えています。

また、msw を利用する場合も、可能であれば本物の API の振る舞いに近づけるとより信頼性が高まることが期待されます。必要に応じて検討すると良いでしょう。

const users = [];
const server = setupServer(
  http.get('/api/users/:id', ({ params }) => {
    const user = users.find((x) => x.id.equals(params.id));
    if (user == null) return new HttpResponse(null, { status: 404 });
    else return HttpResponse.json(serializeUser(user));
  }),
  http.post('/api/users', async ({ request }) => {
    const { name } = await request.json();
    const id = makeUserID();
    users.push(new User(id, name));
    return HttpResponse.json({ id, name });
  }),
);

jest.mock() / vi.mock() の使用が適したケースについて

筆者としては jest.mock() の使用は極力避けた方が良いとは考えていますが、まだテストコードが導入されておらず、これから導入していきたいというようなケースにおいては、どうしても jest.mock() を使わないとなかなかテストを追加することが難しいというような場合もあると思います。そういったケースにおいては jest.mock() は非常に便利な機能であると思うため、用途や場面を限定して使用すると良いと思っています。

jest.spyOn()/vi.spyOn() の使用について

先ほどの定義に基づいて考えると、jest.spyOn()vi.spyOn() はスタブやスパイなどのセットアップに利用できる機能です。つまり、テストにおいて本物のオブジェクトを使用する場合やフェイクを使用する場合と比較して、忠実性は低下してしまいます。

例として localStorage に依存した useSidebar() フックをテストするケースについて考えてみます。

function useSidebar() {
  const [isCollapsed, _setIsCollapsed] = useState(() => JSON.parse(localStorage.getItem('isSidebarCollapsed') ?? 'false'));
  const setIsCollapsed = useCallback((isCollapsed) => {
    localStorage.setItem('isSidebarCollapsed', JSON.stringify(isCollapsed));
    _setIsCollapsed(isCollapsed);
  }, []);
  return { isCollapsed, setIsCollapsed };
}

これをテストする場合、例えば jest.spyOn()を使用して localStorage をスタブする方法が考えられます。

test('useSidebar', async () => {
  jest.spyOn(localStorage, 'getItem').mockImplementation(() => 'true');
  const setItem = jest.spyOn(localStorage, 'setItem');
  const { result } = renderHook(() => useSidebar());
  expect(result.current.isCollapsed).toBe(true);
  expect(setItem).not.toHaveBeenCalled();
  
  act(() => result.current.setIsCollapsed(false));
  expect(result.current.isCollapsed).toBe(false);
  expect(setItem).toHaveBeenCalledTimes(1);
});

上記の例では jest.spyOn() を使用して localStorage をスタブしていますが、jsdom には localStorage のフェイク実装がすでに含まれており、こちらに依存した方がより信頼性が高まるでしょう。特に localStorage は Web 標準の API であり、その振る舞いや API に破壊的変更が生じる可能性は比較的低いと考えられます。また、localStorage はネットワークアクセスが発生するわけでもなく、高速に動作することが期待されます。そのため、わざわざフェイク実装やスタブを用意せずとも比較的信頼性の高いテストが書けそうです。

afterEach(() => localStorage.clear());
test('useSidebar', async () => {
  localStorage.setItem('isSidebarCollapsed', 'true');
  const { result } = renderHook(() => useSidebar());
  expect(result.current.isCollapsed).toBe(true);
  
  act(() => result.current.setIsCollapsed(false));
  expect(result.current.isCollapsed).toBe(false);
  expect(localStorage.getItem('isSidebarCollapsed')).toBe('false');
});

このようにスタブを使用せずに、実際のオブジェクトとのやりとりも含めたインテグレーションテストを記述することで、より信頼性の高いテストが書けるケースもあります。

Testing Library を使ってコンポーネントのインテグレーションテストを記述する

次はコンポーネントに対するテストの観点から考えてみます。

Enzyme / Vue Test Utils について

コンポーネントのテストという観点では、React においては Enzyme、Vue.js においては Vue Test Utils のような高機能なテスト用パッケージがあります。

これらのパッケージはレンダリングされたコンポーネントの状態を直接問い合わせたり、CSS セレクターによるレンダリング結果の柔軟な問い合わせなどをサポートしてくれる便利なライブラリーです。

これらのライブラリーを使用して信頼性の高いテストコードを書くことも可能ではあると思います。しかし、そのためには注意深くテストの記述やレビューなどを行う必要があります。

React Testing LibraryVue Testing Library などのいわゆる Testing Library では最初から信頼性を念頭に置いて設計されており、これらのライブラリーを利用することでより信頼性の高いテストコードを記述しやすいです。

なぜ Testing Library を使うのか?

公式の Guiding Principles で解説されていますが、Testing Library の考えとして、テスト対象のコンポーネントに対して「ユーザーは実際にブラウザー上でどのようにして対話するか?」という観点からテストを記述できるようにすることで、テストコードが対象コンポーネントの実装の詳細に依存することを回避し、より信頼性の高いテストコードを記述できるようにしてくれます。

github.com

Testing Library の説明は Web 上にすでにたくさん存在しているため、簡潔に特徴を紹介します。

例えば、Enzyme においてはコンポーネントのレンダリング結果に対して CSS セレクターを使用して柔軟に問い合わせを行うことが可能です。

const wrapper = shallow(<MyForm />);
wrapper.find('.some-button').simulate('click');

それに対して Testing Library では CSS セレクターによるレンダリング結果の問い合わせ機能が意図的に提供されていません。その代わりにアクセシビリティーの観点から問い合わせることが推奨されています。以下は React Testing Library を使用した例です。

import { render } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';

test('MyForm', () => {
  const user = userEvent.setup();
  const screen = render(<MyForm />);
  // button ロールを持ち Save というアクセシブル名をもつ要素を問い合わせ、それをクリックします
  await user.click(screen.getByRole('button', { name: 'Save' }));
});

ユーザーが実際に UI を操作する際は CSS セレクターという実装の詳細に基づいて要素を認識しているわけではなく、個々の要素の役割やラベルなどに基づいて操作します。Testing Library ではこの考えに基づき、意図的に CSS セレクターによる問い合わせを禁止し、代わりにアクセシビリティーに基づいた問い合わせを推奨しています。

また、CSS セレクターによる問い合わせを避けることで、例えば、クラス名がリネームされた際に意図せずテストが失敗してしまうことを防止できます。

また、Enzyme においてはコンポーネントの現在の状態を直接問い合わせたり、またはコンポーネントで定義されたメソッドを直接呼ぶことも可能でした。しかし、コンポーネントの状態や定義されたメソッドというのは実装の詳細です。例えば、コンポーネントの状態がリネームされた場合、それにテストコードが依存していた場合、テストは容易に壊れてしまいます。

const wrapper = shallow(<MyForm />);
wrapper.find('.some-input').simulate('click');
expect(wrapper.state('isSaving')).toBe(true); // コンポーネントの状態を直接問い合わせる (もし state がリネームされると、このテストは失敗します)

Testing Library ではこのようなコンポーネントの状態の問い合わせやメソッドの直接的な呼び出しも廃止されており、先ほどの CSS セレクターの例と同様にアクセシビリティーの観点からレンダリング結果を問い合わせたり、要素を操作することによって対応することが想定されています。

const user = userEvent.setup();
const screen = render(<MyForm />);
await user.click(screen.getByRole('button', { name: 'Save' }));
expect(await screen.findByRole('img', { name: 'Saving' })).toBeVisible(); // アクセシビリティーに基づいてレンダリング結果を問い合わせる

このように Testing Library では意図的に実装の詳細への依存を回避することで、高い信頼性を提供してくれます。

Testing Library を利用する際の Tips

どのクエリメソッドを使うべきか?

詳細については公式ドキュメントに記載されていますが、基本的には ByRole クエリーを使用して問い合わせをすることが推奨されています。

github.com

具体的には、以下の場合、Save というアクセスシブル名を持つ button ロール を問い合わせます (大抵の場合、Save というラベルが設定された button が見つかることでしょう)

screen.getByRole('button', { name: 'Save' });

ByRole クエリーを使用することで、特定の要素をユーザーが特定・操作する際の意図に基づいて問い合わせることができます。極端な例ではありますが、ある UI コンポーネントフレームワークから別の UI コンポーネントフレームワークへ移行したとしても、ByRole クエリーに基づいて要素を問い合わせていれば、ある程度はテストコードを変更せずにそのまま動作し続けてくれることが期待できます。

もし ByRole クエリーを利用することが難しい場合は、ByLabelText または ByPlaceholderText などの代替手段を利用すると良いと思います。ByTestId はどうしても他の手段では要素を問い合わせることが困難な場合に限定して使用すると良いです。

イベントを発火させる際は @testing-library/user-event を使う

例えば、React Testing Library には fireEvent() というAPIがあり、要素に対して任意のイベントを発火させることが可能です。例えば以下のように記述することで、特定要素に対して click イベントを発火させることができます。

fireEvent.click(someButton);

しかし、実際にユーザーがブラウザーをマウスで操作してクリックする際は、まずマウスによってカーソルをボタンの上部まで移動させ 、その後、マウスの左キーをクリックするといったように、実際にはその背後では様々なイベントが発火されています。

@testing-library/user-event パッケージは、このようなユーザーが実際にブラウザー上で特定の要素を操作する際の一連の振る舞いを可能な限り再現してくれるパッケージです。

import { userEvent } from '@testing-library/user-event';

  // ...

  const user = userEvent.setup();
  const someButton = screen.getByRole('button', { name: 'Foo' });
  await user.click(someButton);

要素を操作する際は fireEvent() ではなく @testing-library/user-event を使用することで、より信頼性が改善されることが期待できます。

バグの修正時にはリグレッションテストを記述する

長年、プロダクトの開発や運用を続けていると、どうしてもバグ修正などが積み重なった結果、意図の不明瞭なコードなどができてしまいがちです。

しかし、AI コーディングエージェントはそれらの背景の情報を持っておらず、意図せずそういった不明瞭なコードを書き換えてしまう可能性が考えられます。

そういったケースへの対策や、バグの再発防止などのために、リグレッションテストを記述するとより安心して運用が行いやすくなると思います。

具体的には、あるバグが発見された際には、まずそのバグを再現するためのテストコード (リグレッションテスト) を記述すると理想的です。

例えば、与えられた数値の合計値を求める sum() を例に考えてみます。

const sum = (...numbers) => numbers.reduce((x, y) => x + y);

この sum() はある特定の状況下で例外が発生してしまうことが発覚しました。具体的には引数が一つも与えられていない場合に例外が起きてしまいます。この場合、0 が返却されると望ましそうです。sum() を修正する前にまずはバグを再現するテストコードを記述します。

test('Regression test for issue #1234', () => {
  expect(sum()).toBe(0);
});

この状況でこのテストコードを実行してみます。失敗した場合、意図通りにテストコードによってバグを再現できています。

それでは実際にこのバグを修正してみます。

const sum = (...numbers) => numbers.reduce((x, y) => x + y, 0);

修正後、再度テストコードを実行し、今度はテストが成功することを確認します。

こうすることにより、追加したリグレッションテストコードがバグの再発防止のための仕組みとして機能してくれます。もし AI コーディングエージェントによって本来の意図を損なう形でコード変更が行われてしまった際も、CI でテストコードを自動実行しておけば、事前に気づくことができます。

今回は解説のために単純な関数を使用した例で紹介しましたが、実際には React Testing Library などを活用することで、UI のバグなどに関するリグレッションテストを記述することも可能です。

まとめ

ブラックボックステストを意識する

テストダブルや Testing Library などを例に、信頼性の高いテストコードを記述するための方法について紹介しました。本番コードと同様にテストコードにおいても実装の詳細に強く依存してしまうと、テストコードの信頼性が低下してしまうことがあります。できる限りブラックボックステストとして記述することを意識すると良いと思います。

より安定したものに依存する

テストコードにおいても本番コードと同様により安定したものに依存することを意識すると、信頼性を高めることが期待できます。

具体的には、サードパーティーライブラリーは「変わりやすいもの」の最たる例ではないかと思います。サードパーティーライブラリーに依存したテストコードを記述する際は、サードパーティーライブラリーが提供する API に対して jest.spyOn()jest.mock() などを使用してスタブするよりも、以下の方法などを検討すると良いでしょう。

  • サードパーティーライブラリーが提供する API をスタブせずにインテグレーションテストを記述する
  • サードパーティーライブラリーによって達成したい目的に基づいて interface を定義し、テスト対象コードをサードパーティーライブラリーではなくその interface に依存させる (これによってフェイクオブジェクトを注入したり、サードパーティーライブラリーの API に破壊的変更が加わった際のテストコードへの影響を避けることが期待できます)

終わりに

本記事で紹介した内容が少しでも役に立てば幸いです。この記事で度々紹介した Googleのソフトウェアエンジニアリング はオススメなので、今回紹介したような内容などに興味があればぜひ参照ください!

また、明日も Advent Calendar の記事を公開予定のため、もしご興味があればぜひご覧ください!

参考文献・出典