オブジェクトの広場はオージス総研グループのエンジニアによる技術発表サイトです

マイクロサービス

マイクロサービスアーキテクチャに効く!テスト技法

サービス間インターフェースのテスト技法 CDCテスト 実践編その2 非同期メッセージングのテスト
オージス総研 技術部 アドバンストテクノロジセンター
今村 大輔
2021年6月22日

前回はSpring Cloud Contractを用いてREST APIのCDCテストを実施しました。
今回はより高度な非同期メッセージングのCDCテストを実施します。メッセージキューはAWS SQSを使用します。CDCテストツールは前回同様Spring Cloud Contractを使用します。

はじめに

マイクロサービスアーキテクチャでサービス間連携を実施する際、接続方式がREST API (HTTP)だけで要件充足するとは限りません。分散システムとしての耐障害性向上や、あるイベントをトリガーとする複数処理の並列実行を目的として、非同期メッセージングによるサービス間連携が必要になるケースが多いです。データ更新を伴う処理は非同期メッセージングがインターフェースになるのではないでしょうか。

非同期メッセージングのインターフェースにもCDCテストは効果を発揮します。Spring Cloud Contractは、非同期メッセージングによるサービス間連携のCDCテストにも対応しています。この記事を通じて、以下が実現できればと思います。

  • Contractを作成できるようになる
    • 記述形式
    • ディレクトリ配置ルール
  • Contractからテストコード・スタブを生成し、実行できるようになる
    • Gradleでテストコード・スタブ生成タスク実行
    • GradleでスタブをMavenリポジトリへインストール
    • Contractとテストコード出力内容の対応付け
    • Spring Cloud Contractが提供するMessageVerifierの使い方
    • 手作業で実装が必要なクラスの内容把握
  • Spring Cloud Contractの設定箇所を把握する
    • Gradleの設定

トレーニングアプリケーションで使用する各種ツール・ライブラリは以下のバージョンで実装・動作確認しています。

  • Spring Cloud Contract:2.2.5.RELEASE
  • Spring Boot:2.3.7.RELEASE
  • Spring Cloud AWS:2.2.5.RELEASE
  • Gradle:6.7.1

前述の通り、本記事では非同期メッセージングの実装プロトコルとしてAWSのSQS (Simple Queue Service)を使用します。Spring Cloud Contractは様々なプロトコルと組み合わせられるよう汎用インターフェースを提供しており、AWS SQSはその使い方の題材として説明します。その他のプロトコルを使用する場合も参考にできるでしょう。

トレーニングアプリケーションの開発・実行環境

トレーニングアプリケーションの開発・実行環境は前回同様です。ソースコード一式はGitHubのリポジトリで公開しています。

前回同様、各種ファイルのパスはGitHubリポジトリをcloneしたパスを基準に相対パスで記述します。

加えて、今回はAWSのサービスを組み合わせて使用します。以下のサービスをセットアップしてください。CloudFormationのテンプレートからスタックを作成する方法は公式ドキュメントを参照してください。同様に、IAMポリシー付与方法についても公式ドキュメントを参照してください。

サービス CloudFormationテンプレートのパス 必要なIAMポリシー
SQS demo-cdctest/cdctest-awssqs-queue/src/main/resources/cfn/aws-sqs-queue.yaml
  • AmazonSQSFullAccess
S3 demo-cdctest/cdctest-awssqs-consumer/src/main/resources/cfn/cdctest-awssqs-s3.yaml
  • AmazonS3FullAccess
  • 独自に環境構築したAWS外のローカルPCで実行する場合、IAMユーザにポリシーを付与の上、クレデンシャルと実行するリージョン(S3とSQSのリージョン)の設定を実施ください
  • GitHubリポジトリ内の設定ファイル上は、実行リージョンとして「us-east-2」を指定しています。その他のリージョンで実行する場合、以下箇所を実際に使用するリージョン名に変更ください

    • demo-cdctest/cdctest-awssqs-queue/src/main/resources/application.properties

      • cloud.aws.region.static 値

        cloud.aws.region.static=us-east-2
        
  • S3のバケット名はアカウントを跨ってユニークな名前を付与する必要があります。CloudFormationのテンプレートにデフォルトのバケット名として「cdctest-awssqs-s3」を指定していますが、誰かが同じ名前を使用している場合、名称重複で作成エラーが発生します*1。その場合は以下箇所を任意のバケット名に変更ください*2

    • demo-cdctest/cdctest-awssqs-queue/src/main/resources/cfn/aws-sqs-queue.yaml

      • /Parameters/BucketName/Default 値

        Parameters:
          BucketName:
            Type: String
            Default: "cdctest-awssqs-s3"
        
    • demo-cdctest/cdctest-awssqs-queue/src/main/resources/application.properties

      • cdctest-awssqs-consumer.s3.bucket.name

        cdctest-awssqs-consumer.s3.bucket.name=cdctest-awssqs-s3
        

公開しているソースコードはご自身の責任の元でご利用頂くようお願いいたします。CDCテスト / Spring Cloud Contractの個人的学習を目的とするものであり、パッチ適用や脆弱性検査を含むメンテナンスは実施しておりません。本ソースコードに関する問題点及びその問題点が原因で発生した損害等に関して、オージス総研ならびにオブジェクトの広場編集部は一切責任を持ちません。

トレーニングアプリケーションの仕様・構成・ビルド・起動

今回のトレーニングアプリケーションは、以下3つのプロジェクトで構成されています。

プロジェクト 役割・用途・実装の概要 内容物
cdctest-awssqs-queue
  • Queueのインターフェース。「注文Queue」の定義など
  • Contract
  • プロデューサスタブ(テストコード)
  • QueueのCloudFormationテンプレート
  • Queueのモック
cdctest-awssqs-producer
  • Queueのプロデューサ(メッセージ送信アプリケーション)。REST API「注文API」で受けた「注文」を「注文Queue」へ流す
  • Spring Bootで実装されており、WEBサーバ(Servletコンテナ)とSQSクライアントが内包されている
  • 注文APIの実装
  • 注文Queueプロデューサの実装
  • コンシューマスタブ(テストコードと注文Queue受信MessageVerifier)
cdctest-awssqs-consumer
  • Queueのコンシューマ(メッセージ受信アプリケーション)。「注文Queue」からのメッセージを受信し、「注文一覧」Excelファイルを作成してS3へアップロードする
  • Spring Bootで実装されている
  • 注文Queueコンシューマの実装
  • S3クライアントの実装
  • プロデューサスタブ(テストコードと注文Queue送信MessageVerifier)
  • S3のCloudFormationテンプレート


これらプロジェクトは、以下の仕様・配置で構成されます。

トレーニングアプリケーションの構成

