はじめに

「同じようなテストを何度も書いている」「テストケースを追加するたびにコピペが増える」という経験はないでしょうか。入力値と期待値が異なるだけで、検証ロジックは同じというテストは多くのプロジェクトで頻繁に発生します。

パラメタライズドテスト(Parameterized Test)は、この問題を解決するための技法です。1つのテストメソッドに対して複数のデータセットを渡し、同じ検証ロジックを異なるパターンで繰り返し実行できます。これにより、テストコードの重複を排除しながら、網羅的な検証を効率的に行えます。

本記事では、JavaScriptのJestとJavaのJUnit 5を使って、パラメタライズドテストの基本から実践的な活用パターンまでを解説します。

パラメタライズドテストとは

パラメタライズドテストとは、テストデータ(パラメータ)を外部から注入し、同一のテストロジックを複数のデータセットで実行するテスト手法です。「データ駆動テスト(Data-Driven Test)」とも呼ばれます。

従来のテストとの比較

まず、パラメタライズドテストを使わない場合のコードを見てみましょう。

JavaScript(Jest)- 従来のテスト:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// calculator.test.js - 重複の多いテスト
describe('Calculator.add', () => {
  test('1 + 1 は 2 を返す', () => {
    expect(add(1, 1)).toBe(2)
  })

  test('1 + 2 は 3 を返す', () => {
    expect(add(1, 2)).toBe(3)
  })

  test('2 + 1 は 3 を返す', () => {
    expect(add(2, 1)).toBe(3)
  })

  test('0 + 0 は 0 を返す', () => {
    expect(add(0, 0)).toBe(0)
  })

  test('-1 + 1 は 0 を返す', () => {
    expect(add(-1, 1)).toBe(0)
  })
})

このコードには以下の問題があります。

問題 影響
コードの重複 expect(add(...)).toBe(...) が毎回繰り返される
保守性の低下 テストロジックを変更すると全テストを修正する必要がある
可読性の低下 データが増えるほどファイルが肥大化する
追加コストが高い 新しいケースの追加にボイラープレートが必要

JavaScript(Jest)- パラメタライズドテスト:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// calculator.test.js - パラメタライズドテスト
describe('Calculator.add', () => {
  test.each([
    [1, 1, 2],
    [1, 2, 3],
    [2, 1, 3],
    [0, 0, 0],
    [-1, 1, 0],
  ])('%i + %i は %i を返す', (a, b, expected) => {
    expect(add(a, b)).toBe(expected)
  })
})

このように、パラメタライズドテストを使うことでテストロジックを1箇所にまとめ、データだけを変えて複数のケースを実行できます。

パラメタライズドテストのメリット

flowchart LR
    subgraph benefits["パラメタライズドテストのメリット"]
        B1["コード重複の排除"]
        B2["保守性の向上"]
        B3["可読性の向上"]
        B4["追加コストの削減"]
        B5["網羅性の向上"]
    end
    
    B1 --> R1["1つのテストロジックで複数ケースを検証"]
    B2 --> R2["変更箇所が1箇所に集約"]
    B3 --> R3["データと検証ロジックが明確に分離"]
    B4 --> R4["新規ケースはデータ追加のみ"]
    B5 --> R5["テストケースの追加が容易になり網羅性向上"]
    
    style benefits fill:#e1f5fe,stroke:#01579b,color:#000000
メリット 説明
コード重複の排除 同じ検証ロジックを複数回書く必要がない
保守性の向上 テストロジックの変更が1箇所で済む
可読性の向上 テストデータが表形式で見やすい
追加コストの削減 新しいテストケースはデータを追加するだけ
網羅性の向上 ケース追加が容易なため、より多くのパターンをテストできる

Jest test.each の使い方

Jestではtest.each(またはit.each)を使ってパラメタライズドテストを実装します。主に2つの記法があります。

配列形式(Array of Arrays)

最もシンプルな記法です。二次元配列でテストデータを定義し、各要素が関数の引数として渡されます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// validation.test.js
import { isValidEmail } from './validation'

