🧪

Spring/Javaにおけるイベント駆動システムのインテグレーションテスト手法

に公開

はじめに

ウェルスナビ株式会社でID基盤の開発・運用を担当している雨森と申します。

弊社ID基盤の一部ではイベント駆動アーキテクチャを採用しています。本記事では、その開発・運用の中で得た知見をもとに、SpringとJavaを用いたイベント駆動システムにおけるインテグレーションテストの実装方法を解説します。

なお、今回はすぐに使える「構築手順」にフォーカスするため、インテグレーションテストの意義や採用ライブラリの深い解説については割愛します。

イベント駆動システムのテストについて

同期的に動くシステムに比べ、イベント駆動のシステムをテストするのは難易度が高いです。REST APIサーバであれば単にエンドポイントを叩いてレスポンスを検証するというシンプルな手法が取れますが、イベント駆動システムではそうはいきません。ただTestcontainersを利用することで、イベント駆動であっても比較的簡単にインテグレーションテストを実現することが可能となります。それでは実際のテストの書き方について見ていきましょう。

テスト対象のシステム

今回はサンプル用に、Spring/Java + DBはFlywayでバージョン管理を行なっているシステムを想定します。なおFlywayの利用方法については割愛します。

テスト観点でのFlywayの有用性

余談ですが、Flywayはインテグレーションテストを構築する上で非常に有用です。テスト環境は隔離された再現性の高いものを都度立ち上げるのが望ましく、その上で毎回同一のDBスキーマを作成できるFlywayはインテグレーションテスト環境の構築に適しています。既存のDBに後から導入することも可能なので、使っていない方はテストの容易性という観点からも利用をお勧めします。

概要

外部システムとの連携箇所

  • イベントキュー
    イベントの受け渡しにはAWS SQSを利用します。

  • メール送信
    SendGridを想定していますが、何を利用しても実装方法は大きく変わりません。

  • DB
    Aurora MySQLを想定します。とはいえTestContainersが対応していれば設定方法はほぼ同じです。

テストのイメージ

テストの対象となるシステムの概要が把握できたので、次はテストのイメージを明確にしましょう。

概要

検証箇所

モックイベント受信後に以下が期待通りであることを検証します。

  • DBのレコード
  • メール送信内容

利用する主なライブラリ

  • JUnit
    Javaテストの定番です。単体テストでも利用している方は多いかと思います。

  • Testcontainers[1]
    インテグレーションテストを実施する上で肝となるライブラリです。テスト用に外部システムのコンテナを自動で立ち上げてくれます。

  • LocalStack [2]
    SQSをテスト環境で再現します。実際のSQSとは一部挙動が異なる部分もありますが、テスト用のイベント受け渡しを再現したいという用途であれば特に問題にはなりません。TestContainersのサポートがあり、テストへのシームレスな統合が可能です。

利用バージョン

  • Java correto-21
  • Spring Boot 3.2.3[3]
  • Gradle 8.4
  • JUnit 5.10.0
  • LocalStack 3.0
  • MySQL 8.0.34
  • Flyway 11.4.0

テストを作る

それでは実際にテストコードを書いていきます。

ディレクトリ構成

src
+-- main
|   +-- resources
|       +-- db
|           +-- migration
|               +-- V1__init.sql など   // TestContainers起動時にFlywayが適用するSQL
+-- test
    +-- java
    |   +-- package/integration                 // 単体テストと分けたディレクトリを作成する
    |      +-- config                           
    |      |  +-- SendGridMockConfiguration   // SendGrid設定
    |      |   +-- SqsTestConfiguration        // SQS設定
    |      |   +-- TestContainersConfiguration // TestContainer設定
    |      +-- service                          
    |      |   +-- SqsService                  // キューへのイベント送信用サービス
    |      +-- util                             
    |      |   +-- QueueProcessHelper          // キューへのイベント送信検証用
    |      +-- BaseIntegrationTest              // テスト用の基底クラス
    |      +-- 各種テストクラス
    +-- resources
        +-- application-test.yml

build.gradleの設定

まずはテストに利用するライブラリを追加するところから始めましょう。今回のセットアップでは以下が入っていれば十分です。

build.gradle
dependencies {
    ...
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.boot:spring-boot-testcontainers'
    testImplementation 'org.testcontainers:junit-jupiter'
    testImplementation 'org.testcontainers:mysql'
    testImplementation 'org.testcontainers:localstack'
    testImplementation 'com.sendgrid:sendgrid-java'
}

