はじめに

「テストを書いているのに、なぜバグが見逃されるのか」「どこまでテストすれば十分なのか」という悩みは、多くの開発者が抱える共通の課題です。その原因の多くは、テスト設計の不足にあります。

テスト設計とは、限られたリソースの中で最大限の欠陥検出効果を得るために、何をテストするかを体系的に決定するプロセスです。闘雲にテストケースを増やしても、重複したパターンをテストしているだけでは効果は上がりません。

本記事では、JSTQB(Japan Software Testing Qualifications Board)/ISTQBのFoundation Levelシラバスに準拠したテスト設計技法の基本パターンを解説します。これらの技法を習得することで、少ないテストケースで高いカバレッジを実現し、見落としがちなバグを効率的に発見できるようになります。

テスト設計技法の分類

テスト設計技法は、大きく分けてブラックボックステスト技法ホワイトボックステスト技法に分類されます。

flowchart TB
    A[テスト設計技法] --> B[ブラックボックステスト技法]
    A --> C[ホワイトボックステスト技法]
    A --> D[経験ベースのテスト技法]
    
    B --> B1[同値分割法]
    B --> B2[境界値分析]
    B --> B3[デシジョンテーブル]
    B --> B4[状態遷移テスト]
    
    C --> C1[ステートメントカバレッジ]
    C --> C2[ブランチカバレッジ]
    C --> C3[条件カバレッジ]
    
    D --> D1[エラー推測]
    D --> D2[探索的テスト]
    
    style A fill:#e1f5fe,stroke:#01579b,color:#000000
    style B fill:#fff3e0,stroke:#e65100,color:#000000
    style C fill:#f3e5f5,stroke:#7b1fa2,color:#000000
    style D fill:#e8f5e9,stroke:#2e7d32,color:#000000
分類 特徴 主な技法
ブラックボックステスト 内部構造を知らずに仕様から導出 同値分割、境界値分析、デシジョンテーブル
ホワイトボックステスト 内部構造(コード)を分析して導出 ステートメントカバレッジ、ブランチカバレッジ
経験ベースのテスト テスト担当者の経験と直感を活用 エラー推測、探索的テスト

本記事では、実務で最も活用頻度が高いブラックボックステスト技法を中心に解説します。

同値分割法(Equivalence Partitioning)

同値分割法とは

同値分割法は、入力データを**同じ振る舞いをするグループ(同値クラス)**に分割し、各グループから代表値を選んでテストする技法です。同じ同値クラス内のデータは同じ処理結果になると仮定し、テストケース数を削減します。

同値クラスの分類

同値クラスは、大きく分けて有効同値クラス無効同値クラスに分類されます。

分類 説明 例(年齢入力の場合)
有効同値クラス 仕様で許容される正常な入力 0〜120の整数
無効同値クラス 仕様で許容されない不正な入力 負の数、121以上、文字列

実践例: 年齢による料金区分

映画館の料金区分を判定する関数を例に、同値分割法を適用してみましょう。

仕様:

  • 0〜5歳: 無料
  • 6〜12歳: 子供料金(500円)
  • 13〜64歳: 一般料金(1,800円)
  • 65歳以上: シニア料金(1,200円)
  • 負の数や不正な値: エラー

同値クラスの特定:

flowchart LR
    subgraph invalid["無効同値クラス"]
        I1["負の数<br/>例: -1"]
        I2["非数値<br/>例: 'abc'"]
    end
    
    subgraph valid["有効同値クラス"]
        V1["0〜5歳<br/>無料"]
        V2["6〜12歳<br/>子供料金"]
        V3["13〜64歳<br/>一般料金"]
        V4["65歳以上<br/>シニア料金"]
    end
    
    style invalid fill:#ffebee,stroke:#c62828,color:#000000
    style valid fill:#e8f5e9,stroke:#2e7d32,color:#000000

