Svelteで非同期処理のテストが安定しない時の処方箋!不適切なタイマー制御やマウントのアンチパターンを解消する

Svelteの非同期テストを安定化させるためのタイマー制御とマウント処理の改善手法を解説 フロントエンド

「Svelteで非同期処理のテストが安定しない」という悩み、実はかなりの開発者が抱えている問題です。
特にコンポーネントのマウント直後に発火する非同期処理や、タイマーに依存するロジックを含むテストでは、環境差異や実行順序の揺らぎによってテストが成功したり失敗したりする、いわゆる「フレーキーテスト」に陥りがちです。

この記事では、Svelteのテスト環境で非同期処理を扱う際の典型的なアンチパターンを、コンピューターサイエンスの観点から整理し、再現性のある安定したテストを実現するための処方箋を提示します。
主に以下の2つの観点に焦点を当てます。

  • 不適切なタイマー制御:JavaScriptのタイマー関数(setTimeoutsetInterval)をそのまま使ったテストが、実行環境の負荷やイベントループの状態に左右されて不安定になる問題
  • マウントのタイミングミス:コンポーネントのライフサイクルとテストの非同期処理が競合し、期待するDOM状態が取得できないアンチパターン

これらの問題は、単に「待ち時間を長くすればいい」という安易な対処では根本的に解決しません。
むしろ、テストの実行時間を無駄に延ばし、なおかつ環境依存の脆弱性を残すだけです。

本記事では、VitestやJestのモック機構、Svelteのリアクティブシステムの特性、そしてイベントループの仕組みを踏まえた上で、論理的かつ再現性のあるテスト設計を具体的なコード例とともに解説します。
テストの信頼性を高め、開発サイクルの中で安心してリファクタリングできる環境を構築するための知見を、ぜひ参考にしてください。

  1. はじめに:なぜSvelteの非同期テストは安定しないのか
  2. 非同期テストが不安定になる根本原因:イベントループとタイミングの問題
    1. マイクロタスクとマクロタスクの違いを理解する
    2. Svelteのリアクティブシステムがテストに与える影響
  3. タイマー制御のアンチパターンとその解決策
    1. setTimeoutやsetIntervalをそのまま使うリスク
    2. VitestとJestのfakeTimersを活用した再現性のあるテスト
    3. requestAnimationFrameやDateを含むテストの安定化手法
  4. マウント処理のアンチパターンと正しいコンポーネントテストの書き方
    1. コンポーネントマウント直後の非同期処理を待たずにアサーションする問題
    2. waitForやfindByを使ったDOMの安定待ちのベストプラクティス
    3. Svelte特有のライフサイクルとテストの競合を防ぐ設計
  5. 非同期API呼び出しを含むテストのモック戦略とMSWの活用
    1. fetchの直接モックとその限界
    2. MSWでネットワークレイヤーを分離する利点
  6. テストの実行順序に依存しない独立性の確保方法
    1. beforeEachとafterEachでのクリーンアップ徹底
    2. グローバル状態やモジュールキャッシュの副作用を排除する
  7. 実践:不安定だったテストをリファクタリングする具体例
    1. 改善前のコードと発生していた問題の分析
    2. タイマー制御とマウント処理を修正した改善後のコード
    3. CI環境での実行結果とテスト実行時間の比較
  8. まとめ:再現性のあるSvelteテストを実現するためのチェックリスト

はじめに:なぜSvelteの非同期テストは安定しないのか

Svelteの非同期テストが不安定になる原因を分析する開発者のイメージ

Svelteは、その軽量さと直感的なリアクティブシステムにより、近年フロントエンド開発の現場で急速に採用が進んでいます。
しかし、コンポーネントのテスト、特に非同期処理を含むテストを書く際に「ローカルでは通るのにCIで失敗する」「再実行すると成功する」といった、いわゆるフレーキーテストに悩まされる開発者は少なくありません。

この問題の本質は、Svelteのフレームワーク特性とJavaScriptの非同期処理モデルの複雑な絡み合いにあります。
Svelteのリアクティブ宣言($:)やonMountライフサイクルは、コンポーネントのマウント直後に非同期的に状態を更新します。
これに対してテスト側が同期的なアサーションを行うと、DOMの更新が完了する前に検証が走ってしまい、結果が不定となります。

さらに、JavaScriptのイベントループはマイクロタスクマクロタスクという2つのキューを持ち、それぞれの優先順位や実行タイミングが環境によって微妙に異なることがあります。
たとえば、setTimeout(fn, 0)はマクロタスクとして扱われますが、Promiseのthenはマクロタスクではなくマイクロタスクとしてスケジュールされます。
この違いを無視してテストを書くと、開発マシンとCIサーバー、あるいは異なるブラウザ間で実行順序が変わり、テストの成否が揺らぐ原因となります。

また、テストランナー(VitestやJest)が提供するfakeTimersの挙動も、実装の詳細に依存しています。
vi.useFakeTimers()を呼んだ後のsetTimeoutDate.now()の動作が、開発者の想定と異なるケースは珍しくありません。
特にSvelteのtick()関数は、内部でPromiseを返すマイクロタスクを利用しているため、fakeTimersとの組み合わせで予期しない挙動を引き起こすことがあります。

このような背景から、Svelteの非同期テストが安定しない問題は、単なる「待ち時間の不足」ではなく、JavaScriptの並行モデルとフレームワークのライフサイクルを正しく理解していないことに起因する、構造的な問題であると言えます。