テスト用の設定

基本的にインテグレーションテストの対象となるのはシステム内部のコンポーネントであり、外部の依存関係はテスト対象外です。外部システムとの連携までテストしたい場合はE2Eテストを構成しましょう。今回のシステム構成の場合、以下はモックすべきです。

  • DB
  • SQS
  • SendGrid

TestContainersではこうした外部システムをコンテナとしてモックとすることが可能です。まずはDBとSQSのモックに関する設定を見ていきます。(SendGridについては後述)

TestContainerConfiguration
@TestConfiguration
public class TestContainerConfiguration {
    private static final LocalStackContainer staticLocalStackContainer;
    private static final MySQLContainer<?> staticMySQLContainer;

    public static LocalStackContainer getLocalStackContainer() {
        return staticLocalStackContainer;
    }

    @Bean
    public LocalStackContainer localStackContainer() {
        return staticLocalStackContainer;
    }

    @Bean
    @ServiceConnection
    public MySQLContainer<?> mySQLContainer() {
        return staticMySQLContainer;
    }

    // テストコンテナを初期化する
    static {
        try {
            System.out.println("Initializing test containers...");
            staticLocalStackContainer = new LocalStackContainer(DockerImageName.parse("localstack/localstack:3.0"))
                    .withServices(LocalStackContainer.Service.SQS)
                    .withEnv("AWS_DEFAULT_REGION", "ap-northeast-1");
            staticLocalStackContainer.start();
            System.out.println("LocalStack container started");

            staticMySQLContainer = new MySQLContainer<>("mysql:8.0.34")
                    .withDatabaseName("sample-db")
                    .withUsername("test")
                    .withPassword("test");
            staticMySQLContainer.start();
            System.out.println("MySQL container started");
        } catch (Exception e) {
            System.err.println("Failed to initialize test containers: " + e.getMessage());
            e.printStackTrace();
            throw new RuntimeException("Test container initialization failed", e);
        }
    }
}

DBのモック

今回の構成ではMySQLを利用しており、TestContainersを利用すれば非常に簡単に設定ができます。@ServiceConnectionというアノテーションを付与することでTestContainersが自動で必要な設定をしてくれるため、テスト本体ではモックを意識せず実装することが可能です。

SQSのモック

SQSのモックにはlocalstackを利用します。こちらも@ServiceConnectionを利用したいのですが、残念ながらまだ対応していないようで自動設定ではうまく動きません。そうしたケースでは@DynamicPropertySourceを利用してテスト実行時に動的に設定する必要があります。

SampleIntegrationTest
@ActiveProfiles("test")
@SpringBootTest // インテグレーションテストなのでコンテキストを読み込む
@Import(TestContainersConfiguration.class) // TestContainersの設定を読み込む
public class SampleIntegrationTest {

    @DynamicPropertySource
    static void registerProperties(DynamicPropertyRegistry registry) {
        LocalStackContainer localStackContainer = TestContainersConfiguration.getLocalStackContainer();

        String endpoint = localStackContainer.getEndpointOverride(LocalStackContainer.Service.SQS).toString();
        registry.add("spring.cloud.aws.sqs.endpoint", () -> endpoint);
        registry.add("spring.cloud.aws.region.static", localStackContainer::getRegion);
        registry.add("spring.cloud.aws.credentials.access-key", localStackContainer::getAccessKey);
        registry.add("spring.cloud.aws.credentials.secret-key", localStackContainer::getSecretKey);
    }

    // あとは実際のテストメソッドを書いていく
    ...
}

registry.add(xxx)によりapplication.ymlの内容を動的に変更するイメージです。これを記載することで、テスト実行時に毎回必要な設定ができるようになります。

実用的な書き方

実務ではインテグレーションテスト用のクラスを複数作成したいこともあるかと思います。その場合は動的な設定を行う部分を切り出した抽象クラスを作成し、各テストにはこれを継承させるのが楽です。

BaseIntegrationTest
...
@DirtiesContext // テスト実行後に環境を綺麗にする
@Execution(ExecutionMode.SAME_THREAD) // テスト実行時の衝突を避ける
public abstract class BaseIntegrationTest {