各プロジェクトをビルドして、動作確認を行います。下記手順を実施してください。

  1. cdctest-awssqs-queueのビルドと実行

    1. demo-cdctest/cdctest-awssqs-queueディレクトリに移動
    2. 以下コマンドを実行してビルド

      gradlew build -x test
      
    3. 以下コマンドを実行してMavenローカルリポジトリへインストール

      gradlew install
      
  2. cdctest-awssqs-producerのビルドと実行

    1. demo-cdctest/cdctest-awssqs-producerディレクトリに移動
    2. 以下コマンドを実行してビルド

      gradlew build -x test
      
    3. 以下コマンドを実行してプロデューサ起動

      java -jar .\build\libs\cdctest-awssqs-producer-0.1.0-SNAPSHOT.jar
      

      起動時、標準出力に以下のスタックトレースが出力されますが、「警告」のメッセージです。無視して問題ありません。

      2021-04-26 19:46:38.952  WARN 3068 --- [           main] com.amazonaws.util.EC2MetadataUtils      : Unable to retrieve the requested metadata (/latest/meta-data/instance-id). Failed to connect to service endpoint:
      
      com.amazonaws.SdkClientException: Failed to connect to service endpoint:
              at com.amazonaws.internal.EC2ResourceFetcher.doReadResource(EC2ResourceFetcher.java:100) ~[aws-java-sdk-core-1.11.792.jar!/:na]
              at com.amazonaws.internal.InstanceMetadataServiceResourceFetcher.getToken(InstanceMetadataServiceResourceFetcher.java:91) ~[aws-java-sdk-core-1.11.792.jar!/:na]
              at com.amazonaws.internal.InstanceMetadataServiceResourceFetcher.readResource(InstanceMetadataServiceResourceFetcher.java:69) ~[aws-java-sdk-core-1.11.792.jar!/:na]
              at com.amazonaws.internal.EC2ResourceFetcher.readResource(EC2ResourceFetcher.java:66) ~[aws-java-sdk-core-1.11.792.jar!/:na]
              at com.amazonaws.util.EC2MetadataUtils.getItems(EC2MetadataUtils.java:402) [aws-java-sdk-core-1.11.792.jar!/:na]
              at com.amazonaws.util.EC2MetadataUtils.getData(EC2MetadataUtils.java:371) [aws-java-sdk-core-1.11.792.jar!/:na]
              at org.springframework.cloud.aws.context.support.env.AwsCloudEnvironmentCheckUtils.isRunningOnCloudEnvironment(AwsCloudEnvironmentCheckUtils.java:38) [spring-cloud-aws-context-2.2.5.RELEASE.jar!/:2.2.5.RELEASE]
              at org.springframework.cloud.aws.context.annotation.OnAwsCloudEnvironmentCondition.matches(OnAwsCloudEnvironmentCondition.java:38) [spring-cloud-aws-context-2.2.5.RELEASE.jar!/:2.2.5.RELEASE]
      
  3. cdctest-awssqs-consumerのビルドと実行

    1. demo-cdctest/cdctest-awssqs-consumerディレクトリに移動
    2. 以下コマンドを実行してビルド

      gradlew build -x test
      
    3. 以下コマンドを実行してコンシューマ起動

      java -jar .\build\libs\cdctest-awssqs-consumer-0.1.0-SNAPSHOT.jar
      

      起動時、EC2メタデータのエンドポイントに接続できずSdkClientExceptionが発生した旨の警告メッセージが出力されます。プロデューサ同様に無視して問題ありません。

  4. 動作確認

    1. 以下コマンド*3を実行してプロデューサを実行

      curl -X POST -H "Content-Type: application/json; charset=UTF-8" -i http://localhost/orderservice/order --data "{\"product\":\"Jagatara Nanban-Torai\", \"orderer\":\"Akemi Edo\", \"charge\":1000}"
      
    2. 標準出力に下記が出力されてプロデューサが注文を受け付けたことを確認

      HTTP/1.1 200
      Content-Type: application/json
      Transfer-Encoding: chunked
      Date: Tue, 20 Apr 2021 04:29:51 GMT
      {"id":"c90a56ca-1db7-443d-86fe-e7d9a9b05331"}
      
    3. S3のcdctest-awssqs-s3バケット*4にorder-list.xlsxが生成されていることを確認

上記が一通り動作すれば、トレーニングアプリケーションの開発・実行環境は問題なく動作しています。

Contractの読み解き

Spring Cloud ContractのContractを具体的に読み解いていきましょう。「注文Queue」では、以下のContractを定義しています。

  • demo-cdctest/cdctest-awssqs-queue/src/test/resources/contracts/OrderQueue/orderqueue-contract-testcase-01-01.yaml
description: This contract verifies Order Queue! Please built in your CI system consumer and producer each other!
name: Order Queue Contract Test Case 01-01
ignored: false
label: test_case_01_01
input:
  triggeredBy: executeOrder("test_case_01_01")
outputMessage:
  sentTo: cdctest-awssqs-queue
  body:
    id: 550e8400-e29b-41d4-a716-446655440000
    product: プロダクト①
    orderer: 町田町蔵
    charge: 1000
    comment: 備考
  matchers:
    body:
      - path: $.id
        type: by_regex
        value: "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"

非同期メッセージングの場合でも、Contractの書き方はREST APIと大差ありません。HTTPにおけるrequest/response属性が非同期メッセージングにおけるinput/outputMessage属性に置き換わると捉えると、スキーマの読み解きが進むでしょう。

Contractの基本構造は下記の通りです。

  • テストの定義
    • テスト内容の説明
    • テストケース名
    • ラベル
  • 入力値の定義
    • トリガー
  • 出力値の定義
    • 送信先キュー
    • ヘッダ
    • ボディ

スキーマ詳細は公式ドキュメントを参照してください。ここでは、頻繁に使用する属性を紹介します。(*横にスクロールします)

属性 データ型 説明 備考
description 文字列
  • 何をテストするContractであるか説明する項目
  • コンシューマスタブ・プロデューサスタブに反映されないコメント欄
プロジェクトで記載内容をテンプレート化し、後々に向けた内容整理をしておくとよい。Contractの数が増えると、テストの意図がわかりにくくなる
name 文字列
  • テスト名
  • 本項目がコンシューマスタブのテストメソッド名になる
日本語使用可能
ignored 真偽
  • 有効/無効を切り替えるフラグ
  • trueに設定した場合、コンシューマスタブのテストメソッドに@Ignoreが付与され実行がSKIPされる
-
label 文字列
  • Contract(テストケース)を特定するキー
プロデューサスタブでContractを指定してメッセージ送信する際に使用する
input triggeredBy 文字列
  • メッセージ送信のトリガー
  • コンシューマスタブで実行するメソッド名と引数を指定する
コンシューマスタブはここで指定したメソッド・引数を用いてプロデューサをキックする
outputMessage sentTo 文字列
  • 送信先キュー名称
-
headers オブジェクト
  • 出力されるメッセージのヘッダ
-
body オブジェクト
  • 出力されるメッセージのボディ
YAMLのオブジェクトで定義した内容が、コンシューマスタブ・プロデューサスタブではJSONに展開される
matchers オブジェクト
  • 固定できない出力値を定義する
  • 正規表現や出来合いのパターン(only_alpha_unicode等)を指定可能
メッセージヘッダ、メッセージボディ等でオブジェクト構造(値範囲定義の仕方)が異なる。詳細は公式ドキュメント参照


そして、Contractを格納するパスの構成にも注目してください。

demo-cdctest
    └─cdctest-awssqs-queue
        └─src
            └─test
                └─resources
                    └─contracts
                        └─OrderQueue
                                orderqueue-contract-testcase-01-01.yaml
                                orderqueue-contract-testcase-01-02.yaml

REST API同様にContractの配置パスがSpring Cloud Contractの規約で定められています。上記における

  • (プロジェクトのディレクトリ)/src/test/resources/contracts

が、Spring Cloud Contractが使用するリソースディレクトリです。上記直下にQueueを分類するディレクトリ(上記例では「OrderQueue」)を掘って、それぞれにContractを配置します。このディレクトリ名をもとに自動生成されるコンシューマスタブのクラス名が決まります。試験対象となるインターフェースを正しく表現した名称にするとよいでしょう。

コンシューマスタブの自動生成・実装

コンシューマスタブはContractから自動生成するテストコードと、プロジェクト固有の内容を実装したいくつかのクラスで構成されます。今回のトレーニングアプリケーションでは、以下のクラスをコンシューマスタブとして使用します。

  • OrderQueueTest
    • JUnitのテストコード
    • Contractで定義したinputをプロデューサに入力・実行する
    • Queueからプロデューサの出力を受信し、Contractで定義したoutputMessageと期待値を突き合わせる
    • Queueからのメッセージ受信にSpring Cloud Contractが提供する「ContractVerifierMessaging」を使用する
    • Contractから自動生成する
  • OrderQueueBase
    • OrderQueueTestのスーパークラス
    • テスト環境の設定など、プロジェクト固有事項を実装する
    • 手作業で実装する
  • AwsSqsMessageVerifierImpl
    • Spring Cloud Contractが提供するインターフェース「MessageVerifier」のAWS SQS用実装
    • コンシューマスタブは本クラスを通じてQueueからメッセージを受信する
    • 手作業で実装する

