はじめに

「テストを書く時間がない」「テストを書いても意味があるのかわからない」「どこまでテストすればいいのかわからない」

これらは単体テストに関して開発者が抱える典型的な悩みです。しかし、これらの悩みの多くは単体テストの本質的な考え方を理解していないことに起因します。

本記事では、特定のプログラミング言語やフレームワークに依存せず、単体テストの普遍的な原則と考え方を解説します。単体テストとは何か、なぜ書く必要があるのか、どのように設計すべきか、そしてどのような落とし穴を避けるべきかを、体系的に理解できる内容となっています。

この記事を読み終える頃には、単体テストに対する本質的な理解が深まり、どのような言語やプロジェクトでも応用できる考え方を習得できるでしょう。

単体テストとは何か

単体テストは、ソフトウェアテストの最も基本的なレベルに位置付けられるテストです。まずは、その定義と特徴を明確にしましょう。

単体テストの定義

単体テスト(Unit Testing)とは、ソフトウェアを構成する最小単位(ユニット)が正しく動作することを検証するテストです。ここでいう「ユニット」とは、一般的に関数、メソッド、クラスなどを指します。

flowchart TB
    subgraph testing["テストのレベル"]
        direction TB
        E2E["E2Eテスト<br/>(システム全体)"]
        INT["結合テスト<br/>(モジュール間連携)"]
        UNIT["単体テスト<br/>(最小単位)"]
    end
    
    E2E --> INT
    INT --> UNIT
    
    subgraph scope["検証対象"]
        S1["ユーザーシナリオ全体"]
        S2["API・DB連携"]
        S3["関数・メソッド・クラス"]
    end
    
    E2E -.-> S1
    INT -.-> S2
    UNIT -.-> S3
    
    style UNIT fill:#e8f5e9,stroke:#2e7d32,color:#000000
    style INT fill:#fff3e0,stroke:#e65100,color:#000000
    style E2E fill:#e1f5fe,stroke:#01579b,color:#000000

単体テストの特徴

単体テストには、他のテストレベルと区別される重要な特徴があります。

特徴 説明
高速 ミリ秒単位で実行完了(目安:1テスト10ms以下)
独立 他のテストや外部環境に依存しない
再現可能 いつ、どこで実行しても同じ結果になる
自己検証 成功/失敗を自動で判定できる
小さい 1つの振る舞いに焦点を当てる

これらの特徴により、単体テストは開発サイクルの中で最も頻繁に実行されるテストとなります。

「ユニット」の定義に関する2つの学派

単体テストにおける「ユニット」の解釈には、2つの学派が存在します。

flowchart LR
    subgraph classical["古典学派(Classical School)"]
        C1["ユニット = 振る舞いの単位"]
        C2["複数クラスの連携もOK"]
        C3["DBなど共有依存のみモック化"]
    end
    
    subgraph mockist["モック学派(Mockist School)"]
        M1["ユニット = コードの単位(クラス)"]
        M2["テスト対象以外はすべてモック化"]
        M3["完全な分離を重視"]
    end
    
    style classical fill:#e8f5e9,stroke:#2e7d32,color:#000000
    style mockist fill:#e1f5fe,stroke:#01579b,color:#000000
学派 ユニットの定義 モック化の範囲 代表的な提唱者
古典学派 振る舞いの単位 共有依存(DB、外部API)のみ Kent Beck
モック学派 コードの単位(クラス) テスト対象以外すべて Steve Freeman

どちらが正しいということではありませんが、本記事では古典学派の考え方をベースに解説します。これは、テストがリファクタリングに強く、より実用的な品質保証ができるためです。

単体テストの目的とメリット

「なぜ単体テストを書くのか」を明確に理解することが、効果的なテスト設計の第一歩です。

単体テストの本質的な目的

単体テストの最も重要な目的は、ソフトウェアの持続可能な成長を支援することです。これは、以下の2つの側面から成り立ちます。