    @DynamicPropertySource
    static void registerProperties(DynamicPropertyRegistry registry) {
        LocalStackContainer localStackContainer = TestContainersConfiguration.getLocalStackContainer();

        // =================== 追加 ==================
        // プロパティにアクセスする前にコンテナが開始されていることを確認
        // これを書かないと複数テスト実行時に失敗する
        if (!localStackContainer.isRunning()) {
            localStackContainer.start();
        }
        // ===========================================
        
        String endpoint = localStackContainer.getEndpointOverride(LocalStackContainer.Service.SQS).toString();
        registry.add("spring.cloud.aws.sqs.endpoint", () -> endpoint);
        registry.add("spring.cloud.aws.region.static", localStackContainer::getRegion);
        registry.add("spring.cloud.aws.credentials.access-key", localStackContainer::getAccessKey);
        registry.add("spring.cloud.aws.credentials.secret-key", localStackContainer::getSecretKey);
    }
}
SampleIT
// 実際のテスト用クラスは上記クラスを継承する
public class SampleIT extends BaseIntegrationTest {
    ...
}

テスト用SQSキューの作成

次はテスト用のキュー作成を自動化します。dockerであらかじめキューを用意しておくケースもあるかとは思いますが、テストの再現性を高く保つためにはテスト実行時に自動でキュー作られる方が望ましいです。テスト実行時にキュー作成をセットで行うことでテストの再現性が上がるだけでなく、IDE上での実行も可能になるなど実務的なメリットも享受できます。
なおキュー名をapplication.ymlにspring.cloud.aws.sqs.キュー名.nameという形式で記載していることを前提としています。設定が異なる場合はコードブロック末尾のfilterQueueNamesの中身を変更してください。

SqsTestConfiguration
@TestConfiguration
public class SqsTestConfiguration {
    @Autowired
    private Environment environment;

    @Autowired
    private SqsClient sqsClient;

    // キューを作成する
    @PostConstruct
    private void createQueues() {
        List<String> queueNames = getQueueNames();
        queueNames.forEach((queueName) -> {
            try {
                sqsClient.createQueue(CreateQueueRequest.builder().queueName(queueName).build());
            } catch (Exception e) {
                System.out.println("Failed to create queue " + queueName + ": " + e.getMessage());
                throw e;
            }
        });
    }

    /**
     * application-test.ymlに定義されているSQSキュー名を取得する
     * @return キュー名のリスト
     */
    private List<String> getQueueNames() {
        if (!(environment instanceof ConfigurableEnvironment)) {
            throw new IllegalStateException("Environment is not a ConfigurableEnvironment");
        }

        return ((ConfigurableEnvironment) environment).getPropertySources().stream()
                .filter((ps) -> ps instanceof EnumerablePropertySource)
                .map((ps) -> ((EnumerablePropertySource<?>) ps).getPropertyNames())
                .flatMap(Arrays::stream)
                .filter(this::filterQueueNames)
                .map(environment::getProperty)
                .filter(Objects::nonNull)
                .toList();
    }

    private boolean filterQueueNames(String name) {
        return name.startsWith("spring.cloud.aws.sqs.") && name.endsWith(".name");
    }
}
application-test.yml
spring.cloud.aws.sqs:
    user-created:
        name:user-created-queue

本設定も忘れずBaseIntegrationTestで読み込んでおきましょう。

BaseIntegrationTest
@Import({TestContainersConfiguration.class, SqsTestConfiguration.class})
public abstract class BaseIntegrationTest {
...

SQSクライアントの設定

基本的にはSQSクライアントはローカルの設定を流用することを想定しています。設定がない場合はapplication-test.ymlにSQSの適切なエンドポイントや名前を記載し、テスト時はそれらを利用するクライアントをBean登録すると良いでしょう。

AwsConfig
@Profile({"local", "test"})
@Configuration
public class AwsConfig {
    @Value("${spring.cloud.aws.sqs.endpoint}")
    private String sqsEndpoint;

    @Value("${spring.cloud.aws.sqs.region}")
    private String sqsRegion;

    @Bean
    public SqsClient amazonSQS() {
        return SqsClient.builder()
                .region(Region.of(sqsRegion))
                .credentialsProvider(ProfileCredentialsProvider.create("localstack"))
                .endpointOverride(URI.create(sqsEndpoint))
                .build();
    }
}

SendGridのモック

次はメール送信機能をモック化していきます。インテグレーションテストでは外部とのインタラクションを確認したいため、モックだけでなくSendGrid APIの呼び出しをキャプチャし検証するクラスも作成しておくと便利です。こちらもBaseIntegrationTestクラスで読み込むのを忘れないようにしましょう。

SendGridMockConfiguration
@TestConfiguration
public class SendGridMockConfiguration {

