gRPC+DBアクセスありのSpring Bootバッチアプリの結合テスト構成を考えてみた

「MySQLへのDBアクセス」と「gRPCを使ったAPIアクセス」を行うSpring Bootを使ったバッチアプリ(ちなみにSpring Batchは使わない)を作ることになりそうなので、そのバッチアプリの結合テストをどうやってやろうか少し考えてみたのでメモっておきます。

# バッチアプリの構成イメージ

以下のように、Spring Bootの`ApplicationRunner`の実装クラスを作ってバッチ処理を実行する想定です。なお、マスター情報など他のサービスで管理している情報へのアクセスはgRPCを利用します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/117313/b6be0410-2e21-9a7e-6382-fa465219a887.png)

# 結合テストの構成イメージ案

で、このバッチアプリを結合テストは以下のような構成で行おうかな〜と今のところ思っています。ここで言う結合テストでは、実際の外部サービスには接続せずに、結合テスト用のシミュレータ（スタブ）にアクセスする構成にします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/117313/6522ad04-3f80-1aaf-12af-2852e4f9f3bf.png)

# 使用する主なライブラリ

* Spring Boot (バッチアプリ実行環境)
* MyBatis (DBアクセスライブラリ)
* gRPC-Java (gRPCライブラリ)
* JUnit (テスティングフレームワーク)
* AssertJ (検証支援用ライブラリ)
* DBUnit (DBセットアップ/検証用ライブラリ)

# テスト支援向けの自作クラス

いくつかのテスト支援クラスを作りました。

## MockGrpcServer

gRPCの擬似的なサーバの役割を担うクラスです。

```java
package com.example.demo.support.grpc;

import io.grpc.BindableService;
import io.grpc.Server;
import io.grpc.ServerBuilder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import java.util.stream.Stream;

public class MockGrpcServer {
  private static final Logger logger = LoggerFactory.getLogger(MockGrpcServer.class);
  private final int port;
  private final BindableService[] services;
  private String portExportPropertyKey;
  private Server server;

  public MockGrpcServer(int port, BindableService... services) {
    this.port = port;
    this.services = services;
  }

  public MockGrpcServer(BindableService... services) {
    this(50051, services);
  }

  public void setPortExportPropertyKey(String portExportPropertyKey) {
    this.portExportPropertyKey = portExportPropertyKey;
  }

  public void start() throws IOException {
    ServerBuilder<?> builder = ServerBuilder.forPort(port);
    Stream.of(services).forEach(builder::addService);
    server = builder
        .build()
        .start();
    Optional.ofNullable(portExportPropertyKey)
        .ifPresent(x -> System.setProperty(x, String.valueOf(getPort())));
    logger.info("Server started, listening on " + getPort());
    Runtime.getRuntime().addShutdownHook(new Thread(MockGrpcServer.this::stop));
  }

  public int getPort() {
    return server == null ? -1 : server.getPort();
  }

  public void stop() {
    if (server != null) {
      Optional.ofNullable(portExportPropertyKey).ifPresent(System::clearProperty);
      int port = getPort();
      try {
        server.shutdown().awaitTermination(30, TimeUnit.SECONDS);
      } catch (InterruptedException e) {
        e.printStackTrace(System.err);
      }
      logger.info("Server stopped, listening on " + port);
      server = null;
    }
  }
}
```

## MockOperations

gRPCサーバに登録するスタブから応答するデータを管理するクラスです（＋要求データを保持してテストケースから参照できるようにします）。