JavaScript実装:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// priceCalculator.js
function calculateTicketPrice(age) {
  if (typeof age !== 'number' || isNaN(age)) {
    throw new Error('年齢は数値で入力してください');
  }
  
  if (age < 0) {
    throw new Error('年齢は0以上の値を入力してください');
  }
  
  if (age <= 5) {
    return 0;       // 無料
  } else if (age <= 12) {
    return 500;     // 子供料金
  } else if (age <= 64) {
    return 1800;    // 一般料金
  } else {
    return 1200;    // シニア料金
  }
}

module.exports = { calculateTicketPrice };

テストコード(Jest):

 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
// priceCalculator.test.js
const { calculateTicketPrice } = require('./priceCalculator');

describe('calculateTicketPrice - 同値分割法によるテスト', () => {
  // 有効同値クラス: 無料(0〜5歳)
  test('3歳の場合、無料(0円)を返す', () => {
    expect(calculateTicketPrice(3)).toBe(0);
  });

  // 有効同値クラス: 子供料金(6〜12歳)
  test('9歳の場合、子供料金(500円)を返す', () => {
    expect(calculateTicketPrice(9)).toBe(500);
  });

  // 有効同値クラス: 一般料金(13〜64歳)
  test('30歳の場合、一般料金(1,800円)を返す', () => {
    expect(calculateTicketPrice(30)).toBe(1800);
  });

  // 有効同値クラス: シニア料金(65歳以上)
  test('70歳の場合、シニア料金(1,200円)を返す', () => {
    expect(calculateTicketPrice(70)).toBe(1200);
  });

  // 無効同値クラス: 負の数
  test('負の数の場合、エラーをスローする', () => {
    expect(() => calculateTicketPrice(-1)).toThrow('年齢は0以上の値を入力してください');
  });

  // 無効同値クラス: 非数値
  test('文字列の場合、エラーをスローする', () => {
    expect(() => calculateTicketPrice('abc')).toThrow('年齢は数値で入力してください');
  });
});

Java実装:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// PriceCalculator.java
public class PriceCalculator {
    
    public int calculateTicketPrice(Integer age) {
        if (age == null) {
            throw new IllegalArgumentException("年齢は数値で入力してください");
        }
        
        if (age < 0) {
            throw new IllegalArgumentException("年齢は0以上の値を入力してください");
        }
        
        if (age <= 5) {
            return 0;       // 無料
        } else if (age <= 12) {
            return 500;     // 子供料金
        } else if (age <= 64) {
            return 1800;    // 一般料金
        } else {
            return 1200;    // シニア料金
        }
    }
}

テストコード(JUnit 5):

 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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// PriceCalculatorTest.java
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.DisplayName;
import static org.junit.jupiter.api.Assertions.*;

class PriceCalculatorTest {
    
    private final PriceCalculator calculator = new PriceCalculator();
    
    @Test
    @DisplayName("3歳の場合、無料(0円)を返す")
    void shouldReturnFreeForAge3() {
        assertEquals(0, calculator.calculateTicketPrice(3));
    }
    
    @Test
    @DisplayName("9歳の場合、子供料金(500円)を返す")
    void shouldReturnChildPriceForAge9() {
        assertEquals(500, calculator.calculateTicketPrice(9));
    }
    
    @Test
    @DisplayName("30歳の場合、一般料金(1,800円)を返す")
    void shouldReturnAdultPriceForAge30() {
        assertEquals(1800, calculator.calculateTicketPrice(30));
    }
    
    @Test
    @DisplayName("70歳の場合、シニア料金(1,200円)を返す")
    void shouldReturnSeniorPriceForAge70() {
        assertEquals(1200, calculator.calculateTicketPrice(70));
    }
    
    @Test
    @DisplayName("負の数の場合、例外をスローする")
    void shouldThrowExceptionForNegativeAge() {
        IllegalArgumentException exception = assertThrows(
            IllegalArgumentException.class,
            () -> calculator.calculateTicketPrice(-1)
        );
        assertEquals("年齢は0以上の値を入力してください", exception.getMessage());
    }
    
    @Test
    @DisplayName("nullの場合、例外をスローする")
    void shouldThrowExceptionForNull() {
        IllegalArgumentException exception = assertThrows(
            IllegalArgumentException.class,
            () -> calculator.calculateTicketPrice(null)
        );
        assertEquals("年齢は数値で入力してください", exception.getMessage());
    }
}