flowchart TB
    GOAL["単体テストの目的"]
    
    GOAL --> A["バグの早期発見"]
    GOAL --> B["リグレッション防止"]
    GOAL --> C["設計の改善"]
    GOAL --> D["ドキュメンテーション"]
    
    A --> A1["開発中に問題を検出<br/>修正コストを最小化"]
    B --> B1["既存機能の破壊を防止<br/>安全なリファクタリング"]
    C --> C1["テストしやすい設計への誘導<br/>責務の明確化"]
    D --> D1["コードの使い方を示す<br/>仕様の明文化"]
    
    style GOAL fill:#e1f5fe,stroke:#01579b,color:#000000
    style A fill:#e8f5e9,stroke:#2e7d32,color:#000000
    style B fill:#fff3e0,stroke:#e65100,color:#000000
    style C fill:#f3e5f5,stroke:#7b1fa2,color:#000000
    style D fill:#fce4ec,stroke:#c2185b,color:#000000

バグ発見コストの法則

バグは発見が遅れるほど修正コストが増大します。これは「バグ発見コストの法則」として知られています。

発見フェーズ 相対コスト
コーディング中 1x IDEのエラー表示
単体テスト 1.5x テスト失敗
結合テスト 3x API連携の不具合
システムテスト 10x 機能不全
本番稼働後 30-100x 顧客からのクレーム

単体テストは、このコスト曲線の最も左側(低コスト)でバグを発見する手段です。

単体テストの具体的なメリット

メリット1: 安心してリファクタリングできる

単体テストが整備されていれば、コードの改善を恐れる必要がありません。

1
2
3
4
5
6
7
8
9
リファクタリング前:
  - 既存機能が壊れないか不安
  - 手動テストに時間がかかる
  - 結果的に「動いているから触らない」状態に

リファクタリング後(テストあり):
  - テストが緑なら問題なし
  - 数秒で検証完了
  - 積極的にコードを改善できる

メリット2: デバッグ時間の短縮

テストが失敗すると、問題の箇所が特定しやすくなります。

1
2
3
4
5
テストなしの場合:
  バグ報告 → アプリ全体の調査 → 原因特定(数時間〜数日)

テストありの場合:
  テスト失敗 → 失敗したテストを確認 → 原因特定(数分)

メリット3: 仕様のドキュメント化

テストコードは「動くドキュメント」として機能します。

1
2
3
4
テストケース名: 「18歳以上の場合に成人と判定される」
  → この関数は年齢を受け取り、18歳以上で成人と判定することがわかる
  → 境界値が18歳であることがわかる
  → ドキュメントと違い、常に最新の仕様を反映する

メリット4: 設計品質の向上

テストを書こうとすると、テストしにくいコードの問題点が明らかになります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
テストしにくいコード:
  - グローバル状態に依存
  - 1つのメソッドで多くのことをしている
  - 外部システムと密結合

↓ テストを書こうとすると...

改善される設計:
  - 依存性注入の導入
  - 単一責任の原則に従う
  - インターフェースによる抽象化

良いテストの条件 - FIRST原則

良い単体テストを書くためのガイドラインとして、FIRST原則が広く知られています。

flowchart TB
    FIRST["FIRST原則"]
    
    FIRST --> F["Fast(高速)"]
    FIRST --> I["Independent(独立)"]
    FIRST --> R["Repeatable(再現可能)"]
    FIRST --> S["Self-Validating(自己検証)"]
    FIRST --> T["Timely(適時)"]
    
    F --> F1["ミリ秒単位で実行完了"]
    I --> I1["他のテストに依存しない"]
    R --> R1["環境に関わらず同じ結果"]
    S --> S1["成功/失敗を自動判定"]
    T --> T1["実装と同時期に書く"]
    
    style FIRST fill:#e1f5fe,stroke:#01579b,color:#000000

Fast(高速)

テストは高速でなければなりません。遅いテストは実行頻度が下がり、フィードバックが遅れます。

1
2
3
4
5
6
7
8
9
推奨される実行時間:
  - 1つのテスト: 10ms以下
  - テストスイート全体: 数秒〜数分以内

遅くなる原因:
  - 実際のデータベースアクセス
  - ネットワーク通信
  - ファイルI/O
  - sleep/wait処理

Independent(独立)

各テストは独立して実行できなければなりません。テスト間の依存関係は、デバッグを困難にします。

1
2
3
4
5
6
7
8
悪い例:
  テストA → テストB → テストC(順番に依存)
  テストAでデータ作成 → テストBで参照 → テストCで削除

良い例:
  各テストが自分の必要なデータを準備
  各テストが自分の後始末を行う
  どの順番で実行しても同じ結果

Repeatable(再現可能)