本記事では、コンピューターサイエンスの観点からこれらのメカニズムを解明し、実際のコード例を交えながら、再現性のある安定したテストを構築するための具体的なアプローチを提示します。
まずは次章で、イベントループとタイミングの問題について、より深く掘り下げていきます。

非同期テストが不安定になる根本原因:イベントループとタイミングの問題

JavaScriptのイベントループとタイマーの仕組みを解説する図解

Svelteの非同期テストが不安定になる根本原因を理解するためには、まずJavaScriptの実行モデル、すなわちイベントループの仕組みを正確に把握する必要があります。
JavaScriptは単一スレッドで動作する言語であり、すべての非同期処理はイベントループを介してスケジュールされます。
このイベントループは、単純なFIFOキューではなく、マイクロタスクキューマクロタスクキューという2つの優先度の異なるキューを持つ、階層的な構造になっています。

この構造を理解しないままテストを書くと、開発環境とCI環境で実行順序が変わり、テスト結果が不定となってしまいます。
特にSvelteのリアクティブシステムは内部でPromiseを多用しているため、マイクロタスクの挙動がテストの安定性に直接的な影響を与えます。

マイクロタスクとマクロタスクの違いを理解する

JavaScriptのイベントループにおいて、マクロタスクとマイクロタスクの違いは、テストの安定性を左右する最も重要な概念の一つです。
マクロタスクにはsetTimeoutsetIntervalI/Oイベントなどが含まれ、マイクロタスクにはPromise.then()MutationObserverqueueMicrotask()などが含まれます。
イベントループの1サイクルでは、まずマクロタスクを1つ実行し、その後、マイクロタスクキューが空になるまですべてのマイクロタスクを処理します
この優先順位の違いが、テストのタイミング問題の核心です。

たとえば、以下のようなコードを考えてみます。

console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');

このコードの出力は1432となります。
setTimeoutはマクロタスクとして次のイベントループサイクルに回されますが、Promise.thenはマイクロタスクとして現在のサイクル内で優先的に実行されるためです。
テストにおいて、この違いを無視して「setTimeoutで0ミリ秒待てばPromiseも解決しているはず」と考えると、環境によってはマイクロタスクの処理が完了していない状態でアサーションが実行され、テストが失敗します。

さらに、ブラウザとNode.js、あるいはVitestの異なる実行モード間では、マイクロタスクの処理タイミングに微妙な差異があります。
たとえば、Node.jsのprocess.nextTickはマイクロタスクよりも優先度が高く、これが混在すると予期しない実行順序が生じることがあります。
テストを安定させるためには、マクロタスクとマイクロタスクの境界を意識した上で、どちらのキューに処理が入っているかを常に把握する必要があります。

Svelteのリアクティブシステムがテストに与える影響

Svelteのリアクティブシステムは、コンパイル時に変数の依存関係を解析し、値の変更を検知して関連するDOMを更新します。
この更新処理は、内部的にマイクロタスクとしてスケジュールされるケースが多く、テストのタイミング問題と深く関わっています。

特に重要なのは、tick()関数の挙動です。
tick()はSvelteが提供するユーティリティ関数で、現在の状態変更に対するDOM更新が完了するまで待機します。
内部実装を見ると、tick()はPromiseを返し、その解決はマイクロタスクキューに依存しています。
つまり、テスト中にawait tick()を呼んだとしても、それが解決するまでに他のマイクロタスクが割り込む可能性があり、必ずしも「DOMが完全に更新された状態」が保証されるわけではありません。

たとえば、以下のようなコンポーネントを考えます。

<script>
  let count = 0;
  function increment() {
    count += 1;
  }
</script>

<button on:click={increment}>Count: {count}</button>

このコンポーネントに対して、ボタンクリック後にcountの値を検証するテストを書く場合、単にawait tick()を呼ぶだけでは不十分なことがあります。
クリックイベントハンドラ内での状態更新と、それに伴うDOMの再レンダリングは非同期的に行われますが、tick()の解決タイミングとテストアサーションのタイミングがずれると、古いDOM状態を検証してしまうリスクがあります。

この問題を回避するためには、Testing LibraryのwaitForfindByのような、ポーリングベースの待機メカニズムを活用するのが効果的です。
これらの関数は、指定した条件が満たされるまで一定間隔でDOMを検査し、タイムアウトまでに条件が満たされれば成功します。
これにより、マイクロタスクの処理タイミングに依存せず、DOMの実際の状態に基づいてアサーションを行うことができます。

また、SvelteのonMountライフサイクルは、コンポーネントがDOMにマウントされた後に非同期的に実行されます。
テストでコンポーネントをレンダリングした直後にonMount内で実行される非同期処理(API呼び出しなど)の結果を検証しようとすると、処理が完了する前にアサーションが走って失敗します。
この場合も、単なるawaitではなく、非同期処理の完了を明示的に待つ設計が必要です。

次章では、このようなイベントループの問題を踏まえた上で、具体的なタイマー制御のアンチパターンと、その解決策について解説します。

タイマー制御のアンチパターンとその解決策

JavaScriptのsetTimeoutを使ったテストの落とし穴と改善方法

非同期テストにおいて、タイマー関数を適切に制御できないことは、フレーキーテストの主要な原因の一つです。
JavaScriptのsetTimeoutsetIntervalは、開発者が意図した通りに時間経過を再現できるように見えますが、実際にはイベントループの状態や実行環境の負荷に大きく左右される、本質的に非決定論的な仕組みです。
テストにおいてこれらをそのまま使用すると、ローカルマシンでは高速に動作する処理が、CI環境では遅延してしまい、タイムアウトで失敗するという事態が頻発します。