describe('isValidEmail', () => {
  test.each([
    ['user@example.com', true],
    ['admin@company.co.jp', true],
    ['test.name@domain.org', true],
    ['invalid-email', false],
    ['@nodomain.com', false],
    ['user@', false],
    ['', false],
  ])('"%s" の検証結果は %s', (email, expected) => {
    expect(isValidEmail(email)).toBe(expected)
  })
})

テスト名のフォーマット指定子:

指定子 説明
%s 文字列 "user@example.com"
%i 整数 42
%d 数値 3.14
%f 浮動小数点 3.140000
%j JSON {"key":"value"}
%o オブジェクト { key: 'value' }
%# テストケースのインデックス 0, 1, 2
%$ テストケースの番号(1始まり) 1, 2, 3

オブジェクト配列形式(Array of Objects)

より可読性の高い記法です。各テストケースをオブジェクトで定義し、プロパティ名で参照できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// ageCategory.test.js
import { getAgeCategory } from './ageCategory'

describe('getAgeCategory', () => {
  test.each([
    { age: 0, expected: '幼児' },
    { age: 5, expected: '幼児' },
    { age: 6, expected: '子供' },
    { age: 12, expected: '子供' },
    { age: 13, expected: '一般' },
    { age: 64, expected: '一般' },
    { age: 65, expected: 'シニア' },
    { age: 100, expected: 'シニア' },
  ])('$age 歳のカテゴリは "$expected"', ({ age, expected }) => {
    expect(getAgeCategory(age)).toBe(expected)
  })
})

オブジェクト形式では、テスト名に$プロパティ名でデータを埋め込めます。

テンプレートリテラル形式(Tagged Template Literal)

表形式でテストデータを定義できる、最も可読性の高い記法です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// discount.test.js
import { calculateDiscount } from './discount'

describe('calculateDiscount', () => {
  test.each`
    price   | memberType  | expected
    ${1000} | ${'通常'}   | ${0}
    ${1000} | ${'シルバー'} | ${50}
    ${1000} | ${'ゴールド'} | ${100}
    ${2000} | ${'通常'}   | ${0}
    ${2000} | ${'シルバー'} | ${100}
    ${2000} | ${'ゴールド'} | ${200}
  `('価格 $price 円、会員種別 "$memberType" の割引額は $expected 円', 
    ({ price, memberType, expected }) => {
      expect(calculateDiscount(price, memberType)).toBe(expected)
    }
  )
})

テンプレートリテラル形式は、スプレッドシートのような見た目でテストデータを管理できます。ただし、TypeScriptでの型推論に制限があるため、型安全性を重視する場合はオブジェクト配列形式を推奨します。

describe.each によるテストスイートのパラメータ化

describe.eachを使うと、テストスイート全体をパラメータ化できます。複数のテストメソッドで同じデータセットを共有したい場合に有効です。

 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
// calculator.test.js
import { Calculator } from './calculator'

describe.each([
  { name: '整数計算', a: 10, b: 5 },
  { name: '小数計算', a: 10.5, b: 2.5 },
  { name: '負数計算', a: -10, b: 5 },
])('Calculator - $name', ({ a, b }) => {
  let calc

  beforeEach(() => {
    calc = new Calculator()
  })

  test(`add(${a}, ${b}) は ${a + b} を返す`, () => {
    expect(calc.add(a, b)).toBe(a + b)
  })

  test(`subtract(${a}, ${b}) は ${a - b} を返す`, () => {
    expect(calc.subtract(a, b)).toBe(a - b)
  })

  test(`multiply(${a}, ${b}) は ${a * b} を返す`, () => {
    expect(calc.multiply(a, b)).toBe(a * b)
  })
})

JUnit 5 @ParameterizedTest の使い方

JUnit 5では@ParameterizedTestアノテーションと各種ソースアノテーションを組み合わせてパラメタライズドテストを実装します。

依存関係の追加

まず、junit-jupiter-paramsモジュールを依存関係に追加します。

Maven(pom.xml):

1
2
3
4
5
6
<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter-params</artifactId>
    <version>5.11.0</version>
    <scope>test</scope>