テストはいつ、どこで実行しても同じ結果を返さなければなりません。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
再現性を損なう要因:
  - 現在時刻への依存(Date.now()、LocalDate.now())
  - 乱数への依存
  - 外部APIの状態
  - 実行順序への依存

対策:
  - 時刻はテスト用に固定値を注入
  - 乱数はシードを固定
  - 外部依存はテストダブルで置換

Self-Validating(自己検証)

テストは自動的に成功/失敗を判定できなければなりません。人間が目で見て判断する必要があるテストは、自動化の恩恵を受けられません。

1
2
3
4
5
6
7
悪い例:
  console.log("結果を確認してください: " + result)
  → 人間が目視で確認が必要

良い例:
  assert(result === expected)
  → 自動で成功/失敗を判定

Timely(適時)

テストは適切なタイミングで書かれるべきです。実装のずっと後にテストを書くと、テストしにくい設計になっていることが多くなります。

1
2
3
4
5
6
7
理想的なタイミング:
  - TDD: 実装前にテストを書く
  - 最低限: 実装と同時にテストを書く

避けるべきパターン:
  - プロジェクト終盤にまとめてテストを書く
  - リリース後にテストを追加する

テストケース設計の基本原則

効果的なテストケースを設計するための基本原則を解説します。

AAA(Arrange-Act-Assert)パターン

テストの構造を明確にするために、AAA パターンを使用します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
Arrange(準備):
  - テスト対象のオブジェクトを生成
  - 必要なテストデータを準備
  - モック/スタブの設定

Act(実行):
  - テスト対象のメソッドを呼び出す
  - 原則として1回の呼び出しのみ

Assert(検証):
  - 期待する結果と実際の結果を比較
  - 例外のスローを検証
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// 疑似コード(言語非依存)
function testCalculateDiscount() {
    // Arrange(準備)
    customer = createPremiumCustomer()
    order = createOrder(amount: 10000)
    calculator = new DiscountCalculator()
    
    // Act(実行)
    discount = calculator.calculate(customer, order)
    
    // Assert(検証)
    assertEqual(discount, 1000)  // 10%割引
}

1テスト1アサーションの原則

1つのテストでは、1つの振る舞いのみを検証します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
悪い例(複数の振る舞いを検証):
  function testUserRegistration() {
      // ユーザー作成の検証
      user = createUser(name: "John")
      assertNotNull(user)
      
      // メール送信の検証
      assertEmailSent(to: user.email)
      
      // ログ出力の検証
      assertLogContains("User created")
  }
  
  問題点:
    - 最初のアサーションで失敗すると、後続は評価されない
    - 何をテストしているのかわかりにくい

良い例(1つの振る舞いを検証):
  function testCreateUserReturnsValidUser() {
      user = createUser(name: "John")
      assertNotNull(user)
      assertEqual(user.name, "John")
  }
  
  function testCreateUserSendsWelcomeEmail() {
      user = createUser(name: "John")
      assertEmailSent(to: user.email)
  }

テストの命名規則

テスト名は、何をテストしているかを明確に伝える必要があります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
推奨されるパターン:
  1. should_[期待する動作]_when_[条件]
     例: should_return_zero_when_input_is_empty
     
  2. [メソッド名]_[条件]_[期待結果]
     例: calculateTotal_withEmptyCart_returnsZero
     
  3. 日本語での記述(日本語対応の環境)
     例: 空のカートで合計金額を計算すると0円が返される

避けるべきパターン:
  - test1, test2(何をテストしているか不明)
  - testCalculate(どのケースか不明)
  - 実装の詳細を含む名前

境界値とエッジケースの扱い方

バグは境界条件で発生しやすいため、境界値のテストは特に重要です。

境界値分析

境界値分析では、有効範囲の境界とその前後をテストします。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
仕様: 年齢が18歳以上で成人と判定

境界値:
  - 17歳(境界の直前、未成年)
  - 18歳(境界上、成人)
  - 19歳(境界の直後、成人)

テストケース:
  年齢17 → 未成年
  年齢18 → 成人
  年齢19 → 成人
