👻

テストデータの項目が多い時にはYAMLを使ってみよう

2024/11/29に公開

はじめに

テストデータの項目が多い場合、コードの保守性や可読性に課題を感じたことはありませんか。
この記事では、YAMLを活用してテストデータを外部化し、これらの問題を解決する方法を紹介します。

具体例を交えつつ、以下の課題に焦点を当てます。

  • コードが肥大化し、テストの意図が伝わりにくい
  • データ変更時の負担が大きい
  • 複雑なデータを管理するのが難しい

サンプルコード全体はこちらのリポジトリから確認できます。

テストデータの項目が多いときの課題

テストデータの項目が多いときの課題に詳しく触れるために、コードの例を用意しました。

以下は、名前、年齢、メールアドレス、住所、電話番号、性別、登録日、購読ステータスを持つユーザーデータをテストする例です。

@Test
@DisplayName("有効なユーザーかどうか判定")
void testValidateUser() {

    // 正常系
    assertEquals(new ValidateResult(true, null, "USER123", "Success: User registered"),
        validateUser(
            "山田太郎", 30, "yamada@example.com", "東京都xx区1-1-1",
            "090-1234-5678", "男性", "2024-11-29", true
        )
    );

    // 名前が未入力
    assertEquals(new ValidateResult(false, "名前は必須です", null, "Error: Missing name"),
        validateUser(
            "", 30, "yamada@example.com", "東京都xx区1-1-1",
            "090-1234-5678", "男性", "2024-11-29", true
        )
    );

    // 年齢が不正
    assertEquals(new ValidateResult(false, "年齢は0以上である必要があります", null, "Error: Invalid age"),
        validateUser(
            "山田太郎", -1, "yamada@example.com", "東京都xx区1-1-1",
            "090-1234-5678", "男性", "2024-11-29", true
        )
    );

    // メールアドレスが不正
    assertEquals(new ValidateResult(false, "メールアドレスの形式が不正です", null, "Error: Invalid email"),
        validateUser(
            "山田太郎", 30, "invalid-email", "東京都xx区1-1-1",
            "090-1234-5678", "男性", "2024-11-29", true
        )
    );

    // 電話番号が不正
    assertEquals(new ValidateResult(false, "電話番号の形式が不正です", null, "Error: Invalid phone number"),
        validateUser(
            "山田太郎", 30, "yamada@example.com", "東京都xx区1-1-1",
            "09012345678", "男性", "2024-11-29", true
        )
    );
}

このコードにはいくつか問題があります。

1.可読性が低い

  • 直感的にわかりにくい値がある

    • 例えば、ValidateResultfalsenullが何を示しているのかがValidateResultの内部構造を把握していない人間には読み取れない
  • テストケースの内容や意図が一目で理解しにくい

    • すべての入力値ではなく特定の項目に対してのテストも含まれているが、どの項目に対して期待値が設定されているのかが読み取りにくい
    • どの値がValidでどの値がInvalidなのかがわかりにくい

2.保守性が低い

  • テストデータがコード内に埋め込まれているため修正が煩雑
    • 入力データや期待値を変更するたびにコードそのものを修正する必要がある
    • 特に、項目の追加や削除などのテストコードに大きく影響が出る変更が発生した場合、すべてのテストケースのコードを修正する必要が発生する(この場合、テストケースが多ければ多いほど実装コストが増える)

YAMLを用いたテストデータの外部化を試してみる

というわけでやってみましょう。

実装例

上記で確認した問題の解決のために、YAMLを使ってテストデータを外部に持つ形にします。

まずYAMLで、テストデータを以下のように表現します。

# 有効なユーザーかどうか判定するテストのテストケース

# 基礎入力データ
# 全てのテストケースで共通する基本のデータを定義している
# ここで設定するすべての値はValidな値
base_input: &base_input
  name: "山田太郎"
  age: 30
  email: "yamada@example.com"
  address: "東京都xx区1-1-1"
  phone: "090-1234-5678"
  gender: "男性"
  registration_date: "2024-11-29"
  subscription_status: true

# テストケース一覧
# 各ケースは、テストケース名、入力値、期待値、を指定
#
# 入力値に関しては、基礎入力データを元に値を入れ、テストに応じて書き換えたい値を別途修正するようにしている
# これにより、テストに関連する値のみを関心事にするようにしている
test_cases:
  - name: "正常な入力"
    input:
      <<: *base_input
    expected:
      success: true
      error_message: null
      generated_id: "USER123"
      log_message: "Success: User registered"

  - name: "名前が未入力"
    input:
      <<: *base_input
      name: ""
    expected:
      success: false
      error_message: "名前は必須です"
      generated_id: null
      log_message: "Error: Missing name"

  - name: "年齢が不正"
    input:
      <<: *base_input
      age: -1
    expected:
      success: false
      error_message: "年齢は0以上である必要があります"
      generated_id: null
      log_message: "Error: Invalid age"

  - name: "メールアドレスが不正"
    input:
      <<: *base_input
      email: "invalid-email"
    expected:
      success: false
      error_message: "メールアドレスの形式が不正です"
      generated_id: null
      log_message: "Error: Invalid email"

  - name: "電話番号が不正"
    input:
      <<: *base_input
      phone: "09012345678"
    expected:
      success: false
      error_message: "電話番号の形式が不正です"
      generated_id: null
      log_message: "Error: Invalid phone number"