</dependency>

Gradle(build.gradle):

1
testImplementation 'org.junit.jupiter:junit-jupiter-params:5.11.0'

データソースの種類

JUnit 5では、テストデータを供給する「ソース」を複数のアノテーションで指定できます。

flowchart TB
    A["@ParameterizedTest"] --> B["データソース"]
    
    B --> S1["@ValueSource<br/>単一の引数リスト"]
    B --> S2["@CsvSource<br/>CSV形式のデータ"]
    B --> S3["@CsvFileSource<br/>CSVファイルからデータ読込"]
    B --> S4["@MethodSource<br/>メソッドからデータ供給"]
    B --> S5["@EnumSource<br/>Enum値を供給"]
    B --> S6["@ArgumentsSource<br/>カスタムプロバイダ"]
    
    style A fill:#fff3e0,stroke:#e65100,color:#000000
    style B fill:#e1f5fe,stroke:#01579b,color:#000000

@ValueSource - 単一引数のテスト

最もシンプルなソースです。プリミティブ型や文字列の配列を直接指定します。

 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
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;

import static org.junit.jupiter.api.Assertions.*;

class StringValidatorTest {

    @ParameterizedTest
    @ValueSource(strings = {"hello", "world", "java", "junit"})
    void shouldReturnTrueForNonEmptyStrings(String input) {
        assertTrue(StringValidator.isNotEmpty(input));
    }

    @ParameterizedTest
    @ValueSource(ints = {1, 2, 3, 5, 8, 13})
    void shouldIdentifyPositiveNumbers(int number) {
        assertTrue(NumberValidator.isPositive(number));
    }

    @ParameterizedTest
    @ValueSource(doubles = {0.1, 0.5, 0.9, 1.0})
    void shouldValidatePercentage(double value) {
        assertTrue(NumberValidator.isValidPercentage(value));
    }
}

@ValueSourceがサポートする型:

  • strings, ints, longs, doubles, floats, bytes, shorts, chars, booleans, classes

@NullSource, @EmptySource, @NullAndEmptySource

null値や空文字のテストに便利なソースです。

 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
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.NullSource;
import org.junit.jupiter.params.provider.EmptySource;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;

class InputValidatorTest {

    @ParameterizedTest
    @NullSource
    void shouldRejectNullInput(String input) {
        assertFalse(InputValidator.isValid(input));
    }

    @ParameterizedTest
    @EmptySource
    void shouldRejectEmptyInput(String input) {
        assertFalse(InputValidator.isValid(input));
    }

    @ParameterizedTest
    @NullAndEmptySource
    @ValueSource(strings = {"  ", "\t", "\n"})
    void shouldRejectBlankInput(String input) {
        assertFalse(InputValidator.isValid(input));
    }
}

@CsvSource - CSV形式のデータ

複数の引数を持つテストに最適です。カンマ区切りで入力値と期待値を記述します。

 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
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

class CalculatorTest {

    @ParameterizedTest
    @CsvSource({
        "1, 1, 2",
        "2, 3, 5",
        "10, -5, 5",
        "0, 0, 0",
        "-3, -7, -10"
    })
    void shouldAddTwoNumbers(int a, int b, int expected) {
        assertEquals(expected, Calculator.add(a, b));
    }

    @ParameterizedTest
    @CsvSource({
        "user@example.com, true",
        "admin@company.co.jp, true",
        "invalid-email, false",
        "'', false"
    })
    void shouldValidateEmail(String email, boolean expected) {
        assertEquals(expected, EmailValidator.isValid(email));
    }
}

@CsvSourceのオプション:

オプション 説明 デフォルト
delimiter 区切り文字 ,
delimiterString 区切り文字列(複数文字) -
emptyValue 空文字として扱う値 ""
nullValues nullとして扱う値 -
useHeadersInDisplayName ヘッダーを表示名に使用 false
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
@ParameterizedTest
@CsvSource(
    value = {
        "apple | 100 | 果物",
        "carrot | 80 | 野菜",
        "NULL | 0 | 未分類"
    },
    delimiter = '|',
    nullValues = "NULL"
)
void shouldCategorizeProduct(String name, int price, String category) {
    Product product = new Product(name, price);
    assertEquals(category, product.getCategory());
}

