•10 min read

JavaScriptにおける単体テスト:本番環境のバグを実際に防ぐ実践的なパターン

JavaScriptにおける単体テスト:本番環境のバグを実際に防ぐ実践的なパターン

ほとんどのデベロッパーは、自動化された単体テストに関してフラストレーションのたまる段階を経験します。包括的なテストスイートの作成に何時間も費やし、ダッシュボードで90%以上のコードカバレッジを達成し、本番環境へのデプロイに自信を持つでしょう。しかし、数時間以内に、テストスイートでは捕捉されなかったシナリオでユーザーがランタイムクラッシュに遭遇します。さらに悪いことに、関数の内部ヘルパーメソッドをリファクタリングするたびに、外部アプリケーションの動作は完全に無傷であるにもかかわらず、何十ものテストが失敗します。

問題は、Vitest、Jest、またはNodeのネイティブテストランナーのいずれを使用しているかにかかわらず、テストランナーにあることはめったにありません。問題が発生するのは、デベロッパーが**振る舞いの検証(behavioral verification)**ではなく、**構造的カバレッジ(structural coverage)**を教えられているためです。

このガイドでは、壊れやすいテストスイートを回復力のあるセーフティネットに変えるための実践的なパターン、モックの原則、およびプロパティベースのテクニックを詳しく説明します。


Audio Briefing
0:00 / 0:00

1. AAAパターン:単一責任シナリオの強制

一般的なアンチパターンは、単一のモノリシックなテストケース内で複数のアサーションと多段階のミューテーションを組み合わせることです。

// ❌ ANTI-PATTERN: Multi-scenario test with vague intent
test('user checkout flow works', async () => {
  const cart = new Cart();
  cart.add({ id: 'item_1', price: 50 });
  expect(cart.total).toBe(50);

  cart.applyDiscount('SUMMER10');
  expect(cart.total).toBe(45);

  cart.remove('item_1');
  expect(cart.total).toBe(0);
});

このテストが46行目で失敗した場合、失敗がカート計算、割引クーポンロジック、またはアイテム削除のいずれによって引き起こされたのかを理解するために、実行トレース全体を読み解く必要があります。

解決策:明示的なセットアップによるArrange-Act-Assert

明確な状態遷移を、**Arrange-Act-Assert (AAA)**に従って、アトミックで分離されたテストに分割します。

// ✅ PATTERN: Focused, isolated behavioral assertions
describe('Cart Discount Engine', () => {
  it('applies percentage discount to non-empty cart', () => {
    // Arrange
    const cart = createCartWithItems([{ price: 100, quantity: 1 }]);
    const coupon = { code: 'SAVE20', discountPercent: 20 };

    // Act
    cart.applyCoupon(coupon);

    // Assert
    expect(cart.total).toBe(80);
  });

  it('rejects expired coupon codes without mutating total', () => {
    // Arrange
    const cart = createCartWithItems([{ price: 100, quantity: 1 }]);
    const expiredCoupon = { code: 'EXPIRED', discountPercent: 50, expiresAt: new Date(2020, 1, 1) };

    // Act & Assert
    expect(() => cart.applyCoupon(expiredCoupon)).toThrowError(/expired/i);
    expect(cart.total).toBe(100);
  });
});

createCartWithItems(テストオブジェクトファクトリ)の使用に注目してください。ファクトリはArrangeステップを簡潔に保ち、Cartコンストラクタのシグネチャへの変更からテストを保護します。


Advertisement

2. 境界をモックし、内部は決してモックしない

壊れやすいテストを作成する最も手っ取り早い方法の1つは、内部のプライベートメソッドや実装の詳細をモックすることです。

// ❌ BRITTLE: Mocking internal implementation details
jest.spyOn(paymentService, '_calculateTaxRate').mockReturnValue(0.08);
jest.spyOn(paymentService, '_validateZipCode').mockReturnValue(true);

paymentServiceをリファクタリングして、統一された税金テーブルルックアップを介して税金を計算するように変更すると、コードは完全に機能しますが、プライベートな_calculateTaxRateメソッドが存在しなくなるため、すべての単体テストが即座に失敗します。

黄金律:アーキテクチャ境界でモックする

モックするのは以下のものだけです。

  1. ネットワークI/O: MSW (Mock Service Worker)のようなツールを使用したサードパーティAPI (Stripe, GitHub, OpenSearch)。
  2. システムクロック: タイマーとタイムゾーン。
  3. ハードウェア / 非決定論的API: 乱数生成、ファイルシステムアクセス。
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { processSubscriptionPayment } from './payment';

// Mock the network transport boundary
vi.mock('@/lib/stripe-client', () => ({
  stripe: {
    charges: {
      create: vi.fn(),
    },
  },
}));

import { stripe } from '@/lib/stripe-client';

describe('processSubscriptionPayment', () => {
  it('charges customer card and marks subscription active', async () => {
    vi.mocked(stripe.charges.create).mockResolvedValueOnce({
      id: 'ch_123',
      status: 'succeeded',
    });

    const result = await processSubscriptionPayment({
      customerId: 'cus_999',
      amountInCents: 2000,
    });

    expect(result.status).toBe('active');
    expect(stripe.charges.create).toHaveBeenCalledWith({
      customer: 'cus_999',
      amount: 2000,
      currency: 'usd',
    });
  });
});

