shibomb

JS/TS開発者のデプロイ不安を解消!Jest/Vitestで作る壊れないコード

自分の書いたコードに自信が持てず、「このままデプロイして、ユーザーの環境でバグが出たらどうしよう…」と不安になった経験はありませんか?あるいは、テストの重要性は理解しているものの、何から手をつけていいのか、どんなテストを書けば効果的なのか分からず、結局手動での確認に頼ってしまっている方も多いかもしれません。この記事では、そんな悩みを抱える開発者のあなたに向けて、JavaScript/TypeScriptにおけるテストの基本的な考え方から、現代の開発現場で広く使われている JestVitest を用いた具体的なテストコードの書き方までを解説します。テストという強力な武器を手に入れ、自信を持ってコードをリリースする第一歩を踏み出しましょう。

なぜ今、テストが必要なのか?品質と信頼性を高める開発の「当たり前」

かつてテストコードは、大規模なプロジェクトや品質を特に重視する現場で書かれる「特別なもの」というイメージがあったかもしれません。しかし、現代のWeb開発において、テストを書くことはもはや特別なことではなく、開発品質と信頼性を担保するための「当たり前のプラクティス」となりつつあります。

テストを書くことには、多くのメリットがあります。

  1. バグの早期発見: 新しい機能を追加したり、既存のコードを修正(リファクタリング)したりした際に、意図せず他の部分を壊してしまう「デグレード(デグレ)」が発生することがあります。テストがあれば、変更を加えた直後に問題を自動で検知でき、手戻りを最小限に抑えられます。
  2. 自信を持ったデプロイ: テストが通っているという事実は、「少なくとも既存の機能は壊れていない」という強力な裏付けになります。これにより、開発者は心理的な安全性を確保でき、自信を持ってコードを本番環境にデプロイできます。
  3. 仕様のドキュメント化: よく書かれたテストコードは、そのコードが「どのように動くべきか」を示す生きたドキュメントになります。他の開発者がコードを理解するのを助け、将来の改修を容易にします。
  4. 設計の改善: 実は、テストはコードの設計を良くする効果もあります。テストが書きにくいコードは、依存関係が複雑だったり、一つの関数が多くの責務を持ちすぎていたりする傾向があります。テストしやすいようにコードを書こうと意識することで、自然と疎結合で再利用性の高い設計につながるのです。

テストを書く時間は、短期的に見れば開発の工数を増やすように感じるかもしれません。しかし、長期的に見れば、手動確認の時間を削減し、バグ修正に追われる時間を減らし、結果として開発全体のスピードと品質を向上させる、極めて価値の高い投資なのです。

単体テストと結合テスト:それぞれの役割と、いつ・どう使い分けるか?

一言で「テスト」と言っても、その目的や検証する範囲によっていくつかの種類に分類されます。特に重要なのが 単体テスト (ユニットテスト)結合テスト (インテグレーションテスト) です。この二つの違いと役割を理解することが、効果的なテスト戦略の第一歩です。

単体テスト (Unit Test)

単体テストは、関数やクラス、UIコンポーネントといった、アプリケーションを構成する「最小単位」が、個別に正しく動作するかを検証するテストです。外部のモジュールやAPI通信、データベースといった依存関係は「モック」と呼ばれる偽物に置き換えて、テスト対象のロジックだけに集中します。

  • 目的: 特定のコードブロックが、与えられた入力に対して期待通りの出力を返すかを確認する。
  • : calculatePrice(100, 0.1) という税込価格を計算する関数が、110 を返すことを確認するテスト。
  • 特徴: 依存関係が少ないため実行が非常に高速で、問題が発生した際に原因箇所を特定しやすい。

結合テスト (Integration Test)

結合テストは、複数のモジュールやコンポーネントを組み合わせて、それらが連携して正しく機能するかを検証するテストです。単体では正しく動くモジュールも、いざ組み合わせてみると予期せぬ問題が起きることがあります。結合テストは、そうしたモジュール間の「つなぎ目」の問題を発見するのに役立ちます。

  • 目的: 複数の単位が連携したときの一連のフローが、仕様通りに動作するかを確認する。
  • : ユーザーがログインフォームに情報を入力し、送信ボタンをクリックすると、認証APIが呼ばれて、成功時にはダッシュボードページに遷移することを確認するテスト。
  • 特徴: 単体テストよりもユーザーの実際の操作に近く、より現実的なシナリオを検証できる。一方で、複数の要素が絡むため実行速度は遅くなり、失敗したときの原因特定がやや難しくなる傾向があります。