@CsvFileSource - 外部CSVファイルからデータ読込

大量のテストデータを管理する場合、CSVファイルに外部化できます。

src/test/resources/test-data/age-categories.csv:

1
2
3
4
5
6
7
8
9
age,expected
0,幼児
5,幼児
6,子供
12,子供
13,一般
64,一般
65,シニア
100,シニア
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvFileSource;

class AgeCategoryTest {

    @ParameterizedTest
    @CsvFileSource(
        resources = "/test-data/age-categories.csv",
        numLinesToSkip = 1  // ヘッダー行をスキップ
    )
    void shouldReturnCorrectCategory(int age, String expected) {
        assertEquals(expected, AgeCategory.getCategory(age));
    }
}

@MethodSource - メソッドからデータ供給

複雑なオブジェクトや動的に生成するテストデータに最適です。

 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
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.Arguments;

import java.util.stream.Stream;

class UserValidatorTest {

    @ParameterizedTest
    @MethodSource("validUserProvider")
    void shouldAcceptValidUser(String name, int age, String email) {
        User user = new User(name, age, email);
        assertTrue(UserValidator.isValid(user));
    }

    static Stream<Arguments> validUserProvider() {
        return Stream.of(
            Arguments.of("田中太郎", 25, "tanaka@example.com"),
            Arguments.of("John Doe", 30, "john@example.com"),
            Arguments.of("山田花子", 18, "yamada@example.co.jp")
        );
    }

    @ParameterizedTest
    @MethodSource("invalidUserProvider")
    void shouldRejectInvalidUser(User user, String expectedError) {
        ValidationResult result = UserValidator.validate(user);
        assertFalse(result.isValid());
        assertEquals(expectedError, result.getErrorMessage());
    }

    static Stream<Arguments> invalidUserProvider() {
        return Stream.of(
            Arguments.of(
                new User("", 25, "test@example.com"),
                "名前は必須です"
            ),
            Arguments.of(
                new User("田中", -1, "test@example.com"),
                "年齢は0以上である必要があります"
            ),
            Arguments.of(
                new User("田中", 25, "invalid-email"),
                "メールアドレスの形式が不正です"
            )
        );
    }
}

メソッド名を省略すると、テストメソッドと同名のメソッドが自動的に使用されます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
@ParameterizedTest
@MethodSource  // "testAddition" という名前のメソッドを探す
void testAddition(int a, int b, int expected) {
    assertEquals(expected, Calculator.add(a, b));
}

static Stream<Arguments> testAddition() {
    return Stream.of(
        Arguments.of(1, 1, 2),
        Arguments.of(2, 3, 5)
    );
}

@EnumSource - Enum値のテスト

Enum型の全値または一部をテストする場合に使用します。

 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
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;

enum OrderStatus {
    PENDING, PROCESSING, SHIPPED, DELIVERED, CANCELLED
}

class OrderStatusTest {

    @ParameterizedTest
    @EnumSource(OrderStatus.class)
    void shouldHaveValidStatusCode(OrderStatus status) {
        assertNotNull(status.getCode());
        assertFalse(status.getCode().isEmpty());
    }

    @ParameterizedTest
    @EnumSource(
        value = OrderStatus.class,
        names = {"SHIPPED", "DELIVERED"}
    )
    void shouldAllowTracking(OrderStatus status) {
        assertTrue(OrderService.canTrack(status));
    }

    @ParameterizedTest
    @EnumSource(
        value = OrderStatus.class,
        mode = EnumSource.Mode.EXCLUDE,
        names = {"CANCELLED"}
    )
    void shouldAllowModification(OrderStatus status) {
        assertTrue(OrderService.canModify(status));
    }
}

@ArgumentsSource - カスタムプロバイダ