同値分割法のポイント

同値分割法を効果的に活用するためのポイントは以下のとおりです。

  1. 有効同値クラスと無効同値クラスの両方を洗い出す: 正常系だけでなく、異常系のテストも重要
  2. 各同値クラスから最低1つの代表値を選ぶ: クラスの中央付近の値を選ぶことが多い
  3. 同値クラスの境界は境界値分析で補完する: 同値分割法だけでは境界のバグを見逃しやすい

境界値分析(Boundary Value Analysis)

境界値分析とは

境界値分析は、同値クラスの境界(端)の値に着目してテストケースを設計する技法です。経験的に、バグは境界付近で発生しやすいため、境界値を重点的にテストすることで欠陥検出率を高めます。

off-by-oneエラーの検出

境界値分析が特に有効なのは、off-by-oneエラー(1つずれのバグ)の検出です。

1
2
3
4
5
// バグのあるコード例
function isAdult(age) {
  // 本来は >= 18 だが、誤って > 18 と書いてしまった
  return age > 18;  // 18歳が成人と判定されないバグ
}

このようなバグは、同値クラスの中央付近の値(例: 25歳)ではテストをパスしてしまいますが、境界値(18歳)をテストすれば発見できます。

2値境界値分析と3値境界値分析

境界値分析には、テストする境界値の数によって2つのアプローチがあります。

アプローチ テストする値 テストケース数
2値境界値分析 境界値のみ 少ない
3値境界値分析 境界値とその前後 多い(より厳密)

例: 0〜5歳が無料の場合

アプローチ テスト対象の値
2値境界値分析 5, 6
3値境界値分析 4, 5, 6

実践例: 年齢による料金区分(境界値分析版)

先ほどの料金区分の例に境界値分析を適用します。

境界値の特定:

境界 下限値 上限値 上限+1
無料/子供 0 5 6
子供/一般 6 12 13
一般/シニア 13 64 65

JavaScript テストコード(Jest):

 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
37
38
39
40
41
42
43
44
// priceCalculator.boundary.test.js
const { calculateTicketPrice } = require('./priceCalculator');

describe('calculateTicketPrice - 境界値分析によるテスト', () => {
  describe('無料と子供料金の境界(5歳/6歳)', () => {
    test('5歳の場合、無料(0円)を返す', () => {
      expect(calculateTicketPrice(5)).toBe(0);
    });

    test('6歳の場合、子供料金(500円)を返す', () => {
      expect(calculateTicketPrice(6)).toBe(500);
    });
  });

  describe('子供料金と一般料金の境界(12歳/13歳)', () => {
    test('12歳の場合、子供料金(500円)を返す', () => {
      expect(calculateTicketPrice(12)).toBe(500);
    });

    test('13歳の場合、一般料金(1,800円)を返す', () => {
      expect(calculateTicketPrice(13)).toBe(1800);
    });
  });

  describe('一般料金とシニア料金の境界(64歳/65歳)', () => {
    test('64歳の場合、一般料金(1,800円)を返す', () => {
      expect(calculateTicketPrice(64)).toBe(1800);
    });

    test('65歳の場合、シニア料金(1,200円)を返す', () => {
      expect(calculateTicketPrice(65)).toBe(1200);
    });
  });

  describe('下限の境界(0歳/-1)', () => {
    test('0歳の場合、無料(0円)を返す', () => {
      expect(calculateTicketPrice(0)).toBe(0);
    });

    test('-1の場合、エラーをスローする', () => {
      expect(() => calculateTicketPrice(-1)).toThrow();
    });
  });
});

Java テストコード(JUnit 5 Parameterized Test):

 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
// PriceCalculatorBoundaryTest.java
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import static org.junit.jupiter.api.Assertions.*;

class PriceCalculatorBoundaryTest {
    
    private final PriceCalculator calculator = new PriceCalculator();
    