```java
package com.example.demo.support;

import java.util.LinkedList;
import java.util.List;
import java.util.Optional;
import java.util.Queue;
import java.util.function.Function;

public class MockOperations<I, O> {

  private final Queue<Function<I, O>> operations = new LinkedList<>();
  private final Queue<I> inputs = new LinkedList<>();

  public MockOperations<I, O> add(Function<I, O> operation) {
    operations.offer(operation);
    return this;
  }

  public I pollInput() {
    return inputs.poll();
  }

  public List<I> getAllInput() {
    return new LinkedList<>(inputs);
  }

  public boolean hasInputs() {
    return !inputs.isEmpty();
  }

  public void clear() {
    operations.clear();
    inputs.clear();
  }

  public O execute(I input) {
    inputs.offer(input);
    return Optional.ofNullable(operations.poll()).orElseThrow(()
        -> new IllegalStateException("Operation is empty.")).apply(input);
  }
}
```

## TransactionStub

擬似gRPCサーバへ登録するAPIのスタブクラス(protoファイルから生成したクラスを継承したクラス)です。このクラスは前述の`MockOperations`を介してテストケース側から指定されたデータを応答します。

```java
package com.example.demo.support.grpc.stub;

import com.example.demo.support.MockOperations;
import com.example.demo.proto.TransactionGrpc;
import com.example.demo.proto.TransactionReferReply;
import com.example.demo.proto.TransactionReferRequest;
import com.example.demo.proto.TransactionReply;
import com.example.demo.proto.TransactionRequest;
import io.grpc.stub.StreamObserver;

public class TransactionStub extends TransactionGrpc.TransactionImplBase {

  public static final TransactionStub INSTANCE = new TransactionStub();

  public final MockOperations<TransactionRequest, TransactionReply> mockOperationsForCreate
      = new MockOperations<>();
  public final MockOperations<TransactionReferRequest, TransactionReferReply> mockOperationsForRefer
      = new MockOperations<>();

  private TransactionStub() {
    // NOP
  }

  public void clear() {
    mockOperationsForCreate.clear();
    mockOperationsForRefer.clear();
  }

  @Override
  public void create(TransactionRequest request, StreamObserver<TransactionReply> responseObserver) {
    responseObserver.onNext(mockOperationsForCreate.execute(request));
    responseObserver.onCompleted();
  }

  @Override
  public void refer(TransactionReferRequest request, StreamObserver<TransactionReferReply> responseObserver) {
    responseObserver.onNext(mockOperationsForRefer.execute(request));
    responseObserver.onCompleted();
  }

}
```


参考：protoファイル

```proto
syntax = "proto3";

option java_multiple_files = true;
option java_package = "com.example.demo.proto";

package com.example.demo.proto;

service Transaction {
  rpc Create (TransactionRequest) returns (TransactionReply) {}
  rpc Refer (TransactionReferRequest) returns (TransactionReferReply) {}
}

message TransactionRequest {
  string name = 1;
  string vendor = 2;
  int64 amount = 3;
}

message TransactionReply {
  string id = 1;
}

message TransactionReferRequest {
  string id = 1;
}
message TransactionReferReply {
  enum Status {
    ACCEPT = 0;
    COMPLETED = 1;
    REJECTED = 2;
  }
  string id = 1;
  string name = 2;
  string vendor = 3;
  int64 amount = 4;
  Status status = 5;
}
```

## TestDataSetupRunner

バッチのメイン処理を実行する前に、テストデータをDBや擬似gRPCサーバに登録したスタブクラスへ登録するための`ApplicationRunner`の実装クラスです。このクラスを利用することで、トランザクション境界をテストケース側で制御できるようにしています。

> **NOTE**
>
> このクラスが必要と判断した理由は、JUnitの `@BeforeEach` や `@Test` メソッドが呼び出される前にバッチのメイン処理(`ApplicationRunner` の `run` メソッド)が呼び出されるためです。