再利用可能なテストデータプロバイダを作成する場合に使用します。

 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
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ArgumentsSource;
import org.junit.jupiter.params.provider.ArgumentsProvider;
import org.junit.jupiter.api.extension.ExtensionContext;

import java.util.stream.Stream;

class BoundaryValueArgumentsProvider implements ArgumentsProvider {
    @Override
    public Stream<? extends Arguments> provideArguments(ExtensionContext context) {
        return Stream.of(
            // 境界値テスト用のデータ
            Arguments.of(Integer.MIN_VALUE),
            Arguments.of(-1),
            Arguments.of(0),
            Arguments.of(1),
            Arguments.of(Integer.MAX_VALUE)
        );
    }
}

class NumberProcessorTest {

    @ParameterizedTest
    @ArgumentsSource(BoundaryValueArgumentsProvider.class)
    void shouldHandleBoundaryValues(int value) {
        assertDoesNotThrow(() -> NumberProcessor.process(value));
    }
}

テスト名のカスタマイズ

@ParameterizedTestname属性でテスト名をカスタマイズできます。

1
2
3
4
5
6
7
8
9
@ParameterizedTest(name = "{index}: {0} + {1} = {2}")
@CsvSource({
    "1, 1, 2",
    "2, 3, 5",
    "10, -5, 5"
})
void shouldAddNumbers(int a, int b, int expected) {
    assertEquals(expected, Calculator.add(a, b));
}

利用可能なプレースホルダ:

プレースホルダ 説明
{index} 1始まりのインデックス
{arguments} すべての引数(カンマ区切り)
{0}, {1} 引数のインデックス指定
{displayName} メソッドの表示名

実践的な活用パターン

パターン1: 境界値テストの網羅

テスト設計技法の境界値分析と組み合わせることで、効率的に境界値テストを実装できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// ageDiscount.test.js
import { getDiscount } from './ageDiscount'

describe('年齢別割引率', () => {
  // 境界値を網羅的にテスト
  test.each([
    // 幼児(0-5歳): 無料
    { age: 0, expected: 100, description: '幼児の下限' },
    { age: 5, expected: 100, description: '幼児の上限' },
    // 子供(6-12歳): 50%割引
    { age: 6, expected: 50, description: '子供の下限(境界値+1)' },
    { age: 12, expected: 50, description: '子供の上限' },
    // 一般(13-64歳): 割引なし
    { age: 13, expected: 0, description: '一般の下限(境界値+1)' },
    { age: 64, expected: 0, description: '一般の上限' },
    // シニア(65歳以上): 20%割引
    { age: 65, expected: 20, description: 'シニアの下限(境界値+1)' },
    { age: 100, expected: 20, description: 'シニアの代表値' },
  ])('$description: $age 歳の割引率は $expected%', ({ age, expected }) => {
    expect(getDiscount(age)).toBe(expected)
  })
})
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// AgePricingTest.java
class AgePricingTest {

    @ParameterizedTest(name = "{3}: {0}歳の割引率は{2}%")
    @CsvSource({
        "0, 幼児, 100, 幼児の下限",
        "5, 幼児, 100, 幼児の上限",
        "6, 子供, 50, 子供の下限",
        "12, 子供, 50, 子供の上限",
        "13, 一般, 0, 一般の下限",
        "64, 一般, 0, 一般の上限",
        "65, シニア, 20, シニアの下限",
        "100, シニア, 20, シニアの代表値"
    })
    void shouldCalculateCorrectDiscount(
            int age, 
            String category, 
            int expectedDiscount, 
            String testCase) {
        AgePricing pricing = new AgePricing();
        assertEquals(expectedDiscount, pricing.getDiscountRate(age));
    }
}

パターン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
// emailValidator.test.js
import { validateEmail } from './emailValidator'