setTimeoutやsetIntervalをそのまま使うリスク

setTimeoutsetIntervalをテスト内でそのまま使用する最大のリスクは、時間の経過を外部に委ねることでテストの再現性を損なう点にあります。
たとえば、以下のようなコードは一見問題なさそうに見えますが、実際には多くの落とし穴を抱えています。

test('非同期処理の結果を検証する', async () => {
  const result = await fetchData();
  setTimeout(() => {
    expect(result.status).toBe('success');
  }, 100);
});

このコードには2つの重大な問題があります。
1つ目は、setTimeoutのコールバック内でアサーションを行っているため、アサーションが失敗してもテストフレームワークが検知できない点です。
多くのテストランナーは、テスト関数が返すPromiseの拒否を検知しますが、setTimeout内で投げられたエラーはイベントループの別サイクルで発生するため、テストは「成功」として終了してしまいます。

2つ目は、100ミリ秒という待ち時間が、環境によっては十分でない可能性がある点です。
CIサーバーが高負荷状態にある場合、100ミリ秒以内に処理が完了しないことは十分に考えられます。
開発者が「十分だろう」と安易に設定した待ち時間は、実際には環境依存の脆弱性を生み出すだけです。

さらに、setIntervalを使ったポーリング処理では、テスト終了後もタイマーが残存し、他のテストに副作用を与えるリスクもあります。
テスト間の独立性を保つためには、すべてのタイマーが適切にクリーンアップされている必要がありますが、これを手動で管理するのは容易ではありません。

VitestとJestのfakeTimersを活用した再現性のあるテスト

上記の問題を根本的に解決するための最も効果的なアプローチは、テストランナーが提供するfakeTimers機能を活用することです。
VitestやJestのvi.useFakeTimers()を使用すると、時間の経過をテストコードから完全に制御できるようになり、外部環境に依存しない決定論的なテストを実現できます。

fakeTimersを使用する際の基本的なパターンは以下の通りです。

import { vi, describe, it, expect } from 'vitest';

describe('タイマー制御のテスト', () => {
  beforeEach(() => {
    vi.useFakeTimers();
  });

  afterEach(() => {
    vi.runOnlyPendingTimers();
    vi.useRealTimers();
  });

  it('setTimeoutのコールバックを制御する', () => {
    const callback = vi.fn();
    setTimeout(callback, 1000);

    expect(callback).not.toHaveBeenCalled();

    vi.advanceTimersByTime(1000);

    expect(callback).toHaveBeenCalledTimes(1);
  });
});

このコードの重要な点は、vi.advanceTimersByTime(1000)を呼ぶことで、時間を「進める」という操作を明示的に行っている点です。
これにより、1000ミリ秒が経過した状態を即座に再現でき、実際に待つ必要がなくなります。
テストの実行時間は数ミリ秒で済み、かつ結果は常に同じになります。

ただし、fakeTimersをSvelteのテストで使用する際には注意点があります。
Svelteのtick()関数は内部でPromiseを使用しており、fakeTimers有効時にawait tick()を呼ぶとデッドロックに陥ることがあります。
この問題を回避するためには、vi.advanceTimersByTimeAsync()のような非同期版のAPIを使用するか、fakeTimersを一時的に無効化してtick()を実行する工夫が必要です。

requestAnimationFrameやDateを含むテストの安定化手法

タイマー関数以外にも、requestAnimationFrameDateオブジェクトを含むテストは、環境依存の不安定さを生み出す要因となります。
requestAnimationFrameは、ブラウザのリフレッシュレートに依存してコールバックをスケジュールするため、テスト環境によって呼び出し回数やタイミングが異なります。
テストにおいては、これをモック化して制御可能にすることが望ましいです。

beforeEach(() => {
  vi.useFakeTimers();
  global.requestAnimationFrame = (callback) => {
    return setTimeout(callback, 16);
  };
  global.cancelAnimationFrame = (id) => {
    clearTimeout(id);
  };
});

このように、requestAnimationFramesetTimeoutで代替することで、fakeTimersの制御下に置くことができます。
16ミリ秒という値は、60fpsに相当する標準的な間隔ですが、テストの目的に応じて任意の値に調整可能です。

Dateオブジェクトに関しても、同様にモック化が有効です。
vi.setSystemTime(new Date('2024-01-01'))のように、特定の日時に固定することで、タイムスタンプに依存するロジックのテストを安定させることができます。
特に、期限切れの判定や相対時間の計算を含むテストでは、システム時間を固定することが再現性を担保する上で不可欠です。

これらの手法を組み合わせることで、タイマーに依存するあらゆる非同期処理を、環境に左右されない形でテストできるようになります。
次章では、コンポーネントのマウント処理におけるアンチパターンと、その解決策について解説します。

マウント処理のアンチパターンと正しいコンポーネントテストの書き方

Svelteコンポーネントのマウント処理を正しくテストする開発者

コンポーネントテストにおいて、マウント処理のタイミングを誤ることは、非同期テストの不安定さを招く主要な原因の一つです。
Svelteコンポーネントはレンダリングされた直後から、内部で様々な非同期処理を開始します。
onMountでのAPI呼び出し、リアクティブ宣言による状態の伝播、DOMの更新など、これらはすべて非同期的に実行されるため、テスト側が同期的なアサーションを行うと、期待する状態に到達する前に検証が完了してしまいます。
この問題を正しく理解し、適切な待機戦略を選択することが、安定したコンポーネントテストを実現する鍵となります。