```java
package com.example.demo.support;

import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.Ordered;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.DefaultTransactionDefinition;

import java.util.Optional;

public class TestDataSetupRunner implements ApplicationRunner, Ordered {
  private final PlatformTransactionManager transactionManager;
  private final ApplicationRunner delegate;

  private TransactionStatus transactionStatus;

  public TestDataSetupRunner(ApplicationRunner delegate) {
    this(null, delegate);
  }

  public TestDataSetupRunner(PlatformTransactionManager transactionManager, ApplicationRunner delegate) {
    this.transactionManager = transactionManager;
    this.delegate = delegate; // 具体的なテストデータ登録処理はテストケース側で実装してもらう
  }

  public void beginTransaction() {
    Optional.ofNullable(transactionManager).ifPresent(x -> {
      DefaultTransactionDefinition definition = new DefaultTransactionDefinition();
      definition.setName("SetupApplicationRunner");
      this.transactionStatus = x.getTransaction(definition);
    });
  }

  public void rollbackTransaction() {
    Optional.ofNullable(transactionStatus).ifPresent(s -> Optional.ofNullable(transactionManager)
        .ifPresent(m -> m.rollback(s)));
  }

  @Override
  public void run(ApplicationArguments args) throws Exception {
    beginTransaction(); // テストデータの登録処理を実行する前にトランザクションを開始する
    try {
      delegate.run(args);
    } catch (Throwable e) {
      rollbackTransaction();
      throw e;
    }
  }

  @Override
  public int getOrder() {
    return Ordered.HIGHEST_PRECEDENCE; // バッチのメイン処理よりも前に実行されるようにするために優先度を高くする
  }

}
```


# テストクラス

テスト支援クラスを活用したテストケースクラスは以下のような感じになります。なお、DBのテストデータのセットアップ＋DBの検証にはDBUnitを使うようにしました（実際にDBUnit使うかは未定・・・）。