describe('EmailValidator', () => {
  describe('正常系: 有効なメールアドレス', () => {
    test.each([
      'user@example.com',
      'user.name@example.com',
      'user+tag@example.com',
      'user@subdomain.example.com',
      'user@example.co.jp',
    ])('"%s" は有効なメールアドレス', (email) => {
      expect(validateEmail(email)).toEqual({ valid: true })
    })
  })

  describe('異常系: 無効なメールアドレス', () => {
    test.each([
      { email: '', error: 'メールアドレスは必須です' },
      { email: 'invalid', error: '@が含まれていません' },
      { email: '@example.com', error: 'ローカル部が空です' },
      { email: 'user@', error: 'ドメインが空です' },
      { email: 'user@.com', error: 'ドメインが不正です' },
      { email: 'user@example', error: 'TLDがありません' },
    ])('$email -> エラー: "$error"', ({ email, error }) => {
      const result = validateEmail(email)
      expect(result.valid).toBe(false)
      expect(result.error).toBe(error)
    })
  })
})

パターン3: 外部データファイルの活用

大量のテストケースやビジネスルールが複雑な場合、テストデータを外部ファイルに分離することで保守性が向上します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// ProductPricingTest.java
class ProductPricingTest {

    @ParameterizedTest(name = "商品{0}: 数量{1}個 × 単価{2}円 = {3}円")
    @CsvFileSource(
        resources = "/test-data/pricing-rules.csv",
        numLinesToSkip = 1,
        encoding = "UTF-8"
    )
    void shouldCalculateTotalPrice(
            String productCode,
            int quantity,
            int unitPrice,
            int expectedTotal) {
        
        Product product = new Product(productCode, unitPrice);
        Order order = new Order(product, quantity);
        
        assertEquals(expectedTotal, order.calculateTotal());
    }
}

パターン4: 複数の検証を含むテスト

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
29
30
31
32
33
34
35
36
37
38
39
// UserRegistrationTest.java
class UserRegistrationTest {

    @ParameterizedTest
    @MethodSource("registrationScenarios")
    void shouldValidateRegistration(RegistrationTestCase testCase) {
        // Arrange
        UserRegistrationRequest request = testCase.getRequest();
        
        // Act
        ValidationResult result = registrationService.validate(request);
        
        // Assert
        assertAll(
            () -> assertEquals(testCase.isExpectedValid(), result.isValid()),
            () -> assertEquals(testCase.getExpectedErrors(), result.getErrors()),
            () -> assertEquals(testCase.getExpectedWarnings(), result.getWarnings())
        );
    }

    static Stream<RegistrationTestCase> registrationScenarios() {
        return Stream.of(
            RegistrationTestCase.builder()
                .description("全項目が有効な場合")
                .request(new UserRegistrationRequest("田中太郎", "tanaka@example.com", "password123"))
                .expectedValid(true)
                .expectedErrors(List.of())
                .expectedWarnings(List.of())
                .build(),
            RegistrationTestCase.builder()
                .description("パスワードが弱い場合")
                .request(new UserRegistrationRequest("田中太郎", "tanaka@example.com", "pass"))
                .expectedValid(false)
                .expectedErrors(List.of("パスワードは8文字以上必要です"))
                .expectedWarnings(List.of())
                .build()
        );
    }
}

パラメタライズドテストのベストプラクティス

1. テスト名を分かりやすくする

テスト名には、入力値と期待される結果を含めることで、テスト失敗時の原因特定が容易になります。

1
2
3
4
5
6
7
8
// 悪い例: テスト名から何をテストしているか分からない
test.each([[1, 2], [3, 4]])('test %#', (a, b) => { ... })

// 良い例: 入力と期待結果が明確
test.each([
  { input: 1, expected: 2 },
  { input: 3, expected: 4 },
])('入力 $input に対して $expected を返す', ({ input, expected }) => { ... })

2. テストケースをグループ化する

同じカテゴリのテストケースはコメントや構造でグループ化します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
@ParameterizedTest
@CsvSource({
    // 正常系: 有効な入力
    "100, 10, 90",
    "200, 50, 150",
    
    // 境界値: 割引率の限界
    "100, 0, 100",    // 割引なし
    "100, 100, 0",    // 全額割引
    
    // 特殊ケース
    "0, 50, 0"        // 0円商品
})
void shouldApplyDiscount(int price, int discountPercent, int expected) {
    assertEquals(expected, calculator.applyDiscount(price, discountPercent));
}