    // APIコールをキャプチャするスレッドセーフなリスト
    private final List<SendGridApiCall> apiCalls = new CopyOnWriteArrayList<>();

    @Bean
    @Primary
    public SendGrid sendGrid() {
        return new SendGrid("test-api-key") {
            @Override
            public Response api(Request request) throws IOException {
                // APIコールをキャプチャ
                SendGridApiCall call = new SendGridApiCall(
                        request.getMethod(),
                        request.getEndpoint(),
                        request.getBody(),
                        LocalDateTime.now()
                );
                apiCalls.add(call);

                // 成功レスポンスのモックを返す
                Response mockResponse = new Response();
                mockResponse.setStatusCode(202);
                mockResponse.setBody("{\"message\":\"success\"}");
                mockResponse.setHeaders(java.util.Map.of("Content-Type", "application/json"));

                return mockResponse;
            }
        };
    }

    @Bean
    public SendGridApiCallCaptor sendGridApiCallCaptor() {
        return new SendGridApiCallCaptor(apiCalls);
    }

    // APIコールデータを保持するシンプルなレコード
    public record SendGridApiCall(
            Method method,
            String endpoint,
            String body,
            LocalDateTime timestamp
    ) {
    }

    // テストでキャプチャしたコールにアクセスするためのヘルパークラス
    public static class SendGridApiCallCaptor {
        private final List<SendGridApiCall> apiCalls;

        public SendGridApiCallCaptor(List<SendGridApiCall> apiCalls) {
            this.apiCalls = apiCalls;
        }

        public List<SendGridApiCall> getAllCalls() {
            return List.copyOf(apiCalls);
        }

        public int getCallCount() {
            return apiCalls.size();
        }

        public void clear() {
            apiCalls.clear();
        }

        public boolean hasAnyCall() {
            return !apiCalls.isEmpty();
        }
    }
}
BaseIntegrationTest
@Import({TestContainersConfiguration.class, SqsTestConfiguration.class, SendGridMockConfiguration.class})
public abstract class BaseIntegrationTest {
...

Utilsクラスの作成

ここまでの設定でインテグレーションテストの準備はできていますが、テストコードをより効率的かつ簡潔に記述するため、以下のクラスも作成しておくと良いです。

SQSへのモックイベント送信用クラス

SQSへのイベント送信はイベント駆動のインテグレーションテスト全体で利用することになるので、共通のサービスを登録しておくとテストコードの重複を防ぎ、可読性を高めることができます。

SqsService
@Service
public class SqsService {

    private final SqsClient sqsClient;

    private static final ObjectMapper MAPPER = new ObjectMapper();

    public SqsService(SqsClient sqsClient) {
        this.sqsClient = sqsClient;
    }

    // SNS→SQSと接続しているケースが多いかと思うので、ここではSNSのメッセージを構築するメソッドを作成する
    /**
     * 指定したキューにSNSメッセージを送信する
     * @param queueName キュー名
     * @param messageId メッセージID
     * @param message 送信するメッセージ
     */
    public void sendSnsMessageToQueue(String queueName, int messageId, String message) throws JsonProcessingException {
        String sqsMessage = convertToSnsMessage(messageId, message);
        sendMessage(queueName, sqsMessage);
        System.out.printf("Sent message to queue: %s , messageId: %d, message: %s%n", queueName, messageId, message);
    }

    private void sendMessage(String queueName, String message) {
        String queueUrl = sqsClient.getQueueUrl(builder -> builder.queueName(queueName)).queueUrl();
        SendMessageRequest request = SendMessageRequest.builder()
                .queueUrl(queueUrl)
                .messageBody(message)
                .build();
        sqsClient.sendMessage(request);
    }