    @ParameterizedTest
    @DisplayName("境界値テスト: 年齢と料金の対応")
    @CsvSource({
        "0, 0",      // 下限境界: 無料
        "5, 0",      // 無料/子供の境界
        "6, 500",    // 無料/子供の境界
        "12, 500",   // 子供/一般の境界
        "13, 1800",  // 子供/一般の境界
        "64, 1800",  // 一般/シニアの境界
        "65, 1200"   // 一般/シニアの境界
    })
    void shouldReturnCorrectPriceForBoundaryValues(int age, int expectedPrice) {
        assertEquals(expectedPrice, calculator.calculateTicketPrice(age));
    }
}

境界値分析のポイント

  1. すべての境界を洗い出す: 見落としがちな「最小値-1」「最大値+1」も忘れずに
  2. 同値分割法と組み合わせる: 境界値だけでなく、代表値もテストする
  3. 複数の入力がある場合は組み合わせを考慮: 各入力の境界値を組み合わせてテストする

デシジョンテーブル(Decision Table)

デシジョンテーブルとは

デシジョンテーブルは、複数の条件の組み合わせとそれに対応する動作を表形式で整理する技法です。条件が複雑に絡み合う仕様を網羅的にテストする際に有効です。

デシジョンテーブルの構造

デシジョンテーブルは以下の4つの部分で構成されます。

部分 説明
条件スタブ 判断に使用する条件の一覧
条件エントリ 各ルールにおける条件の真偽値
動作スタブ 実行される動作の一覧
動作エントリ 各ルールで実行される動作
flowchart TB
    subgraph table["デシジョンテーブルの構造"]
        direction TB
        subgraph header["条件部"]
            C1["条件スタブ"]
            C2["条件エントリ"]
        end
        subgraph body["動作部"]
            A1["動作スタブ"]
            A2["動作エントリ"]
        end
    end
    
    style table fill:#fff8e1,stroke:#f57f17,color:#000000
    style header fill:#e3f2fd,stroke:#1565c0,color:#000000
    style body fill:#f3e5f5,stroke:#7b1fa2,color:#000000

実践例: ECサイトの送料計算

以下の仕様を持つ送料計算ロジックをテストします。

仕様:

  • 会員は送料無料
  • 非会員でも5,000円以上購入で送料無料
  • 非会員で5,000円未満の場合、地域により送料が異なる
    • 本州: 500円
    • 北海道・沖縄: 1,000円

デシジョンテーブル:

ルール 1 2 3 4
条件
会員である Y N N N
購入金額 >= 5,000円 - Y N N
本州である - - Y N
動作
送料無料 X X
送料500円 X
送料1,000円 X

JavaScript実装:

 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
// shippingCalculator.js
function calculateShippingFee(options) {
  const { isMember, purchaseAmount, region } = options;
  
  // 入力値の検証
  if (typeof isMember !== 'boolean') {
    throw new Error('会員フラグは真偽値で指定してください');
  }
  if (typeof purchaseAmount !== 'number' || purchaseAmount < 0) {
    throw new Error('購入金額は0以上の数値で指定してください');
  }
  if (!['本州', '北海道', '沖縄'].includes(region)) {
    throw new Error('地域は「本州」「北海道」「沖縄」のいずれかを指定してください');
  }
  
  // ルール1: 会員は送料無料
  if (isMember) {
    return 0;
  }
  
  // ルール2: 5,000円以上購入で送料無料
  if (purchaseAmount >= 5000) {
    return 0;
  }
  
  // ルール3 & 4: 地域による送料
  if (region === '本州') {
    return 500;
  } else {
    return 1000;  // 北海道・沖縄
  }
}

module.exports = { calculateShippingFee };

テストコード(Jest):

 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
37
38
39
40
41
42
43
44
45
46
47
48
49
// shippingCalculator.test.js
const { calculateShippingFee } = require('./shippingCalculator');