上記をクラス図で表すと、以下の関係になります。

コンシューマスタブのクラス図

クラス図でピンと来た方もいらっしゃると思います。非同期メッセージングの場合、「MessageVerifier」というSpring Cloud Contractの汎用入出力インターフェースを用いてCDCテストを実現します。利用するプロトコル毎に実装クラスを作成し*5、Contractで定義した値を入出力します。

以下、順に詳しく説明します。

OrderQueueTestの自動生成

Contractから自動生成するクラスです。demo-cdctest/cdctest-awssqs-producerディレクトリに移動して、下記のコマンドを実行してください。

gradlew generateContractTests

cdctest-awssqs-queueに含まれるContractからコンシューマスタブが生成されます。今回のトレーニングアプリケーションの場合、demo-cdctest/cdctest-awssqs-producer/src/test/gen-java/jp/co/ogis_ri/rd/nautible/cdctest/awssqs/producer/OrderQueueTest.javaが出力されたことと思います。

  • demo-cdctest/cdctest-awssqs-producer/src/test/gen-java/jp/co/ogis_ri/rd/nautible/cdctest/awssqs/producer/OrderQueueTest.java
public class OrderQueueTest extends OrderQueueBase {
    @Inject ContractVerifierMessaging contractVerifierMessaging;
    @Inject ContractVerifierObjectMapper contractVerifierObjectMapper;

    @Test
    public void validate_order_Queue_Contract_Test_Case_01_01() throws Exception {
        // when:
            executeOrder("test_case_01_01");

        // then:
            ContractVerifierMessage response = contractVerifierMessaging.receive("cdctest-awssqs-queue");
            assertThat(response).isNotNull();

        // and:;

        // and:
            DocumentContext parsedJson = JsonPath.parse(contractVerifierObjectMapper.writeValueAsString(response.getPayload()));
            assertThatJson(parsedJson).field("['product']").isEqualTo("\u30D7\u30ED\u30C0\u30AF\u30C8\u2460");
            assertThatJson(parsedJson).field("['orderer']").isEqualTo("\u753A\u7530\u753A\u8535");
            assertThatJson(parsedJson).field("['charge']").isEqualTo(1000);
            assertThatJson(parsedJson).field("['comment']").isEqualTo("\u5099\u8003");

        // and:
            assertThat(parsedJson.read("$.id", String.class)).matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}");
    }
}

@Testアノテーションが示す通り、JUnitのテストコードが出力されます。ここで読み解くべきポイントは以下の点です。

  • OrderQueueBaseクラスを継承している
    • OrderQueueBaseはSpring Cloud Contractを利用するプロジェクト側で実装する(プロジェクト固有の内容)
  • OrderQueueBase#executeOrder(“testcase01_01”) を呼び出してプロデューサを実行している
    • executeOrder(“testcase01_01”) はContractのinputで定義した値
  • ContractVerifierMessaging#receive(“cdctest-awssqs-queue”)を呼び出して(コンシューマスタブとして)Queueからメッセージを受信している
    • ContractVerifierMessagingはSpring Cloud Contractが提供する非同期メッセージングの入出力を司るクラス。同クラスが包含するMessageVerifierの実装クラスを通じてQueueと通信する
  • Queueから受信したメッセージがContractで定義した通りの内容であるかassertしている

OrderQueueBaseの実装

REST APIの場合と同様に、プロデューサの試験環境固有事項をOrderQueueBaseクラスに手作業で実装します。

  • demo-cdctest/cdctest-awssqs-producer/src/test/java/jp/co/ogis_ri/rd/nautible/cdctest/awssqs/producer/OrderQueueBase.java
@ExtendWith(SpringExtension.class)
@SpringBootTest(classes = {ProducerApplication.class, OrderApiController.class, OrderQueueTestConfiguration.class})
@AutoConfigureMessageVerifier
@TestInstance(Lifecycle.PER_CLASS)
@Slf4j
public class OrderQueueBase {

    @Configuration
    static class OrderQueueTestConfiguration {

        @Bean
        public CsvMapper testDataMapper() {
            return new CsvMapper();
        }

    }

    @Autowired
    AmazonSQSAsync amazonSqs;

    @Autowired
    OrderApiController target;

    private MockMvc mvc;

    private static final String TEST_DATA_CLASSPATH = "jp/co/ogis_ri/rd/nautible/cdctest/awssqs/producer/OrderQueueTest.csv";

    @Autowired
    CsvMapper testDataMapper;

    private Map<String, TestOrder> inputs;

    @BeforeAll
    public void init() throws Exception {

        inputs = new HashMap<String, OrderQueueBase.TestOrder>() {
            {

                CsvSchema testDataSchema = testDataMapper.schemaFor(TestData.class).withNullValue("null");

                Resource testDataCsvFile = new ClassPathResource(TEST_DATA_CLASSPATH);
                InputStream is = testDataCsvFile.getInputStream();

                try (BufferedReader br = new BufferedReader(new InputStreamReader(is))) {

                    MappingIterator<TestData> iterator = testDataMapper.readerFor(TestData.class).with(testDataSchema).readValues(br);
                    TestData testData;
                    while (iterator.hasNext()) {
                        testData = iterator.next();
                        put(testData.getTestCase(), testData.getTestOrder());
                    }

                }

            }
        };

    }

    @BeforeEach
    public void setUp() throws Exception {

        mvc = MockMvcBuilders.standaloneSetup(target).build();

        // Note:
        // キューをクリーンナップするpurgeQueueは反映に最大60秒必要.
        // さらに、60秒に1度しか実行できない制約がある.
        // 仮に、60秒以内に複数回実行された場合、SC/403が応答され、実行時例外が投出される.

        log.info(this.getClass().getSimpleName() + " try to purge queue \"cdctest-awssqs-queue\". it takes up to 60 seconds...");

        PurgeQueueResult result = null;
        int retryCount = 0;
        final int maxRetryCount = 1;

        while (Objects.isNull(result)) {

            try {

                Future<PurgeQueueResult> purge = amazonSqs.purgeQueueAsync(new PurgeQueueRequest("cdctest-awssqs-queue"));
                result = purge.get(60, TimeUnit.SECONDS);

            } catch (ExecutionException e) {

                if (e.getCause().getClass().equals(PurgeQueueInProgressException.class) && retryCount <= maxRetryCount) {
                    Thread.sleep(60 * 1000);
                    retryCount++;
                } else {
                    throw e;
                }

            }

        }

    }

    protected void executeOrder(String testCase) throws Exception {

        TestOrder input = inputs.get(testCase);

        if (Objects.isNull(input)) {
            throw new TestDataLoadingException("missing testdata! testcase:" + testCase + " in " + TEST_DATA_CLASSPATH);
        }

        log.info("Contract Test " + this.getClass().getSimpleName() + "#" + testCase + " is start execution! test order:" + input.toString());

        mvc.perform(MockMvcRequestBuilders.post("/order")
                .contentType(MediaType.APPLICATION_JSON)
                .characterEncoding(StandardCharsets.UTF_8.toString())
                .content(input.toJsonString())
                .accept(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk())
                .andReturn();

        log.info("Contract Test " + this.getClass().getSimpleName() + "#" + testCase + " completed execution! start queuing and wait few seconds, please wait...");
        Thread.sleep(3 * 1000);

    }

    @AllArgsConstructor
    @NoArgsConstructor
    @Getter
    @ToString
    @JsonPropertyOrder({"testCase", "product", "orderer", "charge", "comment"})
    static class TestData {

        private String testCase;

        private String product;

        private String orderer;

        private Integer charge;

        private String comment;

        public TestOrder getTestOrder() {
            return new TestOrder(product, orderer, charge, comment);
        }

    }

    @AllArgsConstructor
    @Getter
    @ToString
    @JsonInclude(JsonInclude.Include.NON_NULL)
    static class TestOrder {

        @JsonIgnore
        private static final ObjectMapper MAPPER = new ObjectMapper();

        private String product;

        private String orderer;

        private Integer charge;

        private String comment;

        public String toJsonString() throws Exception {
            return MAPPER.writeValueAsString(this);
        }

    }

    static class TestDataLoadingException extends RuntimeException {

        public TestDataLoadingException(String message) {
            super(message);
        }

        public TestDataLoadingException(String message, Exception cause) {
            super(message, cause);
        }

    }

}

OrderQueueBaseで実施していることを端的に述べると