```java
package com.example.demo;

import com.example.demo.support.grpc.MockGrpcServer;
import com.example.demo.support.grpc.stub.TransactionStub;
import com.example.demo.proto.TransactionReferReply;
import com.example.demo.proto.TransactionReferRequest;
import com.example.demo.proto.TransactionReply;
import com.example.demo.proto.TransactionRequest;
import com.example.demo.support.TestDataSetupRunner;
import org.assertj.core.api.Assertions;
import org.dbunit.Assertion;
import org.dbunit.DataSourceDatabaseTester;
import org.dbunit.IDatabaseTester;
import org.dbunit.dataset.IDataSet;
import org.dbunit.dataset.ITable;
import org.dbunit.dataset.SortedTable;
import org.dbunit.dataset.xml.FlatXmlDataSetBuilder;
import org.dbunit.operation.DatabaseOperation;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.system.CapturedOutput;
import org.springframework.boot.test.system.OutputCaptureExtension;
import org.springframework.context.annotation.Bean;
import org.springframework.core.io.ClassPathResource;
import org.springframework.jdbc.datasource.TransactionAwareDataSourceProxy;
import org.springframework.transaction.PlatformTransactionManager;

import javax.sql.DataSource;
import java.util.Optional;
import java.util.UUID;

@ExtendWith(OutputCaptureExtension.class)
@SpringBootTest(
    args = "--user=kazuki",
    classes = {DemoBatchApplication.class, DemoBatchApplication001Tests.LocalTestContext.class}
)
class DemoBatchApplication001Tests {

  private static MockGrpcServer server;
  private static String tranId;

  @Autowired
  TestDataSetupRunner setupApplicationRunner;
  @Autowired
  IDatabaseTester databaseTester;

  @BeforeAll
  static void startupMockGrpcServer() throws Exception {
    // 擬似gRPCサーバを起動
    server = new MockGrpcServer(TransactionStub.INSTANCE);
    server.setPortExportPropertyKey("demo.grpc.port");
    server.start();
  }

  @AfterAll
  static void shutdownMockGrpcServer() {
    // 擬似gRPCサーバの停止
    Optional.ofNullable(server).ifPresent(MockGrpcServer::stop);
    TransactionStub.INSTANCE.clear();
  }

  @AfterEach
  void tearDownDatabase() throws Exception {
    // テスト終了時のトランザクションをロールバック
    setupApplicationRunner.rollbackTransaction();
    databaseTester.onTearDown();
  }

  @Test
  void runJob(CapturedOutput output) throws Exception {
    // このメソッドが呼び出されるタイミングでは、メイン処理が実行されている
    // データベースの検証
    {
      IDataSet expectedDataSet = new FlatXmlDataSetBuilder().build(new ClassPathResource("db/xml/After.xml").getInputStream());
      ITable expectedTable = new SortedTable(expectedDataSet.getTable("t_users"));
      IDataSet databaseDataSet = databaseTester.getConnection().createDataSet();
      ITable actualTable = new SortedTable(databaseDataSet.getTable("t_users"));
      Assertion.assertEquals(expectedTable, actualTable);
    }
    // gRPCの更新系処理の呼び出しパラメータの検証
    {
      TransactionRequest request = TransactionStub.INSTANCE.mockOperationsForCreate.pollInput();
      Assertions.assertThat(request.getName()).isEqualTo("Electric bil at March 2021");
      Assertions.assertThat(request.getVendor()).isEqualTo("Demo Pay");
      Assertions.assertThat(request.getAmount()).isEqualTo(5000);
    }
    // gRPCの参照系処理の呼び出しパラメータの検証
    {
      TransactionReferRequest request = TransactionStub.INSTANCE.mockOperationsForRefer.pollInput();
      Assertions.assertThat(request.getId()).isEqualTo(tranId);
    }
    // 標準出力の検証
    {
      Assertions.assertThat(output).contains("New Transaction Id: " + tranId);
      Assertions.assertThat(output).contains("Transaction Status of " + tranId + ": COMPLETED");
    }
  }

  static class LocalTestContext {
    @Bean // DBUnitのセットアップ
    IDatabaseTester databaseTester(DataSource dataSource) {
      // DBUnitの処理をSpringのトランザクション管理下で行うようにするためにデータソースをTransactionAwareDataSourceProxyでラップする
      return new DataSourceDatabaseTester(new TransactionAwareDataSourceProxy(dataSource));
    }

    @Bean // テストデータの登録を行うRunnerのセットアップ
    TestDataSetupRunner testDataSetupRunner(PlatformTransactionManager transactionManager, IDatabaseTester databaseTester) {
      return new TestDataSetupRunner(transactionManager, args -> {
        // ----------------------------
        // データベースへテストデータを登録
        // ----------------------------
        {
          databaseTester.setDataSet(new FlatXmlDataSetBuilder().build(new ClassPathResource("db/xml/Before.xml").getInputStream()));
          databaseTester.setSetUpOperation(DatabaseOperation.CLEAN_INSERT);
          databaseTester.onSetup();
        }
        // ----------------------------
        // 応答データをgRPCのスタブへ登録
        // ----------------------------
        {
          tranId = UUID.randomUUID().toString();
          // for create transaction
          TransactionStub.INSTANCE.mockOperationsForCreate
              .add(request -> TransactionReply.newBuilder().setId(tranId).build());
          // for refer transaction
          TransactionStub.INSTANCE.mockOperationsForRefer
              .add(request -> TransactionReferReply.newBuilder()
                  .setId(tranId)
                  .setName("Electric bil at March 2021")
                  .setVendor("Demo Pay")
                  .setAmount(5000)
                  .setStatus(TransactionReferReply.Status.COMPLETED)
                  .build());
        }
      });
    }

  }

}
```

# サンプルプロジェクト

ここで紹介したコードは、GitLabに格納してあります。（GitHubではなくGitLabに格納したのは、お仕事でGitLabを使ってCI/CDを行っているため、CI/CDの検証もしたかったのでGitLabにしました）

* https://gitlab.com/kazuki43zoo/demo-batch

# まとめ

最終的にこの方法で結合テストを行うかはまだ分かりませんが、パッと思いついた方法を共有してみました。もっとスマートな方法あるよ〜とかあればぜひコメントを！！