flowchart LR
    subgraph boundary["境界値テストの考え方"]
        B1["境界の直前<br/>(17歳 → 未成年)"]
        B2["境界上<br/>(18歳 → 成人)"]
        B3["境界の直後<br/>(19歳 → 成人)"]
    end
    
    B1 --> B2 --> B3
    
    style B1 fill:#ffebee,stroke:#c62828,color:#000000
    style B2 fill:#fff3e0,stroke:#e65100,color:#000000
    style B3 fill:#e8f5e9,stroke:#2e7d32,color:#000000

エッジケースの識別

一般的なエッジケースを意識してテストケースを設計します。

種類 エッジケースの例
数値 0、負数、最大値、最小値、オーバーフロー
文字列 空文字、null、空白のみ、非常に長い文字列
コレクション 空、1要素、大量要素、null要素を含む
日付 月末、閏年、タイムゾーン境界
ファイル 存在しない、空、読み取り権限なし
1
2
3
4
5
6
7
8
関数: リストの最大値を取得する

テストすべきエッジケース:
  - 空のリスト → 例外 or null
  - 1要素のリスト → その要素を返す
  - 全要素が同じ値 → その値を返す
  - 負の値を含む → 正しく比較される
  - 最大値が複数存在 → いずれかを返す

テスト容易性を高める設計

テストしやすいコードには共通の設計特性があります。

依存性注入(Dependency Injection)

外部依存を外から注入できるようにすることで、テスト時に代替実装を使用できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
悪い例(依存がハードコード):
  class OrderService {
      repository = new DatabaseRepository()  // 固定
      
      function createOrder(data) {
          return repository.save(data)
      }
  }
  
  問題点:
    - テスト時も実際のDBにアクセスしてしまう
    - DBがないとテストできない

良い例(依存性注入):
  class OrderService {
      constructor(repository) {  // 外部から注入
          this.repository = repository
      }
      
      function createOrder(data) {
          return repository.save(data)
      }
  }
  
  利点:
    - テスト時はモックを注入できる
    - 本番時は実際のDBリポジトリを注入

単一責任の原則(SRP)

1つのクラス/関数が1つの責任のみを持つようにします。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
悪い例(複数の責任):
  class UserManager {
      createUser(data)
      sendEmail(user)
      generateReport()
      validateCreditCard()
  }
  
  問題点:
    - テストの準備が複雑
    - 関係ない機能の変更で影響を受ける

良い例(責任の分離):
  class UserService { createUser(data) }
  class EmailService { sendEmail(user) }
  class ReportGenerator { generateReport() }
  class PaymentValidator { validateCreditCard() }
  
  利点:
    - 各クラスを独立してテスト可能
    - 変更の影響範囲が限定される

副作用の分離

計算ロジックと副作用(I/O、状態変更)を分離します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
悪い例(計算と副作用が混在):
  function processOrder(order) {
      total = calculateTotal(order.items)
      tax = total * 0.1
      finalPrice = total + tax
      
      database.save(order, finalPrice)  // 副作用
      email.send(order.customer)        // 副作用
      
      return finalPrice
  }

良い例(分離):
  // 純粋な計算(テストしやすい)
  function calculateFinalPrice(items) {
      total = calculateTotal(items)
      tax = total * 0.1
      return total + tax
  }
  
  // 副作用を含む調整役
  function processOrder(order) {
      finalPrice = calculateFinalPrice(order.items)
      database.save(order, finalPrice)
      email.send(order.customer)
      return finalPrice
  }

テストの自動化とCI/CD

単体テストの価値を最大化するには、継続的インテグレーション(CI)との連携が不可欠です。

テスト自動化の段階

flowchart LR
    subgraph stages["テスト自動化の成熟度"]
        L1["レベル1<br/>手動実行"]
        L2["レベル2<br/>ローカル自動実行"]
        L3["レベル3<br/>CI連携"]
        L4["レベル4<br/>品質ゲート"]
    end
    
    L1 --> L2 --> L3 --> L4
    
    style L1 fill:#ffebee,stroke:#c62828,color:#000000
    style L2 fill:#fff3e0,stroke:#e65100,color:#000000
    style L3 fill:#e8f5e9,stroke:#2e7d32,color:#000000
    style L4 fill:#e1f5fe,stroke:#01579b,color:#000000
レベル 説明 特徴
レベル1 手動実行 開発者が意識的にテストを実行
レベル2 ローカル自動実行 ファイル保存時に自動実行
レベル3 CI連携 コミット/プッシュ時にCIで自動実行
レベル4 品質ゲート テスト失敗でマージをブロック