一般的に、「テストピラミッド」という考え方が推奨されています。これは、高速で書きやすい 単体テスト を土台として最も多く書き、その上に 結合テスト、さらにその上により実行コストのかかるE2E(End-to-End)テストを配置するという戦略です。まずは堅牢な単体テストで個々の部品の品質を保証し、結合テストでそれらの連携を確認するのが効率的です。

実践!JavaScript/TypeScriptでのテスト環境構築と基本(Jest/Vitestを活用)

それでは、実際にテストコードを書いてみましょう。2026年現在、JavaScript/TypeScriptのテストフレームワークとしては、長年の実績がある Jest と、Viteとの親和性が高く高速な Vitest が二大巨頭となっています。どちらもAPIが非常に似ており、基本的な書き方はほぼ同じです。ここでは、設定がシンプルで始めやすい Vitest を例に解説します。

まず、Viteでプロジェクトを作成するか、既存のプロジェクトにVitestを導入します。

# Viteでプロジェクトを作成する場合(対話形式でTypeScriptなどを選択)
npm create vite@latest my-test-project
cd my-test-project

# Vitestをインストール
npm install -D vitest

次に、簡単な関数とそのテストファイルを作成してみましょう。

src/calculator.ts

export function add(a: number, b: number): number {
  if (typeof a !== 'number' || typeof b !== 'number') {
    throw new Error('Inputs must be numbers');
  }
  return a + b;
}

そして、この関数をテストするためのファイルを作成します。ファイル名は *.test.ts*.spec.ts のようにするのが一般的です。

src/calculator.test.ts

import { describe, it, expect } from 'vitest';
import { add } from './calculator';

// 'describe' でテストスイート(テストのグループ)を定義
describe('add function', () => {
  
  // 'it' で個別のテストケースを定義
  it('should correctly add two positive numbers', () => {
    // 'expect' で期待値を検証(アサーション)
    expect(add(2, 3)).toBe(5);
  });

  it('should handle negative numbers', () => {
    expect(add(-5, -10)).toBe(-15);
  });

  it('should throw an error if inputs are not numbers', () => {
    // 例外がスローされることをテスト
    // @ts-ignore
    expect(() => add('2', 3)).toThrow('Inputs must be numbers');
  });
});

このコードのポイントは3つです。

  • describe(name, fn): 関連するテストをグループ化します。どの機能に関するテストなのかが分かりやすくなります。
  • it(name, fn) (または test): 一つ一つのテストケースを定義します。「〜の場合は〜であるべき (it should ...)」というように、テスト内容が分かる名前を付けます。
  • expect(value).matcher(): アサーション(表明)を行う部分です。expect にテスト対象の関数の実行結果などを渡し、.toBe().toThrow() といった「マッチャー」を使って期待通りの結果になっているかを検証します。

package.jsonscripts"test": "vitest" を追加し、ターミナルで npm test を実行すれば、テストが走り結果が表示されます。

「良い単体テスト」の書き方:モックを使いこなす効果的なテスト設計

単体テストの核心は、テスト対象を「隔離」することです。もしテスト対象の関数が、外部のAPIを呼び出したり、データベースにアクセスしたりする場合、そのままテストすると問題が生じます。

  • テストの実行がネットワークやDBの状態に依存し、不安定になる。
  • テストのたびに外部サービスにリクエストが飛び、コストがかかる可能性がある。
  • 実行が遅くなる。

そこで登場するのが モック (Mock) です。モックとは、テスト対象が依存している外部モジュールや関数の「偽物」を用意し、本物の代わりに振る舞わせるテクニックです。これにより、外部要因を排除し、テスト対象のロジックだけに集中して検証できます。

例えば、ユーザーIDを受け取って外部APIからユーザー情報を取得し、名前を返す関数を考えてみましょう。

src/api.ts

// この関数は実際に外部APIを叩く
export async function fetchUser(userId: number): Promise<{ id: number; name: string }> {
  const res = await fetch(`https://api.example.com/users/${userId}`);
  return res.json();
}

src/userService.ts

import { fetchUser } from './api';

export async function getUserName(userId: number): Promise<string> {
  const user = await fetchUser(userId);
  return user.name;
}

この getUserName をテストする際に、実際に fetchUser を呼び出したくありません。そこで、api.ts モジュール全体をモックします。

src/userService.test.ts

import { describe, it, expect, vi } from 'vitest';
import { getUserName } from './userService';
import * as api from './api'; // apiモジュールをインポート

// 'vi.mock' で指定したモジュールを自動的にモック化
vi.mock('./api');