  • @SpringBootTestと@AutoConfigureMessageVerifierアノテーションでMessageVerifierを設定する
  • @BeforeAllアノテーションを付与したinitメソッドで、テストケース毎のテストデータ(「注文API」へ入力されるデータ)をCSVファイルからメモリに展開する
  • @BeforeEachアノテーションを付与したsetUpメソッドで、テストの都度Queueをクリーンナップする
  • OrderQueueTestの各テストメソッドから呼び出されるexecuteOrderメソッドで、「注文API」を実行する
    • Contractで定義されたexecuteOrderメソッドの引数をキーとしてテストデータを特定する
    • 「注文API」はモックを使用する。Spring MVCのControllerをMockMvcでオンメモリ実行する

です。REST APIの時よりも実装することが多いように見えるかもしれません。非同期メッセージングの場合、テスト毎にQueueのステート管理を行う必要があります。また、プロデューサをキックするためのテストデータ管理が必要になります。

@AutoConfigureMessageVerifierアノテーションについて補足すると、クラスパス中にあるMessageVerifierの実装クラスをSpringのDIコンテナにBeanとして登録するものです*6。オプションで設定できるパラメータはありません。@SpringBootTestとセットで使用してください。このアノテーションを付与することで、OrderQueueTestがQueueからのメッセージ受信に使用するContractVerifierMessagingへMessageVerifierのインスタンス(ここではAwsSqsMessageVerifierImpl)が依存性注入されます。

AwsSqsMessageVerifierImplの実装

OrderQueueTestがContractVerifierMessagingを通じて呼び出すQueueからのメッセージ受信クラスを実装します。

  • demo-cdctest/cdctest-awssqs-producer/src/test/java/jp/co/ogis_ri/rd/nautible/cdctest/awssqs/producer/AwsSqsMessageVerifierImpl.java
@Component
@Slf4j
public class AwsSqsMessageVerifierImpl implements MessageVerifier<Message<String>> {

    @Autowired
    AmazonSQSAsync amazonSqs;

    @Autowired
    QueueMessagingTemplate queueMessagingTemplate;

    @PostConstruct
    public void onPostConstruct() {
        log.info(this.getClass().getSimpleName() + " of MessageVerifier implimentation is instantated!");
    }

    @Override
    public void send(Message<String> message, String destination) {
        throw new UnsupportedOperationException("This is producer application testing! No need to send message to any queue, only receiving! message:" + message.toString() + " destination:" + destination);
    }

    @Override
    public <T> void send(T payload, Map<String, Object> headers, String destination) {
        throw new UnsupportedOperationException("This is producer application testing! No need to send message to any queue, only receiving! message:" + payload.toString() + " headers:" + headers.toString() +  " destination:" + destination);
    }

    @Override
    public Message<String> receive(String destination, long timeout, TimeUnit timeUnit) {
        // 実際には使用されないメソッドなのでtimeout/timeUnitの実装は割愛
        return receive(destination);
    }

    @SuppressWarnings("unchecked")
    @Override
    public Message<String> receive(String destination) {

        Message<?> message = queueMessagingTemplate.receive(destination);

        amazonSqs.deleteMessage(amazonSqs.getQueueUrl("cdctest-awssqs-queue").getQueueUrl(), message.getHeaders().get("ReceiptHandle").toString());

        log.info("successflly receive " + message.toString() + " from " + destination + "!");

        return (Message<String>) message;

    }

}

読み解きのポイントは以下の通りです。

  • クラスに@Componentアノテーションを付与してSpringのDIコンテナにBeanとして登録する
  • MessageVerifier<Message<T>>をimplementsする
    • ここでは単純な文字列でQueueとやりとりするためStringを型パラメータとした
    • メッセージのJSONに直接対応するクラスを型パラメータで指定してもよい
  • MessageVerifier<Message<T>>のreceiveメソッド(タイムアウト設定あり/なし)を実装する
    • 引数のdestinationはContractで定義された「送信先キュー」がOrderQueueTestから入力される
    • Spring Cloud AWSのQueueMessagingTemplateを通じてメッセージを受信する
    • QueueMessagingTemplateのDIコンテナへの登録はプロデューサ本体の実装クラス(ProducerApplication)で行われている
    • 受信したメッセージをQueueから削除する
    • QueueMessagingTemplateでは対応できないので、AWS SDKを直接呼び出している

コンシューマスタブからQueueへメッセージを送信することはないので、MessageVerifierのsendメソッドは実装していません。実行時例外でエラーにしています。

プロデューサの内部結合試験

では、実際にコンシューマスタブを実行してプロデューサの内部結合試験を実施してみましょう。コンシューマスタブからプロデューサが実行され、Queueにメッセージが送信されます。コンシューマスタブはQueueからメッセージを受信し、Contractで定義された期待値と突合わせを実施します。

プロデューサの内部結合試験

プロデューサ・コンシューマを共に停止した状態で、demo-cdctest/cdctest-awssqs-producerディレクトリに移動して、下記のコマンドを実行してください。(Eclipseから実行する場合は、自動生成されたOrderQueueTestを「JUnitテスト」で実行してください)

gradlew test

Gradleのテスト結果レポートがdemo-cdctest\cdctest-awssqs-producer\build\reports\tests\test\index.htmlに出力されます。プロデューサがQueueへ出力したメッセージとContractの期待値が合致していることがわかります。

プロデューサスタブの実装

プロデューサスタブはJUnitのテストコードとMessageVerifierの実装クラスを手作業で実装します。JUnitのテストコード内にて、Contractを内包するスタブjarを参照し、Queueへ定義されたメッセージを送信します。Spring Cloud Contractが提供する「StubFinder」がこれら処理を一手に実行してくれます。

今回のトレーニングアプリケーションでは、以下のクラスをプロデューサスタブとして使用します。

  • ConsumerApplicationContractTest
    • JUnitのテストコード
    • StubFinderでContract定義値をQueueへ送信する
    • Queueからのメッセージをコンシューマが処理した結果と期待値を突き合わせる
    • 手作業で実装する
  • AwsSqsMessageVerifierImpl
    • Spring Cloud Contractが提供するインターフェース「MessageVerifier」のAWS SQS用実装
    • プロデューサスタブは本クラスを通じてQueueへメッセージを送信する
    • 手作業で実装する

上記をクラス図で表すと、以下の関係になります。

プロデューサスタブのクラス図

ConsumerApplicationContractTestの実装

プロデューサスタブの中核となるJUnitのテストコードです。CDCテストとして、プロデューサとコンシューマ間のインターフェースを試験対象としています。コンシューマの処理結果はS3へ出力するのが仕様ですが、その部分は別テストで担保される想定です。本テストではS3出力部分をモックに差替えてassertする実装としました。