describe('calculateShippingFee - デシジョンテーブルによるテスト', () => {
  // ルール1: 会員は送料無料
  test('会員の場合、購入金額・地域に関わらず送料無料', () => {
    expect(calculateShippingFee({
      isMember: true,
      purchaseAmount: 1000,
      region: '北海道'
    })).toBe(0);
  });

  // ルール2: 非会員でも5,000円以上で送料無料
  test('非会員で5,000円以上購入の場合、送料無料', () => {
    expect(calculateShippingFee({
      isMember: false,
      purchaseAmount: 5000,
      region: '沖縄'
    })).toBe(0);
  });

  // ルール3: 非会員、5,000円未満、本州
  test('非会員で5,000円未満、本州の場合、送料500円', () => {
    expect(calculateShippingFee({
      isMember: false,
      purchaseAmount: 3000,
      region: '本州'
    })).toBe(500);
  });

  // ルール4: 非会員、5,000円未満、北海道
  test('非会員で5,000円未満、北海道の場合、送料1,000円', () => {
    expect(calculateShippingFee({
      isMember: false,
      purchaseAmount: 3000,
      region: '北海道'
    })).toBe(1000);
  });

  // ルール4: 非会員、5,000円未満、沖縄
  test('非会員で5,000円未満、沖縄の場合、送料1,000円', () => {
    expect(calculateShippingFee({
      isMember: false,
      purchaseAmount: 3000,
      region: '沖縄'
    })).toBe(1000);
  });
});

Java テストコード(JUnit 5):

 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
37
38
39
40
41
42
43
44
// ShippingCalculatorTest.java
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.DisplayName;
import static org.junit.jupiter.api.Assertions.*;

class ShippingCalculatorTest {
    
    private final ShippingCalculator calculator = new ShippingCalculator();
    
    @Test
    @DisplayName("ルール1: 会員は送料無料")
    void shouldReturnFreeShippingForMember() {
        int fee = calculator.calculateShippingFee(true, 1000, "北海道");
        assertEquals(0, fee);
    }
    
    @Test
    @DisplayName("ルール2: 非会員でも5,000円以上で送料無料")
    void shouldReturnFreeShippingForLargePurchase() {
        int fee = calculator.calculateShippingFee(false, 5000, "沖縄");
        assertEquals(0, fee);
    }
    
    @Test
    @DisplayName("ルール3: 非会員、5,000円未満、本州は500円")
    void shouldReturn500ForNonMemberHonshu() {
        int fee = calculator.calculateShippingFee(false, 3000, "本州");
        assertEquals(500, fee);
    }
    
    @Test
    @DisplayName("ルール4: 非会員、5,000円未満、北海道は1,000円")
    void shouldReturn1000ForNonMemberHokkaido() {
        int fee = calculator.calculateShippingFee(false, 3000, "北海道");
        assertEquals(1000, fee);
    }
    
    @Test
    @DisplayName("ルール4: 非会員、5,000円未満、沖縄は1,000円")
    void shouldReturn1000ForNonMemberOkinawa() {
        int fee = calculator.calculateShippingFee(false, 3000, "沖縄");
        assertEquals(1000, fee);
    }
}

デシジョンテーブルの最適化

条件数が増えると、ルールの数は指数関数的に増加します(条件がn個なら最大2^n個)。以下のテクニックでテストケース数を削減できます。

  1. 「-」(don’t care)の活用: ある条件が結果に影響しない場合は省略
  2. ルールの統合: 同じ動作をするルールをまとめる
  3. 不可能な組み合わせの除外: 論理的にありえない条件の組み合わせを除外

状態遷移テスト(State Transition Testing)

状態遷移テストとは

状態遷移テストは、システムが持つ状態状態間の遷移を図式化し、各遷移をテストする技法です。ログイン処理、注文処理、ワークフローなど、状態を持つ機能のテストに適しています。

状態遷移図と状態遷移表

状態遷移を可視化するには、状態遷移図または状態遷移表を使用します。

stateDiagram-v2
    [*] --> ログアウト
    ログアウト --> ログイン試行中: ログイン要求
    ログイン試行中 --> ログイン済み: 認証成功
    ログイン試行中 --> ログアウト: 認証失敗(1-2回目)
    ログイン試行中 --> アカウントロック: 認証失敗(3回目)
    ログイン済み --> ログアウト: ログアウト要求
    アカウントロック --> ログアウト: ロック解除