    private String convertToSnsMessage(int messageId, String message) throws JsonProcessingException {
        var sqsMessage = Map.of(
                "MessageId", messageId,
                "Message", message
        );
        return MAPPER.writeValueAsString(sqsMessage);
    }
}

イベント処理待機用のUtilクラス

非同期システムにおいてはイベントを受信してから処理が完了するまでに一定のラグがあります。検証前に単純にThread.sleepを呼び出すだけでも十分ですが、処理完了まで待機するための共通クラスを作成しておくと再現性を担保できます。とはいえ当然ですが、ある処理が完了しないことを検証する際には使えません。そのため利用するかしないかは必要なテストケースに応じて考えましょう。

QueueProcessHelper
public class QueueProcessHelper {
    private static final int WAIT_TIME_MS = 500;

    private static final int MAX_RETRY = 20;

    /**
     * 指定された条件がtrueになるまで待機する。
     *
     * @param condition 待機する条件を表すSupplier<Boolean>
     * @return 条件がtrueになった場合はtrue、タイムアウトした場合はfalse
     * @throws InterruptedException スレッドが待機中に割り込まれた場合
     */
    public static boolean waitForCondition(Supplier<Boolean> condition) throws InterruptedException {
        int retryCount = 0;
        while (retryCount < MAX_RETRY) {
            if (condition.get()) {
                return true;
            }
            Thread.sleep(WAIT_TIME_MS);
            retryCount++;
        }
        return false;
    }
}

テストの実装

以上でテスト用の設定とUtilクラスの作成は完了です。それでは実際にこれらを利用したサンプルテストを書いてみましょう。

SampleIT
public class SampleIT extends BaseIntegrationTest {

    @Value("${spring.cloud.aws.sqs.user-created.name}")
    private String queueName;

    @Autowired
    private SqsService sqsService;

    @Autowired
    private SendGridApiCallCaptor sendGridCaptor;

    @Autowired
    private UserRepository userRepository;

    @BeforeEach
    void setUp() {
        userRepository.deleteAll();
        sendGridCaptor.clear();
    }

    @Test
    @DisplayName("Signupした際にUserが作成され、Welcomeメールが送られること")
    void testSignupEvent_createsUser() throws Exception {
        // Arrange
        String message = """
              {
                "user_id": "sample-id",
                "event": "created"
              }
              """;

        // Act
        sqsService.sendSnsMessageToQueue(queueName, 1, message);

        // Assert
        waitForCondition(() -> {
            boolean isUserCreated = userRepository.count() > 0;
            boolean isMailSent = sendGridCaptor.hasAnyCall();
            return isUserCreated && isMailSent;
        });
        assertTrue(userRepository.findByUserId("sample-id").isPresent());
        assertThat(sendGridCaptor.getAllCalls().getFirst().body()).contains("ようこそ!");
    }
}

Tips: インテグレーションテストと単体テストを分けて実行する

インテグレーションテスト実行用のタスクを単体テストと切り分けておくと便利です。まずはインテグレーションテスト用の基底クラスにタグを付加します。

BaseIntegrationTest
...
@Tag("integration")
public abstract class BaseIntegrationTest {
    ...
}

その上でgradleには上記タグが付加されたテストのみを実行するタスクを定義します。こうすればコミット時は単体テストのみ実行し、プッシュ時はインテグレーションテストも実行する、といった使い分けができます。また下記設定であればIntelliJ上でもテスト実行ボタンからインテグレーションテストができるため非常に便利です。

build.gradle
// 全てのテストを実行
test {
    useJUnitPlatform()
}

// 単体テストのみ実行
tasks.register('unitTest', Test) {
    description = 'Runs unit tests.'
    group = 'verification'

    useJUnitPlatform {
        excludeTags 'integration'
    }
}

// インテグレーションテストのみ実行
tasks.register('integrationTest', Test) {
    description = 'Runs integration tests.'
    group = 'verification'

    useJUnitPlatform {
        includeTags 'integration'
    }
}

あとがき

これでイベント駆動システムのインテグレーションのセットアップは完了です。今回は設定方法の解説だったためサンプルは正常系しか扱いませんでしたが、実際にはメッセージの破損や輻輳といったイレギュラーなケースのテストの方がより重要です。異常系のテストを充実させた上でCICDに組み込めば、バグの混入を効果的に防止できるでしょう。イベント駆動システムにおいてもインテグレーションテストを導入し、バグに強い安心感のある開発を進めてください。

脚注
  1. Testcontainers公式 ↩︎

  2. LocalStack公式 ↩︎

  3. 3系以前ではTestcontainersの仕様が大きく異なります ↩︎

WealthNavi Engineering Blog

Discussion