2026年のユニットテスト:言い訳はもうない
ツールは進化しました。AIがテストの足場を書き、フレームワークは高速になり、もはやテストされていないコードを出荷する正当な理由はありません。

3年前、テストを書かない言い訳には少なくとももっともらしさがありました。テストフレームワークは遅く、モックの設定は面倒でした。テストを書くのにコードを書くよりも時間がかかりました。プロダクトマーケットフィットを目指して疾走する3人規模のスタートアップにとって、ROIは明らかではありませんでした。私はそれらの言い訳に同意しませんでしたが、理解はできました。
2026年、それらの言い訳はなくなりました。Vitestは、かつてJestが起動するのにかかっていた時間で、完全なテストスイートを実行します。AIツールは、関数シグネチャから包括的なテストファイルを生成します。TypeScriptはコンパイル時にバグのカテゴリ全体を捕捉し、実際にテストが必要な範囲を絞り込みます。Docker Composeは、統合テスト用の実際のデータベースを数秒で起動します。「テストなし」と「十分にテスト済み」の間のギャップは、かつてないほど小さくなりました。
現代のテスト環境
テストエコシステムはいくつかの優れたツールに集約され、かつてテストのセットアップを研究プロジェクトのようにしていた断片化は、ほとんどなくなりました。
Vitest vs Jest: 決断は下された
どんな新しいプロジェクトでも、Vitestがデフォルトの選択肢です。Jestが悪いからではありません — Jestは堅牢で実績のあるフレームワークです — しかし、Vitestは現代の開発にとって重要なあらゆる側面で、目に見えて優れているからです。
速度: Vitestは変換にesbuildを使用し、ネイティブESMサポートを備えたワーカー スレッドでテストを実行します。私のプロジェクトでは、同じテストスイートがJestと比較してVitestでは3〜5倍高速に実行されます。Jestで45秒かかっていたスイートが、Vitestでは12秒で完了します。
設定: VitestはViteの設定を再利用します。もしビルドにViteをすでに使用しているなら(そして2026年にはほとんどのプロジェクトがそうです)、追加の設定は一切不要です。これをJestのmoduleNameMapper、transform、transformIgnorePatterns、そして一部のESMパッケージが機能しない理由を巡る避けられないトラブルシューティングと比較してみてください。
互換性: VitestはJest互換のAPIを実装しています。describe、it、expect、beforeEach、afterEach、vi.fn()、vi.mock() — これらすべてが期待通りに機能します。JestからVitestへの移行は、ほとんどがjest.fn()をvi.fn()に検索置換するだけです。
// vitest.config.ts — this is often all you need
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: ['node_modules/', 'dist/', '**/*.d.ts', '**/*.config.*'],
},
},
});
Jestを使い続けるべき時: もし大規模な既存のJestテストスイート(500以上のテスト)があり、差し迫った問題がないのであれば、移行は急務ではありません。Jestは引き続きアップデートを受け、問題なく機能します。しかし、新しいプロジェクトや100未満のテストしかないプロジェクトでは、Vitestが明白な選択肢です。
実践におけるテストピラミッド
従来のテストピラミッド(多数のユニットテスト、少数の統合テスト、ごく少数のE2Eテスト)は原則として正しいままですが、その境界は変化しました。
ユニットテストは、個々の関数、ユーティリティメソッド、純粋なビジネスロジックを検証します。これらは高速であるべきで(各10ms未満)、外部依存関係がなく、実装ではなく振る舞いをテストすべきです。
統合テストは、コンポーネントが連携して機能することを検証します — データベースクエリを伴うAPIルート、外部API呼び出しを伴うサービスメソッド、状態管理を伴うReactコンポーネントなどです。これらはより遅いですが(各100-500ms)、ユニットテストでは見逃されるバグを捕捉します。
E2Eテストは、実際のアプリケーションを通じて完全なユーザーワークフローを検証します。これらは最も遅く(各5-30秒)、最も脆弱ですが、他のどのテストでも捕捉できないバグを捕捉します。Playwrightのようなツールは、E2EテストをSelenium時代よりもはるかに信頼性の高いものにしました。
私が目標とする比率は、ユニット70%、統合20%、E2E10%です。正確な数値よりも、各レベルでテストが存在することの方が重要です。
テストすべきものとスキップすべきもの
すべてのコードが同じテストの厳密さを必要とするわけではありません。テストの書き方を知ることと同じくらい、どこにテストの労力を投資すべきかを知ることが重要です。
常にテストすべきもの
ビジネスロジックとドメインルール。 コードがビジネスルール(価格計算、権限チェック、データ検証、ステートマシンの遷移など)を実装している場合、テストが必要です。これらは、間違っていると金銭的損失やセキュリティ脆弱性を引き起こすルールです。
// pricing.ts
export function calculateOrderTotal(items: OrderItem[], discount?: Discount): number {
const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
if (!discount) return subtotal;
if (discount.type === 'percentage') {
return subtotal * (1 - discount.value / 100);
}
if (discount.type === 'fixed') {
return Math.max(0, subtotal - discount.value);
}
return subtotal;
}
// pricing.test.ts
import { describe, it, expect } from 'vitest';
import { calculateOrderTotal } from './pricing';
describe('calculateOrderTotal', () => {
const items: OrderItem[] = [
{ id: '1', name: 'Burger', price: 12.99, quantity: 2 },
{ id: '2', name: 'Fries', price: 4.99, quantity: 1 },
];
it('calculates subtotal without discount', () => {
expect(calculateOrderTotal(items)).toBe(30.97);
});
it('applies percentage discount', () => {
const discount = { type: 'percentage' as const, value: 10 };
expect(calculateOrderTotal(items, discount)).toBeCloseTo(27.873);
});
it('applies fixed discount', () => {
const discount = { type: 'fixed' as const, value: 5 };
expect(calculateOrderTotal(items, discount)).toBe(25.97);
});
it('does not go below zero with fixed discount', () => {
const discount = { type: 'fixed' as const, value: 50 };
expect(calculateOrderTotal(items, discount)).toBe(0);
});
it('handles empty items array', () => {
expect(calculateOrderTotal([])).toBe(0);
});
});
データ変換関数。 データをある形式から別の形式に変換する関数(APIレスポンスマッパー、フォームデータシリアライザー、CSVパーサーなど)は、代表的な入力とエッジケースを含むテストが必要です。
エラー処理パス。 APIが500を返したらどうなるか?入力がnullだったら?ファイルが存在しなかったら?開発者はハッピーパスを手動でテストし、エラーパスは機能すると仮定するため、エラーパスにバグが潜んでいます。
ユーティリティ関数。 文字列フォーマッター、日付ヘルパー、配列ユーティリティ、バリデーション関数など。これらはあらゆる場所で使用され、ユーティリティ関数のバグはコードベース全体に波及します。
スキップまたは軽くテストすべきもの
直接的なフレームワークラッパー。 あなたの関数が、十分にテストされたフレームワークメソッドの薄いラッパーである場合、それをテストすることはあなたのコードではなくフレームワークをテストすることになります。<h1>{title}</h1>をレンダリングするReactコンポーネントは、h1がレンダリングされることを検証するテストを必要としません。
設定ファイル。 静的な設定、定数、型定義 — これらはテストを必要としません。TypeScriptがコンパイル時にすでにそれらを検証しています。
サードパーティライブラリの統合グルーコード。 あなたのデータモデルからのパラメータでstripe.charges.create()を呼び出すコードは、Stripeをモックして呼び出しを検証するユニットテストを必要としません。それは、エンドツーエンドの課金フローを検証する統合テストを必要とします。
モック戦略
モックは、テストが「振る舞いの検証」から「実装の詳細のテスト」へと移行する場所です。目標は、テストを高速かつ決定論的に保ちながら、できるだけモックを少なくすることです。
依存性注入アプローチ
モジュールをモックする代わりに、依存関係をパラメータとして渡します。これによりテストが自然になり、テストをモジュール構造に結合させるvi.mock()の魔法を避けることができます。
// user-service.ts
export function createUserService(db: Database, emailClient: EmailClient) {
return {
async createUser(data: CreateUserInput): Promise<User> {
const user = await db.users.create({ data });
await emailClient.send({
to: user.email,
template: 'welcome',
data: { name: user.name },
});
return user;
},
};
}
// user-service.test.ts
import { describe, it, expect, vi } from 'vitest';
import { createUserService } from './user-service';
describe('createUserService', () => {
it('creates user and sends welcome email', async () => {
const mockUser = { id: '1', name: 'Jane', email: 'jane@example.com' };
const db = {
users: { create: vi.fn().mockResolvedValue(mockUser) },
};
const emailClient = {
send: vi.fn().mockResolvedValue(undefined),
};
const service = createUserService(db as any, emailClient as any);
const result = await service.createUser({ name: 'Jane', email: 'jane@example.com' });
expect(result).toEqual(mockUser);
expect(emailClient.send).toHaveBeenCalledWith({
to: 'jane@example.com',
template: 'welcome',
data: { name: 'Jane' },
});
});
});
vi.mock()の呼び出しも、モジュールパス文字列も、順序依存のセットアップもありません。テストは、何が本物で何が偽物であるかを明確に示しています。
vi.mock()を使用すべき時
モジュールレベルのモックは、依存性注入を制御できない場合に適切です — 通常、モジュールを直接インポートするReactコンポーネントをテストする場合や、環境固有のグローバル変数を使用するコードをテストする場合です。
// When you genuinely need module mocking
vi.mock('./api-client', () => ({
fetchUser: vi.fn().mockResolvedValue({ id: '1', name: 'Test User' }),
}));
APIモックのためのMSW
HTTPリクエストを行うテストの場合、Mock Service Worker (MSW) はサービスワーカーレベルでネットワークリクエストを傍受します。これはfetchやaxiosをモックするよりも優れています。なぜなら、実際のHTTPクライアントコードをテストするからです。
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
const server = setupServer(
http.get('https://api.example.com/users/:id', ({ params }) => {
return HttpResponse.json({
id: params.id,
name: 'Test User',
email: 'test@example.com',
});
}),
http.post('https://api.example.com/orders', async ({ request }) => {
const body = await request.json();
return HttpResponse.json(
{ id: 'order-1', ...body, status: 'created' },
{ status: 201 }
);
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
AI支援によるテスト生成
AIツールは、テスト作成の経済性を真に変えました。初期のテストの足場 — ボイラープレート、基本的なハッピーパスケース、標準的なエッジケース — を生成することは、LLMがうまく処理する反復的でパターンベースの作業そのものです。
AIが得意なこと
- 関数シグネチャと型定義からのテストファイルの生成
- 思いつかないかもしれないエッジケースの提案(空の配列、null値、境界値)
- 反復的なセットアップ/ティアダウンのボイラープレートの記述
- インターフェースの形状に一致するモックオブジェクトの作成
- 多数の入力組み合わせを持つ関数のためのパラメータ化されたテストケースの生成
AIが苦手なこと
- ビジネスコンテキストの理解(「ユーザーは深夜以降に注文できないため、これは失敗すべきだ」はドメイン知識です)
- 複数の関数呼び出しにわたる複雑な状態インタラクションのテスト
- 実際のシステム境界を検証する意味のある統合テストの記述
- 何をテストし、何をテストしないかを決定すること
- 実装ではなく振る舞いを検証するテストの記述
実践的なワークフロー
AI支援テストにおける私のワークフロー:
- 関数またはモジュールを作成します。
- AIに基本的なケースを含むテストファイルを生成するよう依頼します。
- 生成されたテストをレビューし、修正します — AIが生成したテストは、振る舞いではなく実装の詳細をテストすることがよくあります。
- AIが見逃したドメイン固有のケースを追加します。
- テストを実行し、一時的にコードを壊すことで実際にバグを捕捉するかどうかを確認します。
AIがステップ2(かつては面倒な部分でした)を実行し、私はステップ3〜5(判断が必要な部分)に集中します。これにより、標準的なユーティリティコードやサービスコードのテスト作成時間が約50〜60%削減されます。
Reactコンポーネントのテスト
コンポーネントテストは大きく進化しました。EnzymeのシャローレンダリングからReact Testing Libraryのユーザー中心のテスト哲学への移行は、私がコンポーネントテストについて考える方法を変えました。
コンポーネントでテストすべきこと
- コンポーネントは異なるpropsで正しくレンダリングされるか?
- ユーザーインタラクション(クリック、入力、選択)は期待される振る舞いをトリガーするか?
- コンポーネントはローディング、エラー、空の状態を処理するか?
- 条件付きレンダリングは正しく機能するか?
コンポーネントでテストすべきでないこと
- 内部状態の値(Reactが追跡するものではなく、ユーザーが見るものをテストする)
- 実装の詳細(どの関数が呼び出されたか、何回再レンダリングされたか)
- スタイリング(それにはビジュアルリグレッションテストを使用する)
// OrderSummary.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { OrderSummary } from './OrderSummary';
describe('OrderSummary', () => {
const defaultProps = {
items: [
{ id: '1', name: 'Burger', price: 12.99, quantity: 2 },
{