状態遷移表:

現在の状態 イベント 次の状態 条件/動作
ログアウト ログイン要求 ログイン試行中
ログイン試行中 認証成功 ログイン済み 失敗回数リセット
ログイン試行中 認証失敗 ログアウト 失敗回数 < 3
ログイン試行中 認証失敗 アカウントロック 失敗回数 = 3
ログイン済み ログアウト要求 ログアウト
アカウントロック ロック解除 ログアウト 失敗回数リセット

実践例: ログイン機能

JavaScript実装:

 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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
// loginStateMachine.js
class LoginStateMachine {
  constructor() {
    this.state = 'LOGGED_OUT';
    this.failureCount = 0;
  }
  
  getState() {
    return this.state;
  }
  
  login(password) {
    if (this.state === 'ACCOUNT_LOCKED') {
      throw new Error('アカウントがロックされています');
    }
    
    if (this.state === 'LOGGED_IN') {
      throw new Error('すでにログイン済みです');
    }
    
    this.state = 'LOGIN_ATTEMPTING';
    
    // 認証処理(簡略化のため固定パスワード)
    if (password === 'correct_password') {
      this.state = 'LOGGED_IN';
      this.failureCount = 0;
      return { success: true, message: 'ログイン成功' };
    } else {
      this.failureCount++;
      if (this.failureCount >= 3) {
        this.state = 'ACCOUNT_LOCKED';
        return { success: false, message: 'アカウントがロックされました' };
      } else {
        this.state = 'LOGGED_OUT';
        return { 
          success: false, 
          message: `認証失敗(${this.failureCount}/3回)` 
        };
      }
    }
  }
  
  logout() {
    if (this.state !== 'LOGGED_IN') {
      throw new Error('ログインしていません');
    }
    this.state = 'LOGGED_OUT';
    return { success: true, message: 'ログアウトしました' };
  }
  
  unlockAccount() {
    if (this.state !== 'ACCOUNT_LOCKED') {
      throw new Error('アカウントはロックされていません');
    }
    this.state = 'LOGGED_OUT';
    this.failureCount = 0;
    return { success: true, message: 'アカウントのロックを解除しました' };
  }
}

module.exports = { LoginStateMachine };

テストコード(Jest):

  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
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
// loginStateMachine.test.js
const { LoginStateMachine } = require('./loginStateMachine');

describe('LoginStateMachine - 状態遷移テスト', () => {
  let machine;
  
  beforeEach(() => {
    machine = new LoginStateMachine();
  });
  
  describe('正常な状態遷移', () => {
    test('ログアウト状態から正しいパスワードでログイン済み状態に遷移', () => {
      expect(machine.getState()).toBe('LOGGED_OUT');
      
      const result = machine.login('correct_password');
      
      expect(result.success).toBe(true);
      expect(machine.getState()).toBe('LOGGED_IN');
    });
    
    test('ログイン済み状態からログアウト状態に遷移', () => {
      machine.login('correct_password');
      expect(machine.getState()).toBe('LOGGED_IN');
      
      const result = machine.logout();
      
      expect(result.success).toBe(true);
      expect(machine.getState()).toBe('LOGGED_OUT');
    });
  });
  
  describe('認証失敗時の状態遷移', () => {
    test('1回目の認証失敗でログアウト状態に戻る', () => {
      const result = machine.login('wrong_password');
      
      expect(result.success).toBe(false);
      expect(result.message).toContain('1/3回');
      expect(machine.getState()).toBe('LOGGED_OUT');
    });
    
    test('3回連続で認証失敗するとアカウントロック状態に遷移', () => {
      machine.login('wrong_password');
      machine.login('wrong_password');
      const result = machine.login('wrong_password');
      
      expect(result.success).toBe(false);
      expect(result.message).toBe('アカウントがロックされました');
      expect(machine.getState()).toBe('ACCOUNT_LOCKED');
    });
  });
  
  describe('アカウントロック解除', () => {
    test('ロック解除でログアウト状態に遷移', () => {
      // 3回失敗してロック状態にする
      machine.login('wrong_password');
      machine.login('wrong_password');
      machine.login('wrong_password');
      expect(machine.getState()).toBe('ACCOUNT_LOCKED');
      
      const result = machine.unlockAccount();
      
      expect(result.success).toBe(true);
      expect(machine.getState()).toBe('LOGGED_OUT');
    });
    
    test('ロック解除後に再度ログイン可能', () => {
      // ロック → 解除 → ログイン
      machine.login('wrong_password');
      machine.login('wrong_password');
      machine.login('wrong_password');
      machine.unlockAccount();
      
      const result = machine.login('correct_password');
      
      expect(result.success).toBe(true);
      expect(machine.getState()).toBe('LOGGED_IN');
    });
  });
  
  describe('無効な状態遷移のエラー処理', () => {
    test('ロック状態でログイン試行するとエラー', () => {
      machine.login('wrong_password');
      machine.login('wrong_password');
      machine.login('wrong_password');
      
      expect(() => machine.login('correct_password'))
        .toThrow('アカウントがロックされています');
    });
    
    test('ログインしていない状態でログアウト試行するとエラー', () => {
      expect(() => machine.logout())
        .toThrow('ログインしていません');
    });
    
    test('ロックされていない状態でロック解除するとエラー', () => {
      expect(() => machine.unlockAccount())
        .toThrow('アカウントはロックされていません');
    });
  });
});