  • demo-cdctest/cdctest-awssqs-consumer/src/test/java/jp/co/ogis_ri/rd/nautible/cdctest/awssqs/consumer/ConsumerApplicationContractTest.java
@ExtendWith(SpringExtension.class)
@AutoConfigureStubRunner(
        stubsMode = StubRunnerProperties.StubsMode.CLASSPATH,
        ids = "jp.co.ogis_ri.rd.nautible.cdctest:cdctest-awssqs-queue:0.1.0-SNAPSHOT:stubs"
)
@SpringBootTest(classes = {ConsumerApplication.class, OrderQueueTestConfiguration.class})
@TestInstance(Lifecycle.PER_CLASS)
@Slf4j
public class ConsumerApplicationContractTest {

    @Configuration
    static class OrderQueueTestConfiguration {

        @Autowired
        AmazonSQSAsync amazonSqs;

        @Bean
        public QueueMessagingTemplate queueMessagingTemplate() {
            return new QueueMessagingTemplate(amazonSqs);
        }

        @Bean
        public SimpleMessageListenerContainerFactory simpleMessageListenerContainerFactory(AmazonSQSAsync amazonSqs) {
            SimpleMessageListenerContainerFactory factory = new SimpleMessageListenerContainerFactory();
            factory.setAmazonSqs(amazonSqs);
            factory.setWaitTimeOut(3);
            factory.setAutoStartup(false);
            factory.setMaxNumberOfMessages(1);
            factory.setVisibilityTimeout(10 * 60);
            return factory;
        }

    }

    @Autowired
    AmazonSQSAsync amazonSqs;

    @Autowired
    SimpleMessageListenerContainer messageListenerContainer;

    @Autowired
    StubFinder stubFinder;

    @MockBean
    OrderRepository orderRepository;

    @BeforeEach
    public void setUp() throws Exception {

        // Note:
        // キューをクリーンナップするpurgeQueueは反映に最大60秒必要.
        // さらに、60秒に1度しか実行できない制約がある.
        // 仮に、60秒以内に複数回実行された場合、SC/403が応答され、実行時例外が投出される.

        log.info(this.getClass().getSimpleName() + " try to purge queue \"cdctest-awssqs-queue\". it takes up to 60 seconds...");

        PurgeQueueResult result = null;
        int retryCount = 0;
        final int maxRetryCount = 1;

        while (Objects.isNull(result)) {

            try {

                Future<PurgeQueueResult> purge = amazonSqs.purgeQueueAsync(new PurgeQueueRequest("cdctest-awssqs-queue"));
                result = purge.get(60, TimeUnit.SECONDS);

            } catch (ExecutionException e) {

                if (e.getCause().getClass().equals(PurgeQueueInProgressException.class) && retryCount <= maxRetryCount) {
                    Thread.sleep(60 * 1000);
                    retryCount++;
                } else {
                    throw e;
                }

            }

        }

    }

    @Test
    public void test_case_01_01() throws Exception {

        doNothing().when(orderRepository).save(any());
        ArgumentCaptor<Order> captor = ArgumentCaptor.forClass(Order.class);

        stubFinder.trigger("test_case_01_01");

        messageListenerContainer.start("cdctest-awssqs-queue");

        log.info("Consumer Application is now queuing... this test will sleep few seconds soon, please wait...");
        Thread.sleep(3 * 1000);

        messageListenerContainer.stop("cdctest-awssqs-queue");

        verify(orderRepository, times(1)).save(captor.capture());

        Order savedOrder = captor.getValue();

        assertThat(savedOrder.getId()).isEqualTo("550e8400-e29b-41d4-a716-446655440000");
        assertThat(savedOrder.getProduct()).isEqualTo("プロダクト①");
        assertThat(savedOrder.getOrderer()).isEqualTo("町田町蔵");
        assertThat(savedOrder.getCharge()).isEqualTo(1000);
        assertThat(savedOrder.getComment()).isEqualTo("備考");

    }
}

ここで読み解くべきポイントは以下の点です。

  • @AutoConfigureStubRunnerでContractを含むQueueのスタブjarを読み込む
    • cdctest-awssqs-queue-0.1.0-SNAPSHOT-stubs.jarをテスト用クラスパスから読み込み
    • cdctest-awssqs-queue-0.1.0-SNAPSHOT-stubs.jarはMavenローカルリポジトリからGradleを通じてテスト用クラスパスに通される
  • @SpringBootTestでテスト用のDIコンテナを構成する
    • テスト実行時に意図したタイミングでQueueを参照させるため、SimpleMessageListenerContainerFactoryのインスタンスを自動起動falseでDIコンテナへ登録する
  • @BeforeEachアノテーションを付与したsetUp()で、テストの都度Queueをクリーンナップする
  • @MockBeanアノテーションをコンシューマのS3永続化クラスに付与し、モックインスタンスへ差替える
  • StubFinder#trigger(“testcase01_01”)でContractで定義されたメッセージをQueueへ送信する
    • triggerメソッドの引数にはContractのlabel値を指定する
  • SimpleMessageListenerContainerでQueueのリスナーを起動/停止し、テストとして意図する非同期メッセージングになるよう流れを制御する
    • リスナーが起動すると、コンシューマがQueueからメッセージを受信して処理を実施する
    • コンシューマの処理が完了するであろう頃合いまでテストメソッドの処理を一時停止し、Queueのリスナーを停止する
  • Mockitoを用いてS3永続化クラスのモックインスタンスが期待値通りかをassertする

@AutoConfigureStubRunnerアノテーションを用いてスタブjarを参照するのはREST APIの場合と同様です。前回ご紹介した重要な設定項目を再掲します。その他の設定項目は公式ドキュメントを参照してください。

設定項目 項目概要
stubsMode
  • スタブjarをどこから読み込むかを指定する
  • 設定可能な値は以下
    • StubRunnerProperties.StubsMode.CLASSPATH:JUnit実行時のクラスパス
    • StubRunnerProperties.StubsMode.LOCAL:実行ノードのローカルMavenリポジトリ
    • StubRunnerProperties.StubsMode.REMOTE:リモートMavenリポジトリ
ids
  • 読み込むスタブjarを指定する
  • 「:」区切りで、以下を指定する
    • グループID
    • アーティファクトID
    • バージョン
    • パッケージング
    • ポート番号


idsのバージョン番号について、トレーニングアプリケーションでは明示的に指定していますが、「+」を指定するとMavenリポジトリ内の最新バージョンを読み込みます。同様にパッケージングについても、慣行としてSpring Cloud Contractではスタブjarのパッケージングに「stubs」を使用します。

AwsSqsMessageVerifierImplの実装

ConsumerApplicationContractTestがStubFinderを通じて呼び出すQueueへのメッセージ送信クラスを実装します。

  • demo-cdctest/cdctest-awssqs-consumer/src/test/java/jp/co/ogis_ri/rd/nautible/cdctest/awssqs/consumer/AwsSqsMessageVerifierImpl.java
@Component
@Slf4j
public class AwsSqsMessageVerifierImpl implements MessageVerifier<Message<String>> {

    @Autowired
    QueueMessagingTemplate queueMessagingTemplate;

    @PostConstruct
    public void onPostConstruct() {
        log.info(this.getClass().getSimpleName() + " of MessageVerifier implimentation is instantated!");
    }

    @Override
    public void send(Message<String> message, String destination) {
        send(message.getPayload(), message.getHeaders(), destination);
    }

    @Override
    public <T> void send(T payload, Map<String, Object> headers, String destination) {

        if (!headers.containsKey("contentType")) {
            headers.put("contentType", "application/json");
        }

        queueMessagingTemplate.send(destination, new GenericMessage<T>(payload, headers));

        log.info("send message success! destination:" + destination + ", headers:" + headers.toString() + ", payload:" + payload.toString());

    }

    @Override
    public Message<String> receive(String destination, long timeout, TimeUnit timeUnit) {
        throw new UnsupportedOperationException("This is consumer application testing! No need to receive message to any queue, only sending!" + "destination:" + destination + " timeout:" + Long.toString(timeout) +  " timeUnit:" + timeUnit.toString());
    }

