テストコード|データベースのテストデータ投入を改善した話

Wallet Stationバックエンドエンジニアの新田大河です。

この記事では、Wallet Stationの統合テストにおけるテストデータ投入方法を改善した事例についてご紹介します。

はじめに

Wallet Stationでは、バックエンドの自動テストは主に以下の3つに分類されます。

  • 単体テスト(テストコード):JUnitを使用し、クラス単位でその振る舞いをテストする

  • 統合テスト(テストコード):Spring Test(MockMvcなど)を利用し、APIバッチ処理単位で、振る舞いやデータベースへの書き込みなどをテストする

  • シナリオテスト(結合環境での自動テスト):自社フレームワークを使い、実際の利用シナリオに基づいてシステム全体の振る舞いをテストする

今回焦点を当てる統合テストはこちらの記事で定義されている「Medium」サイズのテストに該当します。

Wallet Stationの統合テストでは、実際にデータベースに接続してテストを実施するため、テスト実行前に適切なテストデータを投入する必要があります。しかし、プロダクトの成長に伴いテストデータが複雑化していることで、従来の管理方法では精度や保守性が低下し、大きな課題となっていました。

本記事では、この課題をどのように解決したかについて、具体的な方法を紹介します。

以前の仕組みと課題

以前は、SQLを直接書いてテストデータを投入し、そのデータを基に統合テストを行っていました。しかし、プロダクトの成長に伴い、テーブルやカラムの増加によりテストデータの管理が複雑化しました。

特に参照系テーブルは正規化されておらず、カラム数が多いため、クエリの作成やメンテナンスに多大なコストがかかり、正確なデータ再現が難しく、テストの精度も低下していました。

その結果、テストの追加やメンテナンスに時間がかかり、プロダクトの成長を阻害する要因となっていました。

例えば、次のようなクエリを見てみましょう。

INSERT INTO qr_payment_history (
  id, company_id, company_name, shop_id, shop_name, user_id, user_name, amount, qr_payment_type, currency, status, device_id, device_type, completed_at)
VALUES (
  1, 123, 'Sample Company', 456, 'Sample Shop', 789, 'John Doe', 1000, 'CREDIT', 'JPY', 'COMPLETED', 'device123', 'iPhone', '2023-10-02 12:00:00');

このように、qr_payment_typeなどの「真ん中」あたりのカラムでは、設定内容が把握しにくいため認知負荷が高くなっています。

カラムが多くなると、全体を理解しづらくなり、設定ミスや見落としのリスクも増加します。また、テーブル数が増えると、テストデータの量やバリエーションも増え、メンテナンスの負担がさらに大きくなっていました。

課題の解決

上記で述べた課題を、

  • フィクスチャを導入する
  • 実際にAPIを起動してデータを投入する仕組みを構築する

することで、解決しました。

それぞれ以下で詳しく説明していこうと思います。

フィクスチャを導入

概要

前提条件としてデータベースに必要なテストデータを投入するクラスを用意し、フィクスチャとしてデータベースの状態をセットアップするようにしました。

以下では、このテストデータを投入するクラスをフィクスチャと呼びます。

Test Data Builderパターンを参考にデフォルト値を設定することで、テストケースの中で意味のあるデータだけを外部から指定できるようにしました。これにより、クエリ内でどのカラムにどの値をセットするかを一目で把握でき、認知負荷が大幅に減少しました。

フィクスチャの例

以下は実際のテストコードにおけるフィクスチャの例です。なお、テストデータの投入にはjOOQというORMを使用しています。

import static jooq.gen.dbo.tables.QrPaymentHistoryTable.QR_PAYMENT_HISTORY;

import lombok.Builder;
import lombok.Getter;
import org.jooq.DSLContext;

@Builder
@Getter
public class QrPaymentHistoryFixture {
    // デフォルト値を持つフィールド
    @Builder.Default
    private final Integer id = 1;

    @Builder.Default
    private final Integer companyId = 1;
    
    // 他のフィールドも定義...(省略)
    
    @Builder.Default
    private final String qrPaymentType = "charge";

    // 他のフィールドも定義...(省略)

    // データベースにデータを投入
    public void insert(DSLContext dslContext) {
        dslContext
            .insertInto(QR_PAYMENT_HISTORY)
            .set(FixtureHelper.parseMapOfColumnAndValue(this)) //FixtureHelper#parseMapOfColumnAndValueで、カラムと値の対応関係を作る(詳細は省略)
            .execute();
        
    }
}

フィクスチャ使用するテストコードは以下のようになります。

import static org.hamcrest.Matchers.equalTo;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.log;

import org.hamcrest.MatcherAssert;
import org.jooq.DSLContext;
import org.json.JSONObject;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.ResultActions;

@SpringBootTest
public class QrPaymentHistoryAPITest extends IntegrationTestSupport {

    @Autowired
    private DSLContext dslContext;  // DIによってDSLContextを注入