describe('getUserName', () => {
  it('should return the name of the fetched user', async () => {
    // モック化した fetchUser が返す偽のデータを定義
    const mockUserData = { id: 1, name: 'Nozomono Taro' };
    
    // vi.spyOnでモックの実装を上書き
    vi.spyOn(api, 'fetchUser').mockResolvedValue(mockUserData);
    
    const userName = await getUserName(1);
    
    // fetchUser が正しい引数で呼び出されたかを確認
    expect(api.fetchUser).toHaveBeenCalledWith(1);
    
    // 返り値が期待通りかを確認
    expect(userName).toBe('Nozomono Taro');
  });
});

このテストでは、vi.mock('./api') によって api.ts からエクスポートされるすべての関数がモックに置き換わります。そして mockResolvedValue を使って、fetchUser が呼び出されたときに返す値を指定しています。これにより、ネットワーク通信を一切行うことなく、getUserNamefetchUser から受け取ったデータを正しく処理しているかだけを検証できるのです。

本物の動きを保証する結合テスト:DOM操作やAPI連携を含むテストのコツ

単体テストで部品の品質を保証したら、次はその部品を組み合わせたときの動きを結合テストで確認します。特にフロントエンド開発では、ユーザーの操作(クリック、入力など)を起点とした一連のインタラクションをテストすることが重要です。

この領域でデファクトスタンダードとなっているのが Testing Library ファミリーです。React (@testing-library/react) やVue (@testing-library/vue) など、各フレームワークに対応したライブラリが提供されています。Testing Libraryの哲学は、「実装の詳細ではなく、ユーザーが見て操作する方法でテストする」という点にあります。

例えば、ボタンをクリックするとカウンターの数字が増えるReactコンポーネントのテストを見てみましょう。

src/Counter.test.tsx

import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import Counter from './Counter'; // テスト対象のコンポーネント

describe('Counter component', () => {
  it('should display initial count of 0 and increment when button is clicked', () => {
    // 1. コンポーネントをレンダリング
    render(<Counter />);

    // 2. ユーザーが見るであろう要素を取得
    // "Count: 0" というテキストを持つ要素を探す
    expect(screen.getByText('Count: 0')).toBeInTheDocument();

    // "Increment" という名前(アクセシビリティ名)を持つボタンを探す
    const incrementButton = screen.getByRole('button', { name: /increment/i });
    
    // 3. ユーザーの操作をシミュレート
    fireEvent.click(incrementButton);
    
    // 4. 操作後の結果を検証
    // "Count: 1" というテキストが表示されていることを確認
    expect(screen.getByText('Count: 1')).toBeInTheDocument();
  });
});

このテストでは、コンポーネントの内部状態 (state) や実装の詳細には一切触れていません。代わりに、screen.getByTextscreen.getByRole を使って、ユーザーが画面上で認識するであろうテキストや役割(ロール)を元に要素を取得し、fireEvent.click でユーザーのクリック操作をシミュレートしています。

このようなテストは、コンポーネントの内部実装をリファクタリング(例えば divspan に変えるなど)しても、ユーザーから見た振る舞いが変わらなければテストは失敗しません。これにより、リファクタリングに強く、壊れにくいテストを書くことができます。

テストを習慣化する:持続可能なテストコード運用と開発フローへの統合

テストは一度書けば終わりではありません。コードが変更されるたびに実行され、常に品質を保証し続ける仕組みがあって初めて真価を発揮します。テストを開発プロセスの一部として習慣化するための仕組みを導入しましょう。

  • Git Hooksの活用: huskylint-staged といったツールを使うと、git commit を実行する直前に、変更があったファイルに関連するテストを自動で実行できます。これにより、テストが通らないコードがバージョン管理システムにコミットされるのを防ぎます。
  • CI/CDパイプラインへの統合: GitHub ActionsやGitLab CI/CDといったCI(継続的インテグレーション)ツールを使い、プルリクエストが作成されたときや、メインブランチにマージされたときに、プロジェクトの全てのテストを自動で実行するワークフローを構築します。これはチーム開発において、コードの品質を一定に保つために不可欠なプラクティスです。
  • テストカバレッジの計測: テストがコードベースのどれくらいの割合をカバーしているかを示す指標が「テストカバレッジ」です。vitest --coverage のようなコマンドでレポートを生成できます。カバレッジレポートは、テストが書かれていない重要なロジックを発見するのに役立ちます。ただし、カバレッジ率100%を盲目的に目指すのは非効率な場合もあります。まずはアプリケーションの根幹をなすビジネスロジックから優先的にテストを書いていくのが現実的です。

最初から完璧なテストを目指す必要はありません。まずは一つ、小さな関数に対する単体テストを書くことから始めてみてください。テストを書き、それが自動で実行され、自分のコードの正しさを証明してくれる安心感を一度体験すれば、きっとテストが開発の心強いパートナーになるはずです。

関連記事