前回は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 |
|
S3 | demo-cdctest/cdctest-awssqs-consumer/src/main/resources/cfn/cdctest-awssqs-s3.yaml |
|
- 独自に環境構築した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 |
|
|
cdctest-awssqs-producer |
|
|
cdctest-awssqs-consumer |
|
|
これらプロジェクトは、以下の仕様・配置で構成されます。
各プロジェクトをビルドして、動作確認を行います。下記手順を実施してください。
cdctest-awssqs-queueのビルドと実行
- demo-cdctest/cdctest-awssqs-queueディレクトリに移動
以下コマンドを実行してビルド
gradlew build -x test
以下コマンドを実行してMavenローカルリポジトリへインストール
gradlew install
cdctest-awssqs-producerのビルドと実行
- demo-cdctest/cdctest-awssqs-producerディレクトリに移動
以下コマンドを実行してビルド
gradlew build -x test
以下コマンドを実行してプロデューサ起動
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]
cdctest-awssqs-consumerのビルドと実行
- demo-cdctest/cdctest-awssqs-consumerディレクトリに移動
以下コマンドを実行してビルド
gradlew build -x test
以下コマンドを実行してコンシューマ起動
java -jar .\build\libs\cdctest-awssqs-consumer-0.1.0-SNAPSHOT.jar
起動時、EC2メタデータのエンドポイントに接続できずSdkClientExceptionが発生した旨の警告メッセージが出力されます。プロデューサ同様に無視して問題ありません。
動作確認
以下コマンド*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}"
標準出力に下記が出力されてプロデューサが注文を受け付けたことを確認
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"}
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の数が増えると、テストの意図がわかりにくくなる | |
name | 文字列 |
|
日本語使用可能 | |
ignored | 真偽 |
|
- | |
label | 文字列 |
|
プロデューサスタブでContractを指定してメッセージ送信する際に使用する | |
input | triggeredBy | 文字列 |
|
コンシューマスタブはここで指定したメソッド・引数を用いてプロデューサをキックする |
outputMessage | sentTo | 文字列 |
|
- |
headers | オブジェクト |
|
- | |
body | オブジェクト |
|
YAMLのオブジェクトで定義した内容が、コンシューマスタブ・プロデューサスタブではJSONに展開される | |
matchers | オブジェクト |
|
メッセージヘッダ、メッセージボディ等でオブジェクト構造(値範囲定義の仕方)が異なる。詳細は公式ドキュメント参照 |
そして、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 |
|
ids |
|
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 |
|
testMode |
|
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)