3. fast-checkによるプロパティベーステスト

従来の単体テストは、エンジニアが積極的に考えたシナリオ(input = "john@example.com"、input = ""、input = null)を検証します。本番環境で障害を引き起こすバグは、ほとんどの場合、誰も予期しなかった入力(Unicodeグリフ、負の浮動小数点数、プロトタイプ汚染文字列、または巨大なペイロード)から発生します。

**プロパティベーステスト(Property-Based Testing)**は、fast-checkのようなライブラリを使用して、数百のランダム化された入力に対して数学的な不変条件を検証します。

import { test } from 'vitest';
import fc from 'fast-check';
import { encodeBase64Url, decodeBase64Url } from './crypto-utils';

test('round-trip encoding invariant: decode(encode(x)) === x for all UTF-8 strings', () => {
  fc.assert(
    fc.property(fc.fullUnicodeString(), (rawText) => {
      const encoded = encodeBase64Url(rawText);
      const decoded = decodeBase64Url(encoded);
      return decoded === rawText;
    }),
    { numRuns: 500 } // Runs 500 distinct randomized variations
  );
});

もしfast-checkが失敗(例えば、ヌルバイト\u0000や絵文字シーケンスの処理)を見つけた場合、**シュリンキング(shrinking)**を実行します。これは、失敗した入力を最小限の再現可能なテストケースに段階的に単純化するものです。


4. 非同期タイミングとクロックの決定論

タイムアウト、デバウンス、またはリトライを含むコードのテストは、エンジニアがテスト本体で任意のsetTimeout遅延を使用すると、不安定なテストスイートの頻繁な原因となります。

常に決定論的なフェイクタイマーを使用してください。

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { debounce } from './debounce';

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

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

  it('delays execution until inactivity window elapses', () => {
    const callback = vi.fn();
    const debouncedFn = debounce(callback, 300);

    debouncedFn();
    debouncedFn();
    debouncedFn();

    // Fast-forward time by 200ms - should not execute yet
    vi.advanceTimersByTime(200);
    expect(callback).not.toHaveBeenCalled();

    // Advance past the 300ms threshold
    vi.advanceTimersByTime(150);
    expect(callback).toHaveBeenCalledTimes(1);
  });
});

Advertisement

5. テストスイートにおけるDRYよりもDAMP

本番アプリケーションコードでは、DRY (Don't Repeat Yourself) はコアな設計原則です。テストスイートでは、DRYに厳密に従うことは保守性を損ないます。テストセットアップロジックが3つの異なるヘルパーファイルで15のネストされたbeforeEachフックに抽象化されている場合、テストが失敗した理由を理解するには、複数のファイルを飛び回る必要があります。

DAMP (Descriptive And Meaningful Phrases) を優先してください。

  • テストセットアップは、テスト内または直接のファクトリ呼び出し内で見えるように保ちます。
  • beforeEach内のテスト間で共有される可変変数を避けます。
  • 期待されるビジネスルールを明確に伝えるテスト名を記述します。
// ❌ Obscure test name
test('calculate() error', () => ...)

// ✅ Clear, self-documenting test name
test('throws InsufficientInventoryError when requested quantity exceeds available stock', () => ...)

よくある質問

100%のテストカバレッジを目指すべきですか?

いいえ。100%のカバレッジを追いかけると、ボイラープレートコード(ゲッター、セッター、単純なDTO)のテストにつながり、内部をモックする壊れやすいテストを助長します。ビジネスドメインの計算、認証ロジック、状態遷移、エッジケースに重点を置いて、75〜85%のカバレッジを目指しましょう。

単体テストの代わりに統合テストを使用すべきなのはいつですか?

純粋なビジネスロジック、アルゴリズム変換、フォーマット関数、状態リデューサーには単体テストを使用します。データベース境界、メッセージブローカー、または多段階ミドルウェアパイプライン間の相互作用を検証する場合は常に、統合テスト(実際のテストコンテナを使用したデータベースクエリのテストや、Supertestを使用したAPIルートのテストなど)を使用します。

CIで不安定なテストを防ぐにはどうすればよいですか?

不安定なテストは、通常、テスト間の共有状態、未処理のPromise拒否、実際のクロックタイミング依存性、または制御されていない外部ネットワーク呼び出しによって引き起こされます。テストごとにデータベーストランザクションを分離し、実際のタイマーをフェイクタイマーに置き換え、MSWですべてのサードパーティの外部HTTPリクエストをモックすることで、不安定さを排除します。


こちらもおすすめです

Share this article:

Stay Updated

Get the latest posts delivered straight to your inbox.

Free Developer Utilities

Free In-Browser Developer Tools

Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.

Explore Tools
Advertisement
テスト駆動開発(TDD)入門
testing

テスト駆動開発(TDD)入門

長年TDDに抵抗していましたが、リファクタリングの失敗を機に考えが変わりました。Red-Green-Refactorサイクルが実際にどのように機能し、テストを先に書くことがテストカバレッジ以外にもたらす変化について解説します。

Read more