    @Override
    public Message<String> receive(String destination) {
        throw new UnsupportedOperationException("This is consumer application testing! No need to receive message to any queue, only sending!" + "destination:" + destination);
    }

}

読み解きのポイントは以下の通りです。

  • クラスに@Componentアノテーションを付与してSpringのDIコンテナにBeanとして登録する
  • MessageVerifier<Message<T>>をimplementsする
    • ここでは単純な文字列でQueueとやりとりするためStringを型パラメータとした
    • メッセージのJSONに直接対応するクラスを型パラメータとしてもよい
  • MessageVerifier<Message<T>>のsendメソッドを実装する
    • 引数のdestinationはContractで定義された「送信先キュー」がStubFinderから入力される

プロデューサスタブがQueueからメッセージを受信することはないので、MessageVerifierのreceiveメソッドは実装していません。実行時例外でエラーにしています。

AwsSqsMessageVerifierImplはコンシューマスタブと同じクラス名を使用していますが、パッケージが別物です。本記事はプロデューサ/コンシューマを合わせて説明していますが、実際のプロジェクトでは別個のチームが実装・利用するものです。同じクラス名だったとしても、互いに参照して混乱を招くようなことはないでしょう。

コンシューマの内部結合試験

では、実際にプロデューサスタブを実行してコンシューマの内部結合試験を実施してみましょう。プロデューサスタブからQueueへメッセージが送信されます。そのメッセージがトリガーとなり、コンシューマが起動します。プロデューサスタブはコンシューマの処理結果と期待値を突合わせて振舞いを検証します。

コンシューマの内部結合試験

プロデューサ・コンシューマを共に停止した状態で、demo-cdctest/cdctest-awssqs-consumerディレクトリに移動して、下記のコマンドを実行してください。(Eclipseから実行する場合は、予め実装済みのConsumerApplicationContractTestを「JUnitテスト」で実行してください)

gradlew test

Gradleのテスト結果レポートがdemo-cdctest\cdctest-awssqs-consumer\build\reports\tests\test\index.htmlに出力されます。Contractに基づいてプロデューサスタブがQueueへ出力したメッセージから期待通りにコンシューマが動作したことがわかります。

おまけ AWS SQSをモックでテストする

実際のプロジェクトだと、開発環境では実際のメッセージブローカーを使用できない場合があるかもしれません。AWS SQSの場合、リクエスト毎に課金が発生します。コスト意識の高いプロジェクトだと、自動テストの都度メッセージ送受信するのは認められないこともあるでしょう。商用ミドルウェアがメッセージブローカーを担う場合、残念ながら開発環境にはライセンスが用意されていないなんてことも考えられます。

そのような場合、MessageVerifierの実装クラスをメッセージブローカーのモックにしてしまいましょう。cdctest-awssqs-queueに簡易なAWS SQSのモック実装を含めています。

  • demo-cdctest/cdctest-awssqs-queue/src/test/java/jp/co/ogis_ri/rd/nautible/cdctest/awssqs/contracttest/AwsSqsMessageVerifierMock.java
@Component
public class AwsSqsMessageVerifierMock implements MessageVerifier<Object> {

    // AWS SQSは標準設定だとFIFOではない => Setでダミーを構成する
    private Set<Object> queue = Collections.synchronizedSet(new HashSet<>());

    @Override
    public void send(Object message, String destination) {
        send(message, null, destination);
    }

    @Override
    public <T> void send(T payload, Map<String, Object> headers, String destination) {

        if (Objects.isNull(destination) || "".equals(destination)) {
            throw new IllegalQueueException(payload, destination, "destination is null or empty!");
        }

        if (Objects.isNull(payload)) {
            throw new IllegalQueueException(payload, destination, "message is null!");
        }

        // headersは簡略化のため試験対象外とする

        queue.add(payload);

        System.out.println("successflly send " + payload.toString() + " to " + destination + "!");

    }

    @Override
    public Object receive(String destination, long timeout, TimeUnit timeUnit) {

        if (Objects.isNull(destination) || "".equals(destination)) {
            throw new IllegalQueueException(destination, "destination is null or empty!");
        }

        if (this.queue.isEmpty()) {
            return null;
        }

        Object message;
        List<Object> queueAsList = new ArrayList<Object>(this.queue);

        synchronized (this.queue) {
            message = queueAsList.get(0);
            this.queue.remove(message); // AWS SQSの場合、本来はReceiptHandleで削除するが割愛
        }

        System.out.println("successflly receive " + message.toString() + " from " + destination + "!");

        return message;

    }

    @Override
    public Object receive(String destination) {
        return receive(destination, 0, null);
    }

    public static class IllegalQueueException extends RuntimeException {

        private Object queueMessage;

        private String destination;

        public IllegalQueueException(Object queueMessage, String destination, String exceptionMessgae, Exception cause) {
            super(exceptionMessgae, cause);
            this.queueMessage = queueMessage;
            this.destination = destination;
        }

        public IllegalQueueException(Object queueMessage, String destination, String exceptionMessgae) {
            super(exceptionMessgae);
            this.queueMessage = queueMessage;
            this.destination = destination;
        }

        public IllegalQueueException(String destination, String exceptionMessgae) {
            super(exceptionMessgae);
            this.destination = destination;
        }

        public Object getQueueMessage() {
            return queueMessage;
        }

        public String getDestination() {
            return destination;
        }

    }

}

AWS SQSをしっかりシミュレートするものではありませんが、メッセージを“ほんのちょい”流してみる程度には使えます。開発環境の都合でメッセージブローカーが使用できなくとも、CDCテストができないわけではありません。

Gradleの設定

Spring Cloud Contractを組み込むためのGradleの設定をご紹介します。トレーニングアプリケーションでは、cdctest-awssqs-queue/cdctest-awssqs-producer/cdctest-awssqs-consumerの各プロジェクトにGradleの設定「build.gradle」を配置しています。親子関係にはしていません。Spring Cloud Contractの設定をそれぞれの「build.gradle」に記述します。

cdctest-awssqs-queueのGradle設定

まず、cdctest-awssqs-queueのbuild.gradleの詳細を説明します。

  • demo-cdctest/cdctest-awssqs-queue/build.gradle
buildscript {
    repositories {
        mavenCentral()
    }
    dependencies {
       classpath 'org.springframework.cloud:spring-cloud-contract-gradle-plugin:2.2.5.RELEASE'
    }
}

plugins {
    id 'java'
    id 'io.spring.dependency-management' version '1.0.10.RELEASE'
}

apply plugin: 'spring-cloud-contract'
apply plugin: 'maven'
apply plugin: 'eclipse'

group = 'jp.co.ogis_ri.rd.nautible.cdctest'
version = '0.1.0-SNAPSHOT'
sourceCompatibility = '1.8'

jar {
    manifest {
        attributes(
            'Implementation-Title': project.name,
            'Implementation-Version': project.version,
            'Created-By': "Gradle ${gradle.gradleVersion}",
            'Built-By': "${System.properties['user.name']}",
            'Build-Timestamp': new java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZ").format(new Date()),
            'Build-Jdk': "${System.properties['java.version']} (${System.properties['java.vendor']} ${System.properties['java.vm.version']})",
            'Build-OS': "${System.properties['os.name']} ${System.properties['os.arch']} ${System.properties['os.version']}"
        )
    }
}

tasks.withType(JavaCompile) {
    options.encoding = 'UTF-8'
}

sourceSets.test {
    java.srcDirs = ['src/test/java', 'src/test/gen-java']
    resources.srcDirs = ['src/test/resources', 'src/test/gen-resources']
}

repositories {
    mavenCentral()
    mavenLocal()
}

dependencies {

    testImplementation('org.springframework.boot:spring-boot-starter-test:2.3.7.RELEASE') {
        exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
    }
    testImplementation 'org.springframework.cloud:spring-cloud-starter-contract-verifier:2.2.5.RELEASE'

}

dependencyManagement {
    imports {
        mavenBom 'org.springframework.cloud:spring-cloud-dependencies:Hoxton.SR9'
    }
}

contracts {
    testFramework = 'JUNIT5'
    testMode = 'EXPLICIT'
    generatedTestSourcesDir = project.file('src/test/gen-java')
    generatedTestResourcesDir = project.file('src/test/gen-resources')
    basePackageForTests = 'jp.co.ogis_ri.rd.nautible.cdctest.awssqs.contracttest'
    packageWithBaseClasses = 'jp.co.ogis_ri.rd.nautible.cdctest.awssqs.contracttest'
}

test {
    useJUnitPlatform()
    systemProperty 'file.encoding', 'utf-8'
}

ここで読み解くべきポイントは以下の通りです。