CIパイプラインでの単体テストの位置づけ

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
典型的なCIパイプライン:
  
  1. コードチェックアウト
  2. 依存関係のインストール
  3. 静的解析(リンター)
  4. 単体テスト実行 ← ここ
  5. カバレッジレポート生成
  6. 結合テスト(必要に応じて)
  7. ビルド
  8. デプロイ(条件付き)

カバレッジの考え方

コードカバレッジは品質の指標の1つですが、唯一の指標ではありません。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
カバレッジの種類:
  - 行カバレッジ: 実行された行の割合
  - 分岐カバレッジ: 実行された分岐の割合
  - 条件カバレッジ: 各条件の真偽両方をテストした割合

カバレッジの落とし穴:
  - 100%でもバグがないとは限らない
  - 数値を上げることが目的化する危険性
  - 意味のないテストでも数値は上がる

推奨アプローチ:
  - 目安として70-80%を目標に
  - 重要なビジネスロジックは重点的にカバー
  - カバレッジより「意味のあるテスト」を優先

良いテストと悪いテストの比較

具体的な例で、良いテストと悪いテストの違いを理解しましょう。

悪いテストの特徴

パターン1: 実装の詳細に依存するテスト

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
悪い例:
  function testCalculateTotal() {
      cart = new ShoppingCart()
      cart.addItem(item)
      
      // 内部実装に依存
      assert(cart._items.length == 1)
      assert(cart._totalCache == 100)
  }

問題点:
  - privateフィールドに直接アクセス
  - 内部の実装方法を変更するとテストが壊れる
  - リファクタリングを阻害する

良い例:
  function testCalculateTotal() {
      cart = new ShoppingCart()
      cart.addItem(item)
      
      // 公開インターフェースを検証
      assert(cart.getItemCount() == 1)
      assert(cart.getTotal() == 100)
  }

パターン2: 過度にモックを使用するテスト

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
悪い例:
  function testProcessOrder() {
      // すべてをモック化
      mockRepository = createMock()
      mockValidator = createMock()
      mockCalculator = createMock()
      mockNotifier = createMock()
      
      mockCalculator.returns(100)
      mockValidator.returns(true)
      
      service = new OrderService(
          mockRepository, mockValidator, 
          mockCalculator, mockNotifier
      )
      
      result = service.process(order)
      
      assert(result == 100)
  }

問題点:
  - テストが本番コードの構造に完全に依存
  - モックの設定が複雑で読みにくい
  - 実際の連携をテストしていない

良い例:
  function testProcessOrder() {
      // 外部システムのみモック化
      mockPaymentGateway = createMock()
      
      service = new OrderService(mockPaymentGateway)
      result = service.process(order)
      
      assert(result.status == "completed")
  }

パターン3: 脆いテスト(Fragile Test)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
悪い例:
  function testGetUserDisplayName() {
      user = service.getUser(id)
      
      // 完全一致で検証
      assert(user.displayName == "John Smith (Premium Member since 2020)")
  }

問題点:
  - 日付フォーマットの変更で壊れる
  - 表現の微調整で壊れる
  - 本質的でない変更に敏感

良い例:
  function testGetUserDisplayName() {
      user = service.getUser(id)
      
      // 本質的な部分のみ検証
      assert(user.displayName.contains("John Smith"))
      assert(user.isPremium == true)
  }

良いテストの特徴まとめ

特徴 説明
振る舞いをテスト 実装ではなく、外部から見た振る舞いを検証
読みやすい テスト名と構造から意図が明確
壊れにくい リファクタリングで壊れない
診断しやすい 失敗時に原因がわかりやすい
保守しやすい 仕様変更時の修正箇所が限定的

テストにおけるよくある失敗と対策

単体テストで陥りやすい失敗パターンと、その対策を解説します。

失敗1: テストを書く時間がない

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
症状:
  - 「締め切りが近いのでテストは後で」
  - 結果的に永遠に書かれない
  
原因:
  - テストを「追加作業」と捉えている
  - 短期的なコスト削減を優先
  
対策:
  - テストは実装の一部と認識する
  - 「テストなしでは完了ではない」という基準を設ける
  - 最初は重要な部分だけでも書く習慣をつける

失敗2: テストが頻繁に壊れる

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
症状:
  - リファクタリングのたびにテストを修正
  - テストの修正に実装以上の時間がかかる
  