上記のYAMLの値をテストケースとするよう、テストコードを修正します。

@ParameterizedTest(name = "{0}")
@MethodSource("loadTestCases")
@DisplayName("有効なユーザーかどうか判定")
void testValidateUser(TestCase testCase) { // arrange

    // act
    var actual = validateUser(
        testCase.input.name,
        testCase.input.age,
        testCase.input.email,
        testCase.input.address,
        testCase.input.phone,
        testCase.input.gender,
        testCase.input.registration_date,
        testCase.input.subscription_status
    );

    // assert
    assertEquals(new ValidateResult(
        testCase.expected.success,
        testCase.expected.error_message,
        testCase.expected.generated_id,
        testCase.expected.log_message
    ), actual);
}

/**
 * YAMLファイルからテストケースを読み込む処理
 */
@SuppressWarnings("unchecked")
private static Stream<TestCase> loadTestCases() {
    // yamlのmerge keyの機能を使いたかったのでSnakeYamlを採用
    var yaml = new Yaml();

    // test-cases.yamlをリソースから取得
    var yamlStream = ValidateUserTest.class.getClassLoader().getResourceAsStream("testcases.yaml");
    if (yamlStream == null) {
        throw new RuntimeException("YAML file not found");
    }

    // SnakeYAMLでYAMLを読み込み(マージキーやアンカーが展開される)
    Map<String, Object> rootNode = yaml.load(yamlStream);

    // test_casesキーの値を取得し、型安全にキャスト
    var testCasesNode = (List<Map<String, Object>>) rootNode.get("test_cases");

    // JacksonのObjectMapperを使ってTestCaseクラスに変換
    var mapper = new ObjectMapper();
    List<TestCase> testCases = mapper.convertValue(testCasesNode, mapper.getTypeFactory().constructCollectionType(List.class, TestCase.class));

    // Streamに変換して返す
    return testCases.stream();
}


/**
 * テストケースを表現するClass
 * テストケース名、入力値、期待値を設定している
 */
private static class TestCase {
    public String name;
    public Input input;
    public Expected expected;

    // ParameterizedTestのnameで使用される文字列を提供するために実装
    @Override
    public String toString() {
        return name;
    }

    private static class Input {
        public String name;
        public int age;
        public String email;
        public String address;
        public String phone;
        public String gender;
        public String registration_date;
        public boolean subscription_status;
    }

    private static class Expected {
        public boolean success;
        public String error_message;
        public String generated_id;
        public String log_message;
    }
}

YAMLを導入した効果

YAMLを使って外部にテストデータを持つ形にすることで、可読性と保守性の向上が本当にできたのか確認してみます。

  • 直感的にわかりにくい値がある

    • コードにnullのような値が突然発生する形ではなく、YAMLに変数の値として値を設定する形になることにより、最初のコードに比べると意味を読み取りやすい
  • テストケースの内容や意図が一目で理解しにくい

    • 目的の項目のみテストケースで値を設定するというやり方を取ることで、テストの内容や意図が最初のコードに比べて明示的になっている
    • また、YAMLはコメントが使用できるので、意図を記載することで意図を伝えることが可能
  • テストデータがコード内に埋め込まれているため修正が煩雑

    • テストデータを外部ファイルで管理する形にしたので、テストデータの値の変更はコード本体に影響を及ぼさない
    • 項目の追加や削除が発生した場合は、テストコードに手を入れる必要があるが、最初のコードに比べれば実装コストは低い

表にまとめると以下のとおりです。

従来の方法 YAML活用
可読性 全項目を列挙するため冗長 必要な変更点のみ記述
保守性 データの変更が大規模修正に YAMLのみ変更で対応

いうわけで、元のコードに比べれば、圧倒的に改善ができていると判断します。

なぜYAMLなのか

テストデータを外部ファイルで管理する際に、何故YAMLを採用したのかは以下のとおりです。

  • データに関する表現力の高さ
    • スカラ値、配列、辞書、ネスト構造、アンカーとエイリアスなど様々な表現が使用できます
    • 他の形式と比べて、一定複雑なデータ構造でもYAMLであれば対応しやすいケースは多いと思います
  • コメントが書ける
    • 例えば、JSONだとコメントが書けません。テストのような意図を伝えることが重要なものに対してコメントが使えないと運用が非常に厳しいです

ちょっとした注意点

YAMLを使うという方法、もしかしたら一定マイナーな方法かもしれないので、実際に運用する際にはドキュメントをしっかり記載した方が良いと思います。

おわりに

というわけで、テストデータをYAMLで管理する方法について紹介しました。

全てにおいて、この方法が通用するとは思っていませんが、この方法が光るユースケースもあるかと思いますので、必要な時には使ってみてください。

Discussion