  • buildscriptクロージャでspring-cloud-contract-gradle-pluginへの依存関係を設定し、apply plugin: ‘spring-cloud-contract'で有効化する
  • dependenciesクロージャでspring-cloud-starter-contract-verifierを設定する。スコープはテストのみ
  • dependencyManagementクロージャでspring-cloud-dependenciesの依存関係をインポート

cdctest-awssqs-queueはスタブjarのためのプロジェクトです。REST APIのケースと異なり、コンシューマスタブとしては使用しません。よって、contractsクロージャは本来は不要なのですが、AwsSqsMessageVerifierMockをビルド・実行するために便宜上含めています。

cdctest-awssqs-producerのGradle設定

続いて、cdctest-awssqs-producerのbuild.gradleの設定です。詳しくは後述しますが、この設定にはhack(やや乱雑なニュアンスを含みつつ、うまくやる・小ワザを効かす)を含んでいます。みなさんのプロジェクトに適用する際は注意してください。

  • demo-cdctest/cdctest-awssqs-producer/build.gradle
buildscript {
    repositories {
        mavenCentral()
    }
    dependencies {
       classpath 'org.springframework.cloud:spring-cloud-contract-gradle-plugin:2.2.5.RELEASE'
    }
}

plugins {
    id 'java'
    id 'org.springframework.boot' version '2.3.7.RELEASE'
    id 'io.spring.dependency-management' version '1.0.10.RELEASE'
}

apply plugin: 'eclipse'
apply plugin: 'spring-cloud-contract'

group = 'jp.co.ogis_ri.rd.nautible.cdctest'
version = '0.1.0-SNAPSHOT'
sourceCompatibility = '1.8'

jar {
    manifest {
        attributes(
            'Implementation-Title': project.name,
            'Implementation-Version': project.version,
            'Created-By': "Gradle ${gradle.gradleVersion}",
            'Built-By': "${System.properties['user.name']}",
            'Build-Timestamp': new java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZ").format(new Date()),
            'Build-Jdk': "${System.properties['java.version']} (${System.properties['java.vendor']} ${System.properties['java.vm.version']})",
            'Build-OS': "${System.properties['os.name']} ${System.properties['os.arch']} ${System.properties['os.version']}"
        )
    }
}

tasks.withType(JavaCompile) {
    options.encoding = 'UTF-8'
}

sourceSets.test {
    java.srcDirs = ['src/test/java', 'src/test/gen-java']
    resources.srcDirs = ['src/test/resources', 'src/test/gen-resources']
}

repositories {
    mavenCentral()
    mavenLocal()
}

ext {

    localMavenRepo = 'file://' + new File(System.getProperty('user.home'), '.m2/repository').absolutePath
    queueGroupId = 'jp.co.ogis_ri.rd.nautible.cdctest'
    queueArtifactId = 'cdctest-awssqs-queue'
    queueVersion = '0.1.0-SNAPSHOT'

}

dependencies {

    compileOnly 'org.projectlombok:lombok:1.18.16'
    annotationProcessor 'org.projectlombok:lombok:1.18.16'

    implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.5.RELEASE'
    implementation 'org.springframework.cloud:spring-cloud-starter-aws-messaging:2.2.5.RELEASE'

    implementation 'org.springframework.boot:spring-boot-starter-web:2.3.7.RELEASE'
    implementation 'org.springframework.boot:spring-boot-starter-validation:2.3.7.RELEASE'

    testCompileOnly 'org.projectlombok:lombok:1.18.16'
    testAnnotationProcessor 'org.projectlombok:lombok:1.18.16'

    testImplementation('org.springframework.boot:spring-boot-starter-test:2.3.7.RELEASE') {
        exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
    }

    testImplementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-csv:2.11.3'
    testImplementation 'org.springframework.cloud:spring-cloud-starter-contract-verifier:2.2.5.RELEASE'

}

dependencyManagement {
    imports {
        mavenBom 'org.springframework.cloud:spring-cloud-dependencies:Hoxton.SR9'
    }
}

task cleanGenSrc {
    delete 'src/test/gen-java/*'
    delete 'src/test/gen-resources/*'
}

task setupContracts {

    def queueContractsJar = localMavenRepo + '/' + queueGroupId.replace('.', '/') + '/' + queueArtifactId + '/' + queueVersion + '/' + queueArtifactId + '-' + queueVersion + '-stubs.jar'

    copy {
        from zipTree(queueContractsJar)
        into "${projectDir}/src/test/gen-resources/."
        include 'META-INF/**/contracts/**'
        eachFile {
            path = path.substring(path.indexOf('/contracts/'))
        }
        includeEmptyDirs = false
    }

}

contracts {

    testFramework = 'JUNIT5'
    testMode = 'EXPLICIT'
    contractsDslDir = file("${projectDir}/src/test/gen-resources/contracts")
    generatedTestSourcesDir = project.file('src/test/gen-java')
    generatedTestResourcesDir = project.file('src/test/gen-resources')
    basePackageForTests = 'jp.co.ogis_ri.rd.nautible.cdctest.awssqs.producer'
    packageWithBaseClasses = 'jp.co.ogis_ri.rd.nautible.cdctest.awssqs.producer'

}

test {
    useJUnitPlatform()
    systemProperty 'file.encoding', 'utf-8'
}

setupContracts.dependsOn(cleanGenSrc)
generateContractTests.dependsOn(setupContracts)
test.dependsOn(generateContractTests)

ここで読み解くべきポイントは以下の通りです。