原因:
  - 実装の詳細に依存したテスト
  - 過度なモック化
  
対策:
  - 公開インターフェースのみをテスト
  - モックは共有依存(DB、外部API)に限定
  - テストと実装を同時に設計

失敗3: テストがあるのにバグが見つからない

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
症状:
  - カバレッジは高いのにバグが発生
  - テストをパスしても本番で問題が起きる
  
原因:
  - ハッピーパスのみをテスト
  - 境界値やエッジケースの不足
  - アサーションが不十分
  
対策:
  - 境界値分析を適用
  - エラーケースを積極的にテスト
  - 「このテストは何を守っているか」を常に意識

失敗4: テストが遅すぎる

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
症状:
  - テストスイートの実行に数十分かかる
  - 開発者がテストを実行しなくなる
  
原因:
  - 単体テストで実際のDB/ネットワークを使用
  - テストの粒度が大きすぎる
  
対策:
  - 外部依存はテストダブルで置換
  - テストピラミッドを意識(単体テストを多く、E2Eを少なく)
  - 遅いテストは別のテストスイートに分離

単体テストの導入戦略

既存プロジェクトにテストを導入する際の現実的な戦略を解説します。

段階的な導入アプローチ

flowchart TB
    START["テスト導入開始"]
    
    START --> STEP1["ステップ1: 新規コードから開始"]
    STEP1 --> STEP2["ステップ2: バグ修正時にテスト追加"]
    STEP2 --> STEP3["ステップ3: 重要モジュールを優先"]
    STEP3 --> STEP4["ステップ4: カバレッジを段階的に向上"]
    
    STEP1 -.-> N1["新しいコードには必ずテストを書く"]
    STEP2 -.-> N2["バグ再発防止のためのテスト"]
    STEP3 -.-> N3["ビジネス上重要な機能を優先"]
    STEP4 -.-> N4["70-80%を目標に"]
    
    style START fill:#e1f5fe,stroke:#01579b,color:#000000
    style STEP1 fill:#e8f5e9,stroke:#2e7d32,color:#000000
    style STEP2 fill:#fff3e0,stroke:#e65100,color:#000000
    style STEP3 fill:#f3e5f5,stroke:#7b1fa2,color:#000000
    style STEP4 fill:#fce4ec,stroke:#c2185b,color:#000000

優先順位の決め方

すべてのコードに同じ優先度でテストを書く必要はありません。

優先度 対象 理由
ビジネスロジックの中核 バグの影響が大きい
頻繁に変更される部分 リグレッションリスクが高い
複雑な条件分岐 バグが発生しやすい
外部連携部分 問題の切り分けに有用
単純なデータ変換 バグの可能性が低い
フレームワークのラッパー フレームワーク側でテスト済み

レガシーコードへのテスト追加

既存コードにテストを追加する場合は、以下の手順が効果的です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
1. 特性化テスト(Characterization Test)を書く
   - 現在の動作を記録するテスト
   - 正しいかどうかは問わない
   
2. テストが通ることを確認
   - 現状の動作がドキュメント化される
   
3. リファクタリングを行う
   - テストが安全網になる
   
4. テストを仕様に合わせて修正
   - 特性化テストを正規のテストに変換

まとめ

本記事では、単体テストの考え方について、言語に依存しない普遍的な観点から解説しました。

重要なポイントを振り返りましょう。

単体テストの本質

  • 最小単位の振る舞いを検証するテスト
  • 高速・独立・再現可能・自己検証・適時の5要素(FIRST原則)
  • 目的は「持続可能なソフトウェア開発の支援」

テストケース設計

  • AAA(Arrange-Act-Assert)パターンで構造化
  • 1テスト1振る舞いの原則
  • 境界値とエッジケースの重点的なテスト

テスト容易性

  • 依存性注入による疎結合設計
  • 単一責任の原則による責務の分離
  • 副作用と計算ロジックの分離

よくある失敗と対策

  • 実装の詳細ではなく振る舞いをテスト
  • モックは共有依存に限定
  • カバレッジよりも「意味のあるテスト」を優先

単体テストは「書けば終わり」ではなく、チームの文化として定着させることが重要です。最初は小さく始め、徐々にテスト習慣を広げていくことで、持続可能な開発プロセスを構築できます。

参考リンク