3. 1つのテストで検証する観点を絞る

パラメタライズドテストでも「1テスト1検証」の原則を守ります。複数の観点をテストしたい場合は、テストを分割します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// 悪い例: 複数の観点を1つのテストで検証
test.each([...])('validation', ({ input }) => {
  const result = validate(input)
  expect(result.isValid).toBe(true)
  expect(result.sanitized).toBe(expected)
  expect(result.logs.length).toBe(0)
})

// 良い例: 観点ごとにテストを分離
describe('validate', () => {
  test.each([...])('有効な入力に対してisValid=trueを返す', ({ input }) => {
    expect(validate(input).isValid).toBe(true)
  })

  test.each([...])('入力値を正規化して返す', ({ input, expected }) => {
    expect(validate(input).sanitized).toBe(expected)
  })
})

4. テストデータの意図を明確にする

「なぜこのテストケースが必要なのか」をコメントやデータ構造で示します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
static Stream<Arguments> provideEdgeCases() {
    return Stream.of(
        // 境界値: 最小値
        Arguments.of(0, "ゼロ値の処理確認"),
        
        // 境界値: 最大値
        Arguments.of(Integer.MAX_VALUE, "オーバーフロー防止の確認"),
        
        // 異常系: 負の値
        Arguments.of(-1, "負の値のバリデーション確認")
    );
}

よくある落とし穴と対処法

1. テストデータが多すぎて見通しが悪くなる

問題: テストケースが数十〜数百になると、配列が肥大化して可読性が低下します。

対処法: 外部ファイル化、またはヘルパーメソッドでデータを構造化します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// テストデータをカテゴリごとに分割
static Stream<Arguments> allTestCases() {
    return Stream.concat(
        Stream.concat(
            normalCases(),
            boundaryCases()
        ),
        errorCases()
    );
}

private static Stream<Arguments> normalCases() {
    return Stream.of(
        Arguments.of(/* ... */)
    );
}

2. テスト失敗時に原因が分かりにくい

問題: インデックスだけの表示では、どのケースが失敗したか分かりにくいです。

対処法: 意味のあるテスト名を設定します。

1
2
3
4
5
6
7
// 悪い例
test.each([[1, 2], [3, 4]])('test %#', ...)

// 良い例
test.each([
  { scenario: '正常な加算', a: 1, b: 2, expected: 3 },
])('$scenario: $a + $b = $expected', ...)

3. 複雑なオブジェクトの比較が難しい

問題: オブジェクトを比較すると、差分が分かりにくいエラーメッセージになります。

対処法: 個別のプロパティをアサートするか、カスタムマッチャーを使用します。

1
2
3
4
5
6
7
8
// 複雑なオブジェクトは個別に検証
test.each([...])('ユーザー作成', ({ input, expected }) => {
  const result = createUser(input)
  
  expect(result.name).toBe(expected.name)
  expect(result.email).toBe(expected.email)
  expect(result.status).toBe(expected.status)
})

まとめ

パラメタライズドテストは、テストコードの重複を排除し、効率的なテスト管理を実現する強力な技法です。

本記事で解説した内容をまとめると:

  • パラメタライズドテストの本質: 1つのテストロジックに対して複数のデータセットを渡し、網羅的なテストを実現する
  • Jestでの実装: test.eachを使い、配列形式・オブジェクト形式・テンプレートリテラル形式から選択
  • JUnit 5での実装: @ParameterizedTestと各種ソースアノテーション(@ValueSource, @CsvSource, @MethodSourceなど)を組み合わせる
  • 実践パターン: 境界値テスト、正常系/異常系の分離、外部データファイルの活用
  • ベストプラクティス: 意味のあるテスト名、グループ化、1テスト1検証の原則

パラメタライズドテストを活用することで、より少ないコードでより多くのテストケースをカバーでき、テストコードの保守性も向上します。境界値分析やデシジョンテーブルなどのテスト設計技法と組み合わせることで、さらに効果的なテスト戦略を構築できるでしょう。

参考リンク