  • buildscriptクロージャでspring-cloud-contract-gradle-pluginへの依存関係を設定し、apply plugin: 'spring-cloud-contract'で有効化する
  • dependenciesクロージャでspring-cloud-starter-contract-verifierを設定する。スコープはテストのみ
  • dependencyManagementクロージャでspring-cloud-dependenciesの依存関係をインポート
  • contractsクロージャで自動生成するコンシューマスタブに関する設定を記述する
    • ここでコンシューマスタブの自動生成出力先を設定した場合、sourceSets.testクロージャで出力先パスをgradleにも設定しておく
  • extクロージャでローカルMavenリポジトリにあるQueueのjarを特定する
  • setupContractsタスクでローカルMavenリポジトリからQueueのスタブjarからContractを取り出し、プロデューサプロジェクト内にコピーする
  • コンシューマスタブを生成するSpring Cloud ContractのgenerateContractTestsタスクの実行前にsetupContractsタスクが実行されるようdependsOnを設定する

前回の再掲を含みますが、主要なcontractsクロージャで設定できる内容は以下の通りです。詳細な設定項目・内容は公式ドキュメントを参照してください。

設定項目 項目概要
testFramework
  • コンシューマスタブのテストフレームワークを指定する
  • 設定可能な値は以下
    • JUNIT5:JUnit5を使用します
    • JUNIT:JUnit4を使用します
    • ・・・
testMode
  • コンシューマスタブがプロデューサを呼び出す方式を指定します
  • 非同期メッセージングの場合、'EXPLICIT'を指定する
generatedTestSourcesDir
  • 自動生成されるコンシューマスタブ(テストコード)の出力先パス
basePackageForTests
  • 自動生成されるコンシューマスタブ(テストリソース)のパッケージ
packageWithBaseClasses
  • 自動生成されるコンシューマスタブ(テストリソース)の継承元クラスのパッケージ


generatedTestSourcesDir値について、デフォルトではGradleのビルドディレクトリに出力されます。Eclipseから参照・実行しやすくするため、トレーニングアプリケーションではsrc/test/gen-javaに変更しています。

読み解きポイント5,6,7つ目の箇条について、不可解な設定に見えるかもしれません。なぜこのようなhackをしているか説明します。

REST APIの場合と異なり、非同期メッセージングのコンシューマスタブは単純ではありません。OrderQueueBaseの実装で説明した通り、テストデータのロードやプロデューサのキックなど、プロデューサのつくりに強く依存します。単純に実装すると、テストデータやSpring Frameworkの設定などプロデューサとコンシューマスタブを動かすための資源をcdctest-awssqs-queueにあれこれ含めなければなりません。

しかし、前々回説明した通り、Contractはコンシューマ主導で作成するもの。プロデューサしか使用しない資源がcdctest-awssqs-queueに含まれると、コンシューマ側が大変です。責任分界点が不明瞭になります。おいそれと更新できなくなりますし、最悪、プロデューサ側の設定がコンシューマ側の設定に干渉することもあり得ます。

そこで、cdctest-awssqs-queueはContractと必要最低限の共有資源のみの管理とし、プロデューサしか使用しない資源はcdctest-awssqs-producerに配置します。OrderQueueBaseやテストデータはcdctest-awssqs-producerに配置しておき、コンシューマスタブ自動生成の都度Contractをスタブjarからコピーする設定としました。こうすることで、プロデューサとコンシューマのコミュニケーション(仕様変更に伴うPull Requestなど)がシンプル、且つ、明快になります。Spring Cloud Contractの公式ドキュメントでも類似の手法が紹介されています

cdctest-awssqs-consumerのGradle設定

さいごに、cdctest-awssqs-consumerのbuild.gradleの設定です。

  • demo-cdctest/cdctest-awssqs-consumer/build.gradle
plugins {
    id 'org.springframework.boot' version '2.3.7.RELEASE'
    id 'io.spring.dependency-management' version '1.0.10.RELEASE'
    id 'java'
}

group = 'jp.co.ogis_ri.rd.nautible.cdctest'
version = '0.1.0-SNAPSHOT'
sourceCompatibility = '1.8'

jar {
    manifest {
        attributes(
            'Implementation-Title': project.name,
            'Implementation-Version': project.version,
            'Created-By': "Gradle ${gradle.gradleVersion}",
            'Built-By': "${System.properties['user.name']}",
            'Build-Timestamp': new java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZ").format(new Date()),
            'Build-Jdk': "${System.properties['java.version']} (${System.properties['java.vendor']} ${System.properties['java.vm.version']})",
            'Build-OS': "${System.properties['os.name']} ${System.properties['os.arch']} ${System.properties['os.version']}"
        )
    }
}

tasks.withType(JavaCompile) {
    options.encoding = 'UTF-8'
}

repositories {
    mavenCentral()
    mavenLocal()
}

dependencies {

    compileOnly 'org.projectlombok:lombok:1.18.16'
    annotationProcessor 'org.projectlombok:lombok:1.18.16'

    implementation 'org.springframework.boot:spring-boot-starter:2.3.7.RELEASE'

    implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.5.RELEASE'
    implementation 'org.springframework.cloud:spring-cloud-starter-aws-messaging:2.2.5.RELEASE'

    implementation 'org.apache.poi:poi:4.1.2'
    implementation 'org.apache.poi:poi-ooxml:4.1.2'

    testCompileOnly 'org.projectlombok:lombok:1.18.16'
    testAnnotationProcessor 'org.projectlombok:lombok:1.18.16'

    testImplementation('org.springframework.boot:spring-boot-starter-test:2.3.7.RELEASE') {
        exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
    }

    testImplementation 'org.springframework.cloud:spring-cloud-starter-contract-stub-runner:2.2.5.RELEASE'

    testImplementation('jp.co.ogis_ri.rd.nautible.cdctest:cdctest-awssqs-queue:0.1.0-SNAPSHOT:stubs') {
        transitive = false
    }

}

dependencyManagement {
    imports {
        mavenBom 'org.springframework.cloud:spring-cloud-dependencies:Hoxton.SR9'
    }
}

test {
    useJUnitPlatform()
    systemProperty 'file.encoding', 'utf-8'
}

ここで読み解くべきポイントは以下の通りです。

  • dependenciesクロージャで以下の依存関係を設定する
    • プロデューサスタブを実行するspring-cloud-starter-contract-stub-runnerをテストスコープで設定
    • プロデューサスタブとして実行するContractを含むjp.co.ogis_ri.rd.nautible.cdctest:cdctest-awssqs-queueをテストスコープ/スタブパッケージングで設定
  • dependencyManagementクロージャでspring-cloud-dependenciesの依存関係をインポート

こちらはREST APIの場合とあまり変わりません。

まとめ

「マイクロサービスアーキテクチャに効く!テスト技法」の第3回目として、Spring Cloud Contractを用いた非同期メッセージングのCDCテストを実施しました。本記事ではAWS SQSを使用しましたが、Spring Cloud ContractのMessageVerifierを使用すれば多様な通信プロトコルのインターフェースをCDCテストすることができます。ぜひ活用してみてください。CDCテストで皆さんの現場がよりクリエイティヴになれば幸いです。

1: デフォルト値の「cdctest-awssqs-s3」でバケットを作成できた場合、トレーニングアプリケーションの利用終了後にバケットを削除しておいていただけると助かります:-p

2: 本文はCloudFormationのテンプレート自体を修正する形で記載していますが、もちろんStack作成時にバケット名を指定しても問題ありません

3: ここで例示しているcurlコマンドはWindowsにプリインストールされた curl 7.55.1 (Windows) libcurl/7.55.1 WinSSL で動作確認しています。LinuxやMacではエスケープ文字の調整が発生するかもしれません

4: トレーニングアプリケーションに添付しているCloudFormationテンプレートからバケットを生成した場合、パブリックアクセスは全てブロックする設定になっています。手っ取り早く確認するのであれば、マネジメントコンソールからオブジェクトを参照するとよいでしょう

5: プロトコルによっては、出来合いのMessageVerifierの実装クラスが提供されています。公式ドキュメントMessageVerifierのJavadoc(All Known Implementing Classes)を参照してください

6: Spring Frameworkに詳しい方向けのトリビアルな補足です。@AutoConfigureMessageVerifierを使用すると、テスト実行時にSpring Cloud Contract内部でMessageVerifier.classをキーにDIコンテナからMessageVerifier実装クラスのインスタンスを取得してくれます。ですが、この取得ロジックが曲者。MessageVerifier実装クラスのインスタンス取得時に例外が投出されると、ログ出力もなく無条件にNoOpStubMessagesのインスタンスが返されます。クラス名の通り、NoOpStubMessagesは「何もしない」MessageVerifierのNullObject実装。テストコードは動作するが、Queueへの入出力がウンともスンとも言わずにfailし、何のエラーメッセージも出ないという事態が発生します。複数のMessageVerifier実装クラスのインスタンスがDIコンテナに登録されている場合など、けっこう簡単に発生するトラブルです。実際に動かした時、どのMessageVerifier実装クラスで動作しているかには注意してください


変更履歴:

  • 本文中に記載したGitHubのURLを変更しました(2022.4.21)