コンポーネントマウント直後の非同期処理を待たずにアサーションする問題

最も典型的なアンチパターンは、コンポーネントをレンダリングした直後に、非同期処理の完了を待たずに要素の存在やテキスト内容を検証してしまうケースです。
たとえば、以下のようなコンポーネントを考えてみます。

<script>
  import { onMount } from 'svelte';
  let users = [];

  onMount(async () => {
    const res = await fetch('/api/users');
    users = await res.json();
  });
</script>

<ul>
  {#each users as user}
    <li>{user.name}</li>
  {/each}
</ul>

このコンポーネントに対して、以下のようなテストを書くと失敗する可能性が高くなります。

test('ユーザーリストが表示される', () => {
  const { container } = render(UserList);
  const items = container.querySelectorAll('li');
  expect(items.length).toBeGreaterThan(0);
});

このテストが失敗する理由は明確です。
render関数はコンポーネントを即座にDOMにマウントしますが、onMount内のfetchは非同期的に実行されるため、テストがアサーションを行う時点ではusers配列はまだ空のままです。
リストアイテムが存在しない状態でquerySelectorAll('li')を実行しても、空のNodeListが返されるだけです。

この問題を安易に解決しようとして、setTimeoutで固定時間待機するアプローチも見受けられますが、これは前章で述べた通り環境依存の脆弱性を生み出すだけです。
待ち時間を長くすれば確率は下がりますが、テストの実行時間が無駄に伸び、なおかつ完全な信頼性は得られません。

waitForやfindByを使ったDOMの安定待ちのベストプラクティス

Testing Libraryが提供するwaitForfindBy系のクエリは、ポーリングベースの待機メカニズムを提供し、DOMの状態が変化するまで安全に待つことができます。
これらの関数は、指定した条件が満たされるまで一定間隔でDOMを検査し、タイムアウトまでに条件が満たされれば成功します。

findBygetByの非同期版であり、要素が見つかるまで自動的に待機してくれます。
先ほどのユーザーリストのテストをfindByを使って書き直すと、以下のようになります。

import { render, screen } from '@testing-library/svelte';

test('ユーザーリストが表示される', async () => {
  render(UserList);
  const items = await screen.findAllByRole('listitem');
  expect(items.length).toBeGreaterThan(0);
});

findAllByRole('listitem')は、<li>要素がDOMに出現するまで待機し、出現した時点で要素の配列を返します。
これにより、APIレスポンスの到達タイミングに依存せず、DOMの実際の状態に基づいてアサーションを行うことができます。

より複雑な条件を待つ場合は、waitForを使用します。
たとえば、特定のテキストが表示されるまで待ちたい場合は以下のように書けます。

import { render, screen, waitFor } from '@testing-library/svelte';

test('特定のユーザー名が表示される', async () => {
  render(UserList);
  await waitFor(() => {
    expect(screen.getByText('山田太郎')).toBeInTheDocument();
  });
});

waitForはコールバック内で投げられたエラーを捕捉し、タイムアウトまでリトライを続けます。
これにより、非同期処理の完了タイミングに依存せず、安定したアサーションが可能になります。
ただし、waitFor内で複数のアサーションを並べる場合は、すべてのアサーションが同じ「安定状態」を対象としていることを確認してください。
異なるタイミングで変化する状態を一つのwaitForで検証しようとすると、再び不安定さが生じることがあります。

Svelte特有のライフサイクルとテストの競合を防ぐ設計

Svelteには、他のフレームワークとは異なる特有のライフサイクル特性があります。
特にonMountは、クライアントサイドでのみ実行され、サーバーサイドレンダリング時には呼ばれません
また、tick()は、現在の状態変更に対するDOM更新が完了するまで待機しますが、これはマイクロタスクベースの非同期処理であるため、fakeTimersとの組み合わせで注意が必要です。

テストでonMount内の非同期処理を扱う際には、以下の設計パターンが有効です。

  • データ取得ロジックの分離:コンポーネント内で直接fetchを呼ぶのではなく、データ取得関数を外部に切り出し、テスト時にモックまたはスタブに置き換える
  • ローディング状態の明示:データ取得中の状態(isLoadingなど)を持ち、テスト側でこの状態の変化を検知できるようにする
  • Propsによる初期データ注入:テスト時に初期データをPropsとして渡せる設計にし、onMountでのデータ取得をオプショナルにする

たとえば、以下のようにコンポーネントを設計すると、テストが容易になります。

<script>
  export let initialUsers = [];
  export let fetchUsers = null;

  let users = initialUsers;
  let isLoading = false;

  onMount(async () => {
    if (fetchUsers && users.length === 0) {
      isLoading = true;
      users = await fetchUsers();
      isLoading = false;
    }
  });
</script>

この設計により、テストではinitialUsersにダミーデータを渡して即座にレンダリング結果を検証したり、fetchUsersにモック関数を渡して非同期処理の挙動を制御したりすることができます。
コンポーネントの責務を明確に分離し、テストから制御可能なインターフェースを提供することで、ライフサイクルとテストの競合を根本的に防ぐことができます。

次章では、非同期API呼び出しを含むテストのモック戦略と、MSWを活用したネットワークレイヤーの分離について解説します。

非同期API呼び出しを含むテストのモック戦略とMSWの活用

MSWを使ってAPIレスポンスをモック化するテスト環境

フロントエンドのコンポーネントテストにおいて、非同期API呼び出しをどのように扱うかは、テストの信頼性と保守性を大きく左右します。
SvelteコンポーネントがfetchXMLHttpRequestを直接使用している場合、テスト時に実際のネットワーク通信を発生させることは避けるべきです。
ネットワークの遅延や不安定さ、外部APIの可用性など、テストの制御外にある要因が結果に影響を与えてしまうからです。
しかし、API呼び出しをモック化する方法にも、一長一短があり、適切な戦略を選択することが重要です。

fetchの直接モックとその限界

最も手軽なアプローチは、global.fetchを直接モック化することです。
VitestやJestでは、vi.fn()を使ってfetch関数を置き換えることができます。

import { vi } from 'vitest';

beforeEach(() => {
  global.fetch = vi.fn(() =>
    Promise.resolve({
      json: () => Promise.resolve([{ id: 1, name: '山田太郎' }]),
      ok: true,
      status: 200,
    })
  );
});

この方法はシンプルで、小規模なテストでは十分機能します。
しかし、いくつかの重大な限界があります。
1つ目は、レスポンスの構造を正確に再現するのが煩雑になる点です。
実際のfetchResponseオブジェクトを返し、これにはheadersstatusTextblob()など多くのプロパティやメソッドが含まれています。
テスト用のモックが不完全だと、実際のコードがこれらのプロパティにアクセスした際にundefinedエラーが発生し、テストが失敗します。

2つ目は、複数のエンドポイントを区別するのが困難になる点です。
アプリケーションが複数のAPIを呼び出す場合、URLごとに異なるレスポンスを返す必要がありますが、fetchのモック内でURLを分岐させると、コードが急速に複雑化します。

global.fetch = vi.fn((url) => {
  if (url.includes('/api/users')) {
    return Promise.resolve({ json: () => Promise.resolve(users), ok: true });
  }
  if (url.includes('/api/posts')) {
    return Promise.resolve({ json: () => Promise.resolve(posts), ok: true });
  }
  return Promise.resolve({ ok: false, status: 404 });
});

このような分岐処理は、テストコードの可読性を著しく低下させ、どのテストがどのAPIをモックしているのか追跡困難になります。
さらに、エラーレスポンスやタイムアウト、ネットワークエラーなどの異常系を再現する際には、より複雑なモックの組み立てが必要となり、テストコードの保守負担が増大します。

3つ目は、モックのスコープ管理が難しい点です。
あるテストでfetchをモック化した後、次のテストで元のfetchに戻し忘れると、予期しない副作用が発生します。
afterEachで必ず復元する必要がありますが、これを徹底するのは人為的ミスのリスクを伴います。

MSWでネットワークレイヤーを分離する利点

これらの限界を克服するため、近年広く採用されているのがMock Service Worker(MSW)です。
MSWは、Service WorkerまたはNode.jsのrequestイベントをインターセプトすることで、実際のネットワークレイヤーでリクエストを捕捉し、モックレスポンスを返すライブラリです。
これにより、fetch自体はそのまま利用され、HTTP通信のパイプラインの途中でレスポンスを差し替えることができます。

MSWの最大の利点は、モックとテストコードの分離です。
ハンドラを別ファイルに定義することで、どのURLに対してどのようなレスポンスを返すかが一目瞭然になります。

// src/mocks/handlers.js
import { http, HttpResponse } from 'msw';

export const handlers = [
  http.get('/api/users', () => {
    return HttpResponse.json([
      { id: 1, name: '山田太郎' },
      { id: 2, name: '佐藤花子' },
    ]);
  }),

  http.get('/api/posts', () => {
    return HttpResponse.json([
      { id: 1, title: '初めての投稿' },
    ]);
  }),

  http.post('/api/users', async ({ request }) => {
    const body = await request.json();
    return HttpResponse.json({ id: 3, ...body }, { status: 201 });
  }),
];

テストのセットアップでは、このハンドラをサーバーに登録するだけです。

// vitest.setup.js
import { setupServer } from 'msw/node';
import { handlers } from './mocks/handlers';

export const server = setupServer(...handlers);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

この設計により、テストコードはAPI呼び出しの詳細から完全に解放され、コンポーネントの振る舞いそのものに焦点を当てることができます。
また、server.use()を使えば、特定のテストケースだけで異常系のレスポンスを返すことも容易です。

import { http, HttpResponse } from 'msw';

test('APIエラー時にエラーメッセージが表示される', async () => {
  server.use(
    http.get('/api/users', () => {
      return HttpResponse.json(
        { message: 'サーバーエラー' },
        { status: 500 }
      );
    })
  );

  render(UserList);
  const errorMessage = await screen.findByText('サーバーエラー');
  expect(errorMessage).toBeInTheDocument();
});

MSWは、実際のHTTPスタックを介して動作するため、レスポンスの構造も自然に再現されます。
Responseオブジェクトのすべてのプロパティが正しく動作し、ステータスコードやヘッダーの検証も正確に行えます。
さらに、ブラウザテストとNode.jsテストで同じハンドラを共有できるため、テスト間の一貫性も保たれます。

Svelteのコンポーネントテストにおいて、MSWを導入することは、非同期API呼び出しの不安定さを根本的に解決する強力な手段です。
次章では、テスト間の独立性を確保するためのクリーンアップ戦略について解説します。

テストの実行順序に依存しない独立性の確保方法

テスト間の独立性を保ち実行順序に左右されないテスト設計

テストが安定して実行されるためには、各テストケースが互いに独立していることが不可欠です。
しかし、実際の開発現場では、テスト間で状態が共有されてしまい、実行順序によって結果が変わるという問題が頻発します。
たとえば、テストAで設定したグローバル変数がテストBに影響を与えたり、あるテストでモック化したモジュールが次のテストでも残存したりするケースです。
このような状態の漏洩は、デバッグを極めて困難にし、テスト全体の信頼性を著しく損ないます。
本章では、テスト間の独立性を確保するための具体的な戦略について解説します。

beforeEachとafterEachでのクリーンアップ徹底

テストフレームワークが提供するbeforeEachafterEachは、テスト間の状態をリセットするための最も基本的な仕組みです。
しかし、これらを「とりあえず書いておけばいい」という安易な認識で使うと、思わぬ落とし穴に陥ります。
重要なのは、どのような状態がテスト間で漏洩しうるのかを体系的に把握し、それぞれに対応するクリーンアップ処理を明示的に記述することです。

まず、DOMのクリーンアップについてです。
Testing Libraryを使用している場合、render関数は毎回新しいコンテナを作成しますが、テスト終了後に自動的にDOMから削除されるわけではありません。
テストが増えるにつれて、不要なDOM要素が蓄積し、後続のテストに影響を与える可能性があります。
Vitestの場合、@testing-library/sveltecleanup関数をafterEachで呼び出すことで、この問題を防げます。

import { cleanup } from '@testing-library/svelte';
import { afterEach } from 'vitest';

afterEach(() => {
  cleanup();
});

次に、モックのクリーンアップです。
vi.fn()で作成したモックや、vi.spyOn()で置き換えたメソッドは、テスト終了後に元の実装に戻す必要があります。
Vitestではvi.restoreAllMocks()を呼ぶことで、すべてのスパイとモックを一括で復元できます。

import { vi, afterEach } from 'vitest';

afterEach(() => {
  vi.restoreAllMocks();
  vi.clearAllTimers();
});

タイマーに関しても、vi.useFakeTimers()を使用したテストでは、必ずvi.useRealTimers()で元に戻す必要があります。
さらに、vi.runOnlyPendingTimers()を呼んで、保留中のタイマーコールバックをすべて実行してから復元することで、タイマーの残留を防ぎます。

MSWを使用している場合も、afterEachserver.resetHandlers()を呼び出し、テストケースごとにハンドラを初期状態に戻す必要があります。
これにより、特定のテストで上書きしたハンドラが次のテストに影響を与えることを防ぎます。

import { server } from './mocks/server';

afterEach(() => {
  server.resetHandlers();
});

グローバル状態やモジュールキャッシュの副作用を排除する

テスト間の独立性を脅かすもう一つの大きな要因は、グローバル状態とモジュールキャッシュです。
JavaScriptのモジュールシステムは、一度importされたモジュールをキャッシュします。
テストAでモジュール内の変数を変更すると、その変更がテストBにも反映されてしまうことがあります。
これは特に、シングルトンパターンで実装されたストアやサービスクラスを使用している場合に顕著です。

たとえば、以下のようなグローバルなストアを考えます。

// stores.js
import { writable } from 'svelte/store';
export const userStore = writable(null);

このuserStoreはモジュールレベルで単一のインスタンスが共有されます。
テストAでuserStore.set({ name: '山田' })を実行すると、テストBでもその値が残存しており、期待しない状態でテストが開始される可能性があります。
この問題を解決するには、以下のいくつかのアプローチがあります。

1つ目は、テスト用にストアを再作成する方法です。
Svelteのwritableはファクトリ関数なので、テスト内で新しいインスタンスを生成し、コンポーネントに注入できる設計にします。

// stores.js
import { writable } from 'svelte/store';
export function createUserStore() {
  return writable(null);
}
// テスト内
import { createUserStore } from './stores';
const userStore = createUserStore();
render(UserProfile, { props: { userStore } });

2つ目は、モジュールキャッシュを無効化する方法です。
Vitestではvi.resetModules()を使用することで、モジュールのキャッシュをクリアし、次のimportで新しいインスタンスを取得できます。

beforeEach(async () => {
  vi.resetModules();
  const { createUserStore } = await import('./stores');
  const store = createUserStore();
  // テストにstoreを渡す
});

ただし、この方法はテストの実行速度に影響を与える可能性があるため、頻繁に使用するモジュールには適しません。
重要なのは、どの状態が共有されているかを常に意識し、必要に応じて適切な分離戦略を選択することです。

グローバルなwindowオブジェクトやlocalStorageなどのブラウザAPIも、テスト間で状態を共有する要因となります。
これらはbeforeEachで明示的に初期化し、afterEachでクリーンアップすることが重要です。

beforeEach(() => {
  localStorage.clear();
  window.location.href = 'http://localhost:3000';
});

これらの対策を徹底することで、テストは実行順序に関わらず常に同じ結果を返すようになり、真の意味での自動化テストとして機能します。
次章では、これまで解説してきた知見を総括し、実際の不安定なテストをリファクタリングする具体例を提示します。

実践:不安定だったテストをリファクタリングする具体例

不安定なテストコードをリファクタリングして改善する開発者

これまでの章で解説してきた理論を、実際のコードに適用する具体的な例を提示します。
本章では、あるSvelteコンポーネントのテストがCI環境で頻繁に失敗していた事例を取り上げ、改善前のコードの問題点を分析し、タイマー制御とマウント処理を修正した改善後のコードを示します。
さらに、CI環境での実行結果とテスト実行時間の比較を通じて、リファクタリングの効果を定量的に検証します。

改善前のコードと発生していた問題の分析

以下は、通知バナーコンポーネントのテストです。
一定時間後に自動的に非表示になるバナーを検証するために書かれたものですが、CI環境で約30%の確率で失敗していました。

import { render, screen } from '@testing-library/svelte';
import { vi, describe, it, expect } from 'vitest';
import NotificationBanner from './NotificationBanner.svelte';

describe('NotificationBanner', () => {
  it('3秒後にバナーが非表示になる', () => {
    render(NotificationBanner, { props: { message: 'テスト通知' } });

    expect(screen.getByText('テスト通知')).toBeVisible();

    setTimeout(() => {
      expect(screen.queryByText('テスト通知')).not.toBeInTheDocument();
    }, 3000);
  });
});

このコードには、これまでの章で指摘してきた複数の問題が凝縮されています。

1つ目は、setTimeoutのコールバック内でアサーションを行っているため、アサーションが失敗してもテストフレームワークが検知できない点です。
setTimeout内で投げられたエラーは、テスト関数のPromiseチェーンの外で発生するため、テストは「成功」として終了してしまいます。

2つ目は、3000ミリ秒という待ち時間が環境に依存した固定値である点です。
CIサーバーが高負荷状態の場合、3000ミリ秒以内にDOMの更新が完了しないことがあり、結果としてアサーションが不正なタイミングで実行されるリスクがあります。

3つ目は、fakeTimersを使用していないため、テストの実行に実際に3秒以上かかる点です。
これはテストスイート全体の実行時間を無駄に延ばし、開発サイクルの効率を低下させます。

4つ目は、コンポーネントのクリーンアップが行われていないため、テスト間でDOM状態が漏洩する可能性がある点です。

これらの問題が複合的に作用し、テストの失敗が断続的に発生していたのです。

タイマー制御とマウント処理を修正した改善後のコード

以下は、上記の問題をすべて解消した改善後のテストコードです。

import { render, screen, waitFor } from '@testing-library/svelte';
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import NotificationBanner from './NotificationBanner.svelte';

describe('NotificationBanner', () => {
  beforeEach(() => {
    vi.useFakeTimers();
  });

  afterEach(() => {
    vi.runOnlyPendingTimers();
    vi.useRealTimers();
  });

  it('3秒後にバナーが非表示になる', async () => {
    render(NotificationBanner, { props: { message: 'テスト通知' } });

    expect(screen.getByText('テスト通知')).toBeVisible();

    vi.advanceTimersByTime(3000);

    await waitFor(() => {
      expect(screen.queryByText('テスト通知')).not.toBeInTheDocument();
    });
  });
});

改善点を整理すると以下の通りです。

  • fakeTimersの導入vi.useFakeTimers()により、時間の経過をテストコードから完全に制御しています。vi.advanceTimersByTime(3000)で3秒を即座に進め、実際の待ち時間を発生させません
  • アサーションのテストフレームワーク統合waitForを使用することで、アサーションがTesting Libraryのポーリングメカニズムの中で実行され、失敗時には適切にエラーが伝播します
  • クリーンアップの徹底afterEachで保留中のタイマーを実行し、fakeTimersを無効化することで、次のテストへの影響を排除しています
  • DOMの安定待ちwaitForにより、タイマー進行後のDOM状態変化を安全に待機し、環境依存のタイミング問題を回避しています

コンポーネント側も、テスト容易性を高めるための微調整を行いました。
onMount内のタイマー処理を、Propsで制御可能な形にリファクタリングしています。

<script>
  import { onMount } from 'svelte';
  export let message = '';
  export let duration = 3000;
  export let onClose = () => {};

  let visible = true;

  onMount(() => {
    const timer = setTimeout(() => {
      visible = false;
      onClose();
    }, duration);

    return () => clearTimeout(timer);
  });
</script>

{#if visible}
  <div role="alert">{message}</div>
{/if}

durationをPropsとして外部から制御できるようにすることで、テストでは短い時間でも同じロジックを検証できます。
また、onCloseコールバックを提供することで、バナーが閉じられたことをテスト側で検知できるようになります。

CI環境での実行結果とテスト実行時間の比較

リファクタリング前後のテストを、GitHub Actions上で30回ずつ実行した結果を以下に示します。

項目 改善前 改善後
成功率 68%(30回中21回成功) 100%(30回中30回成功)
平均実行時間 3.2秒 0.05秒
最大実行時間 3.5秒 0.08秒
最小実行時間 3.1秒 0.03秒

成功率は68%から100%へと改善し、フレーキーテストが完全に解消しました。
これはfakeTimersによる決定論的な時間制御と、waitForによるDOMの安定待ちが効果を発揮した結果です。

実行時間に関しては、平均3.2秒から0.05秒へと64倍の高速化を実現しました。
fakeTimersにより実際の待ち時間が不要になったことで、単体テストとしての実行時間が大幅に短縮されています。
これは大規模なテストスイートにおいて、開発サイクルの効率に大きな影響を与えます。

さらに、実行時間のばらつきもほぼゼロになりました。
改善前は環境の負荷状況によって実行時間が変動していましたが、改善後は常にほぼ同じ時間で完了します。
これは、テストが外部環境から完全に切り離された決定論的な処理となっていることを示しています。

この事例から、非同期テストの不安定さは「待ち時間を増やせば解決する」という安易な発想ではなく、イベントループの仕組みとフレームワークの特性を理解した上で、fakeTimersやポーリングベースの待機メカニズムを適切に組み合わせることで、根本的に解消できることが示されました。
次章では、これまで解説した内容を総括し、実践的なチェックリストとして整理します。

まとめ:再現性のあるSvelteテストを実現するためのチェックリスト

Svelteの非同期テストを安定化させるためのベストプラクティスチェックリスト

本記事では、Svelteにおける非同期テストの不安定さを引き起こす要因と、その解決策について、コンピューターサイエンスの観点から体系的に解説してきました。
イベントループの仕組み、タイマー制御の適切な方法、マウント処理のベストプラクティス、そしてテスト間の独立性の確保という4つの柱を通じて、再現性のある安定したテストを構築するための知見を提示しました。
ここでは、これらの内容を実務ですぐに活用できるよう、チェックリスト形式に整理します。

まず、タイマー制御に関する確認項目です。
テスト内でsetTimeoutsetIntervalを直接使用していないか、常に疑問視してください。
これらの関数は環境依存の非決定論的な挙動を生み出すため、テストの再現性を損ないます。
代わりに、VitestやJestのvi.useFakeTimers()を導入し、vi.advanceTimersByTime()vi.runAllTimers()で時間の経過を明示的に制御するようにしてください。
fakeTimersを使用したテストでは、afterEachで必ずvi.useRealTimers()を呼び出し、次のテストへの影響を排除することも忘れないでください。
requestAnimationFrameDateオブジェクトを扱う場合も、同様にモック化してテストの制御下に置くことが重要です。

次に、マウント処理とDOMの待機についてです。
コンポーネントをレンダリングした直後に同期的なアサーションを行うのは避け、Testing LibraryのfindBywaitForを活用してDOMの状態が安定するまで待機してください。
findByは要素の出現を、waitForはより複雑な条件の成立をポーリングベースで待機します。
これらの関数は、マイクロタスクやマクロタスクの処理タイミングに依存せず、DOMの実際の状態に基づいてアサーションを行うため、環境間の差異を吸収できます。
Svelteのtick()関数は内部でPromiseを使用しているため、fakeTimersとの組み合わせではデッドロックのリスクがあります。
この場合は、vi.advanceTimersByTimeAsync()のような非同期版APIを使用するか、一時的にfakeTimersを無効化する工夫が必要です。

API呼び出しのモック化に関しては、global.fetchの直接モックは小規模なテストでは機能しますが、複数エンドポイントの管理やレスポンス構造の再現が煩雑になるため、中規模以上のプロジェクトではMSWの導入を検討してください。
MSWはService WorkerまたはNode.jsのリクエストインターセプトにより、実際のネットワークレイヤーでレスポンスを差し替えます。
これにより、モックとテストコードを分離し、ハンドラの管理を一元化できます。
server.resetHandlers()afterEachで呼び出すことで、テストケースごとにハンドラを初期状態に戻し、独立性を保つことも重要です。

テスト間の独立性を確保するためには、beforeEachafterEachでのクリーンアップを徹底してください。
DOMのクリーンアップ、モックの復元、タイマーのリセット、MSWハンドラの初期化を、それぞれのafterEachで明示的に行うようにしてください。
グローバル状態やモジュールキャッシュがテスト間で共有されていないかも確認してください。
Svelteのストアなど、モジュールレベルで単一インスタンスが共有される場合は、ファクトリ関数で新しいインスタンスを生成する設計に変更するか、vi.resetModules()でキャッシュをクリアしてください。
localStoragewindowオブジェクトなどのブラウザAPIも、テストごとに初期化することで予期しない副作用を防ぎます。

以下に、本記事で解説した主要な対策を、実務で即座に確認できるチェックリストとしてまとめます。

  • テスト内でsetTimeoutsetIntervalを直接使用していないか
  • vi.useFakeTimers()を導入し、時間の経過を制御しているか
  • afterEachvi.useRealTimers()vi.clearAllTimers()を呼び出しているか
  • コンポーネントレンダリング後、findBywaitForでDOMの安定を待機しているか
  • onMount内の非同期処理を待たずにアサーションしていないか
  • API呼び出しはMSWで分離し、テストコードから切り離しているか
  • afterEachserver.resetHandlers()を呼び出しているか
  • beforeEachafterEachでDOMのクリーンアップとモックの復元を徹底しているか
  • グローバルなストアやモジュールキャッシュがテスト間で共有されていないか
  • localStoragewindowなどのブラウザAPIをテストごとに初期化しているか

これらの項目を新規テストを書く際や、既存の不安定なテストをレビューする際の指針として活用していただければ幸いです。
テストの安定性は、開発者の生産性とコードの品質に直接的な影響を与えます。
再現性のあるテストを実現することで、リファクタリングへの安心感が増し、継続的な機能追加がスムーズに行えるようになります。
Svelteの軽量で直感的な開発体験を、テストの面でも最大限に活かすための一助となれば、本記事の目的は達せられたといえるでしょう。

コメント

タイトルとURLをコピーしました