    @Test
    void チャージ取消の履歴を取得できる() {
        // Arrange
        //: テストデータのセットアップ
        QrPaymentHistoryFixture qrPaymentHistory = QrPaymentHistoryFixture.builder()
                .qrPaymentType("charge_cancel")
                .build();
        // データベースにテストデータを挿入
        qrPaymentHistory.insert(dslContext);

        // Act:テスト対象処理の実行
        ResultActions results = mockMvc.perform(get("/api/history").headers(HEADER)).andDo(log()); // mockMvcやHEADERはIntegrationTestSupportで作ったものを利用
        JSONObject json = new JSONObject(results.andReturn().getResponse().getContentAsString());

        // Assert:APIのレスポンスやデータベースの検証。適宜フィクスチャを利用
        MatcherAssert.assertThat(json.getString("qr_payment_type"), equalTo(qrPaymentHistory.getQrPaymentType()));
       
    }
}

APIを起動してデータを投入する仕組みを構築

仕組みの概要

フィクスチャの導入により、以前よりテストデータ管理が改善されました。しかし、下流APIバッチ処理ではデータ量やバリエーションが多く、フィクスチャを使っても精度の確保が難しく、メンテナンスの負担が大きい状態が続いていました。

実際の環境では、トランザクション系のデータはシステム(Wallet StationではAPI)の動作によって生成されるため、フィクスチャで手動でデータを投入するよりも、APIを直接呼び出してテストデータを生成する方が効率的です。そこで、APIを起動してテストデータを投入するテストヘルパーを作成し、複雑なデータでも簡単に投入できるようにしました。

APIを叩いてテストデータを投入するクラスを具体的な例を示しました。テストコードでは、TestDataCharge.charge(1000)と呼び出すだけでテストデータを投入することができます。

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.HashMap;
import java.util.Map;
import org.json.JSONObject;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.web.client.RestTemplate;

public class TestDataCharge {

  public static JSONObject charge(Integer chargeAmount) throws JsonProcessingException {
    RestTemplate restTemplate = new RestTemplate();
    // リクエストヘッダーを作り込む
    HttpHeaders headers = new HttpHeaders();
    headers.set("Accept", "application/json");
    // 以下省略...

    // リクエストボディを作り込む
    Map<String, Object> requestBody = new HashMap<>();
    requestBody.put("charge_amount", chargeAmount);
    // 以下省略...

    // RestTemplateを使って実際にAPIを叩く
    ObjectMapper objectMapper = new ObjectMapper();
    HttpEntity<String> requestEntity =
        new HttpEntity<>(objectMapper.writeValueAsString(requestBody), headers);

    return new JSONObject(
        restTemplate
            .exchange(
                "http://localhost:8080/api/charge",
                HttpMethod.POST,
                requestEntity,
                String.class)
            .getBody());
  }
}

Wallet Stationでは、APIを責務ごとにモジュール化し、モジュール間の疎結合を保つため、テスト対象モジュール外の他モジュールを直接参照できない設計となっています。このため、テスト対象外のモジュールに属するAPIをMockMvcで起動することができませんでした。

この問題を解決するために、テスト実行前にバックグラウンドでAPIを起動し、テストヘルパーからそのバックグラウンドAPIにリクエストを送る仕組みを整備しました。

さらに、環境変数の設定などをシェルスクリプトにまとめることで、ローカル環境でも簡単にAPIを起動できるようにしました。(この仕組みは既存のCI環境にも容易に組み込むことができました。)

効果とデメリット

この仕組みを導入したことで、複雑なテストデータを少ないコードで正確に投入できるようになり、CIを回したときにバグを検出する機会が増えました。

一方で、実行時間が若干増加するデメリットがありますが、Wallet Stationでは以下の理由から大きな問題とはなりませんでした。

  • 統合テストのテストケース数は単体テストほど膨大な数にはならない
  • この仕組みの導入後も、1ケースあたり+0.5〜1秒の遅延に収まっており、許容範囲内である
  • CI上では統合テストを並列実行できるため、並列数を増やすことで全体の処理時間を最適化できる
  • 統合テストはデータベースの書き込みをもれなく検証していたりするため、元々1ケースあたり数秒程度の実行時間は許容している

フィクスチャを使うかAPIを起動するかの見極め

Wallet Stationでは、テストデータの投入方法として、フィクスチャを使用する方法と、実際のAPIを起動する方法を使い分けています。それぞれに適したシチュエーションがあり、以下の基準を目安に選択しています。

【フィクスチャを使用するケース】

  • マスタデータなど、運用上SQLクエリで投入されることが想定されているデータの場合
  • テスト対象が上流のAPIやバッチで、テストデータがシンプルな場合

【実際のAPIを使用するケース】

【使い分けの基準】

上記の基準はあくまで目安です。手法の選択に迷った場合は、プロダクトが「お金」を扱う特性を考慮し、品質を最優先に、保守性も踏まえて最適な方法を選択しています。

まとめ

この記事では、テストデータの精度や保守性を向上させテストの追加を容易にするために、統合テストにおけるテストデータ投入方法を改善した取り組みをまとめました。

最後までお読みいただき、ありがとうございました。