状態遷移テストのカバレッジ基準

状態遷移テストには、以下のカバレッジ基準があります。

基準 説明 強度
全状態カバレッジ すべての状態を少なくとも1回通過
全遷移カバレッジ すべての遷移を少なくとも1回実行
全遷移ペアカバレッジ 連続する2つの遷移の組み合わせをすべて実行

実務では全遷移カバレッジを最低限の基準とし、重要な機能には全遷移ペアカバレッジを適用することを推奨します。

テスト設計技法の使い分け

各テスト設計技法には得意・不得意があります。状況に応じて適切な技法を選択しましょう。

技法 適用シーン 不向きなシーン
同値分割法 入力が明確に分類できる場合 条件の組み合わせが複雑な場合
境界値分析 数値範囲や日付範囲がある場合 境界が明確でない場合
デシジョンテーブル 複数条件の組み合わせがある場合 条件数が多すぎる場合(爆発する)
状態遷移テスト 状態を持つ機能の場合 状態がない単純な関数の場合

技法の組み合わせ

実際のテスト設計では、複数の技法を組み合わせて使用します。

flowchart LR
    A[要件分析] --> B{入力の性質}
    B -->|数値範囲あり| C[同値分割法 + 境界値分析]
    B -->|条件の組み合わせ| D[デシジョンテーブル]
    B -->|状態を持つ| E[状態遷移テスト]
    C --> F[テストケース作成]
    D --> F
    E --> F
    F --> G[経験ベースで補完]
    
    style A fill:#e3f2fd,stroke:#1565c0,color:#000000
    style F fill:#e8f5e9,stroke:#2e7d32,color:#000000
    style G fill:#fff3e0,stroke:#e65100,color:#000000

まとめ

本記事では、テスト設計の基本パターンとして以下の4つの技法を解説しました。

技法 目的 主なメリット
同値分割法 入力をグループ化してテスト数を削減 テストケースの重複を排除
境界値分析 境界付近のバグを検出 off-by-oneエラーの発見
デシジョンテーブル 条件の組み合わせを網羅 複雑な仕様の整理と漏れ防止
状態遷移テスト 状態変化の正しさを検証 無効な遷移の検出

これらの技法は、TDDやBDDなどの開発手法と組み合わせることで、さらに効果を発揮します。テストを書く際には、まず「何をテストすべきか」をこれらの技法で整理し、体系的にテストケースを設計することを心がけてください。

テスト設計は、開発者のスキルの中でも特に費用対効果の高い投資領域です。少ないテストケースで多くのバグを発見できる「効率的なテスト」を目指して、ぜひ実践で活用してみてください。

参考リンク