原文はこちら。
The original article was written by Dan Siwiec (Solution Principal, Slalom)
https://medium.com/dan-on-coding/testing-event-driven-systems-63c6b0c57517
この記事では従来のP2P(ポイントツーポイント)アーキテクチャと比較した場合のイベント駆動型システムのテストの違いを見ていき、これらのシステムに堅牢なテスト戦略を実装するための代替的なアプローチを提案します。
Event-Driven architecture refresher
まず、従来のP2P型の同期システムと比較して、イベントドリブンアーキテクチャがどのようなものかを簡単におさらいしましょう。まず、従来のデザインから始めましょう。以下は、支払い、出荷、在庫管理、メール通知を処理する注文処理システムの例です。

このアーキテクチャでは、注文サービスはオーケストレータとして機能し、通常は直接のAPIコールを使って他のすべてのサービスのアクションを調整します。このサービスはシステムの頭脳であり、注文が出荷されたときにメールで顧客に通知を送信するなど、注文のライフサイクルの周りのビジネスルールを強制する責任があります。
では、イベント駆動型システムと比較してみましょう。

まず最初に気づくのは、オーケストレーションサービスがないことです。このアーキテクチャでは、各サービスは、支払いが正常に処理されたことなど、自分の注目分野に関連する事実やイベントを発表する責任がありますが、これらの事実によって駆動されるルールを強制することはありません。このパターンはしばしばコレオグラフィ(choreography、振り付け)と呼ばれ、各サービスが発生したイベントにどのように対応するかを知っている場合には、バレエダンサーがお互いを観察するように、それぞれが自分のタイミングに責任を持っています。このパターンは、イベントコラボレーションとも呼ばれています。
Event Collaboration
https://martinfowler.com/eaaDev/EventCollaboration.html
振り付けられたアプローチ(choreographed approach)は、将来的に開発や拡張が容易な、疎結合のアーキテクチャを生成する傾向があります。このトピックは多くの場所で書かれているので、ここでは取り上げません。
Microservices Choreography vs Orchestration: The Benefits of Choreography
https://solace.com/blog/microservices-choreography-vs-orchestration/
Microservice Orchestration Vs Choreography
https://www.softobiz.com/microservice-orchestration-vs-choreography/
Microservices — When to React Vs. Orchestrate
https://medium.com/capital-one-tech/microservices-when-to-react-vs-orchestrate-c6b18308a14c
Trade-offs of Event-Driven Systems
ご覧いただいたように、イベント駆動型システムは、従来のモノリシックのシステムや従来のP2P型マイクロサービスとは全く異なるものです。データフローは非同期で、コンポーネントは分離され、単一の責任を持っています。これがモダンなソフトウェアアーキテクチャにおけるすべての偉大な特徴です。これにより、(パフォーマンスと開発チームのスケールの両方の意味での)スケーラビリティ、変更の分離、フォールトトレランスなどが可能になります。しかし、これらの利点は無料で得られるわけではありません。Martin Fowlerが説明しているように、分散システムでは、より高い運用の成熟度と、チーム内でのDevOps文化の採用が必要です。これは、モノリシックシステムと比較してコンポーネントが増殖していることに起因しています。ビルドやバージョニング、デプロイ、監視などが必要な対象全てが増えているからです。コンポーネントの数が増えれば増えるほど、これらのタスクを手動で実行する労力は、これまで以上に法外なものになります。
MicroservicePrerequisites
https://martinfowler.com/bliki/MicroservicePrerequisites.html
通常のマイクロサービスアーキテクチャで組織が実施する必要のある運用上の要件に加えて、特にイベント駆動型システムは、余計にコストがかかります。統合レベルでは、このアーキテクチャ・パターンは、ほとんどのマイクロサービス・システムで採用されているP2P通信とは大きく異なります。この重要な違いは、この記事の焦点であるテストを含む多くのソフトウェアパターンの変更を必要とします。
Testing
通常、システムのために書くテストには複数のレベルがあります。最も一般的なケースでは、ユニットテスト、サービステスト、E2E(エンドツーエンド)テストを書くことになるでしょう。これらのケースのそれぞれにおいて、テスト対象システム (System Under Test/SUT、実際にテストされているもの) は、アプリケーションの異なる部分を構成しています。
Unit Tests
ユニットテストは、あなたが書く最も基本的なテストです。この場合のSUTは通常、個別のクラスです。例えば、Payment Service(支払サービス)が顧客の所在地に基づいて消費税を適用する必要があるとします。このクラスは、引数Order
を受け入れ、合計に適用する必要がある税金を表すdouble
型の値を返すcalculate()
メソッドを持つTaxCalculator
クラスがあるものとします。ユニット・テストでは、このクラスと直接対話し、さまざまなOrder
の値を渡して、税金が適切に計算されているかどうかを検証します。ユニット・レベルでは、P2P型システムとイベント駆動型システムの違いはほとんどありません。そのため、ここでは深くは触れません。

アーキテクチャスタイルに関係なく、Javaでは以下のような感じです。
public class TaxCalculatorTest { | |
@Test | |
public void shouldCalculateCaliforniaTaxOnOrderWithSingleItem() { | |
var singleItemOrder = Order.withItems(Item.withCost(50)).withCustomerLocation('CA'); | |
var tax = TaxCalculator.calculate(singleItemOrder); | |
assertThat(tax).isEqualTo(3.625); | |
} | |
@Test | |
public void shouldAddZeroTaxOnEmptyOrder() { | |
var emptyOrder = Order.withItems().withCustomerLocation('CA'); | |
var tax = TaxCalculator.calculate(emptyOrder); | |
assertThat(tax).isEqualTo(0); | |
} | |
} |
Service Tests
サービステストは、その名が示すように、サービス全体をSUTとして扱います。マイクロサービスアーキテクチャでは、自動化されたテストの大部分がここで行われるようになっています。これらのテストは、サービスのコントラクト(Contract、契約)、つまり、特定の入力が与えられると、サービスが特定の出力を生成することを検証します。これらのテストは、デプロイに先立ち(デプロイされたサービスではなく)インメモリで実行されます。そのため、ほとんどは実行コストが低いものです。ここで、P2P型システムとイベント駆動型システムでテスト実装に大きな違いが見られるようになります。これらの違いの理由は、サービス間のコラボレーションスタイルが根本的に異なっていること、つまり、これらのテストがカバーする必要がある契約が異なっている点にあります。
まず、P2P型システムから始めましょう。Payment Systemのコントラクトは、その唯一の消費者であるOrder ServiceとのHTTPリクエストとレスポンスを記述しています。

Javaでは以下のような感じになるでしょう。
@SpringBootTest | |
public class PaymentServiceTest { | |
@Autowired | |
private RestTemplate restTemplate; | |
@Test | |
public void shouldProcessPaymentForValidOrder() { | |
var validOrder = Order | |
.withCustomerId(123) | |
.withItems(Item.withId("af12da")); | |
var response = restTemplate.postForEntity("/payment", validOrder); | |
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); | |
assertThat(response.getBody().getPaymentTransactionId()).isNotNull(); | |
} | |
@Test | |
public void shouldRejectPaymentForOrderWithMissingCustomer() { | |
var validOrder = Order | |
.withItems(Item.withId("af12da")); | |
var response = restTemplate.postForEntity("/payment", validOrder); | |
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); | |
assertThat(response.getBody().getPaymentTransactionId()).isNull(); | |
} | |
} |
イベント駆動型システムでは、Payment Serviceのコントラクトは異なります。HTTPメッセージではなく、イベントに基づいて動作します。この場合、サービスは支払いを処理し、Orders
トピックに新しい注文があった場合に、Payment
トピックにPayment Successfulイベントを発行することを約束します。

重要なことは、このアーキテクチャの分離性ゆえに、サービスは「入力」メッセージの発信元の知識を持つ必要がなく、結果として、「出力」メッセージの消費者について知る必要がないということです。今回はメッセージブローカが図にあるように、このようなテストのオーケストレーションは、P2P型システムのサービス・テストよりも少し複雑です。KafkaやSpring Bootのような成熟した技術スタックの場合、特にこのユースケースのために埋め込まれたメッセージブローカにアクセスできますが、他のフレームワークやメッセージングプラットフォームでは、ブローカの相互作用を抽象化して、SUTのブローカーとの相互作用を検証することに頼らざるを得ません。これは、サービスレベルで実施したいテストというよりは、「ホワイトボックス」なテストになるので、あまり望ましくありません。
Spring Bootでは、テストは以下のようになるでしょう。
@SpringBootTest | |
@EmbeddedKafka(topics = {"orders", "payments"}) | |
public class PaymentServiceTest { | |
@Autowired | |
private Producer<Order> orderProducer; | |
@Autowired | |
private Consumer<Payment> paymentConsumer; | |
@Test | |
public void shouldProcessPaymentForValidOrder() { | |
var validOrder = Order | |
.withCustomerId(123) | |
.withItems(Item.withId("af12da")); | |
orderProducer.send(validOrder); | |
var payment = paymentConsumer.poll(); | |
assertThat(payment.getPaymentStatus()).isEqualTo("OK"); | |
assertThat(payment.getPaymentTransactionId()).isNotNull(); | |
} | |
@Test | |
public void shouldRejectPaymentForOrderWithMissingCustomer() { | |
var validOrder = Order | |
.withItems(Item.withId("af12da")); | |
orderProducer.send(validOrder); | |
var payment = paymentConsumer.poll(); | |
assertThat(payment.getPaymentStatus()).isEqualTo("ORDER_INVALID"); | |
assertThat(payment.getPaymentTransactionId()).isNull(); | |
} | |
} |
上記の2個の実装を比較すると、コミュニケーションスタイルの違いがテストコードに与える影響が明確になるはずです。これらのサービステストは、メッセージトピックのみで動作します。要するに、イベント駆動型システムのためのサービステストは、入力用イベントと出力用イベントで動作します。分離アーキテクチャのもう一つの副産物として、サービステストコードにモックやスタブがないことがよくありますが、これは、サービスが他のサービスに対して本来持っている「認識不足」に起因します。
イベント駆動型システムのサービステストは入力用イベントと出力用イベントで動作し、他サービスのことを意識しません。
End-to-end Tests
ここで議論するテストの最終レベルは、E2E(エンドツーエンド)テストです。これは、サービステストでカバーされているコントラクトが機能することを保証する究極の動作検証です。言い換えれば、これらのテストは個々のサービス間の点をつなぎ、サービスAが必要としているものが、実際にサービスBが提供しているものであることを確認します。これらのテストは通常、デプロイ後にテスト環境に対して実行されます。これらのテストは実行に時間がかかり、障害が発生した場合の集中力が低下するため、最後の防御の砦として扱われるべきです。
これらのテストは、実際のユーザの立場に似た立場から実施されます。これらのテストでは、個々のサービスやメッセージングプラットフォームの仕様は抽象化されており、このレベルでは Kafka と直接やりとりすることはほとんどありません。以下は、SUTがどのようなものかを示しています。

エンドツーエンドのテストレベルでは、システムのアーキテクチャがテストコードに影響を与えないということでしょうか?まあ、それは場合によるでしょう。多くの場合、イベント駆動型システムは、同期型のP2P型システムとは全く異なるセマンティクスを持っています。注文処理システムを例に考えてみましょう。P2P型システムの場合、基礎となるすべての操作をオーケストレーションするOrder Serviceは、元のHTTPリクエストを開いたままにしておき、注文がすべてのコンポーネントで処理されたときにのみレスポンスを返す可能性があります。Javaテストは次のようになります。
public class OrderProcessingTest { | |
@Autowired | |
RestTemplate restTemplate; | |
@Test | |
public void shouldProcessValidOrder() { | |
var validOrder = Order.withItems(Item.id("2fa2ac")).withCustomerId("123"); | |
// submit Order | |
var response = restTemplate.postForEntity("/processOrder", validOrder); | |
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); | |
assertThat(response.getBody().getStatus()).isEqualTo("PROCESSED"); | |
// validate Payment | |
var paymentTransactionId = response.getBody().getPaymentTranscationId(); | |
var paymentStatus = restTemplate.getForEntity("http://externalPaymentGateway/paymentStatus/" + paymentTransactionId); | |
assertThat(paymentStatus).isEqualTo("PAYMENT_PROCESSED"); | |
// validate Shipment | |
var shipmentId = response.getBody().getShipmentId(); | |
var shipmentStatus = restTemplate.getForEntity("http://externalShipmentService/shipmentStatus/" + shipmentId); | |
assertThat(shipmentStatus).isEqualTo("QUEUED_FOR_SHIPMENT"); | |
} | |
} |
ご覧のように、処理される注文のすべての側面が元のリクエストに依存しており、それが完了するとすぐに処理は終了します。これは、処理が非同期的に行われるイベント駆動型のシステムでは全く異なるように見えるかもしれません。
public class OrderProcessingTest { | |
@Autowired | |
RestTemplate restTemplate; | |
@Test | |
public void shouldProcessValidOrder() { | |
var validOrder = Order.withItems(Item.id("2fa2ac")).withCustomerId("123"); | |
// submit Order | |
var response = restTemplate.postForEntity("/processOrder", validOrder); | |
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); | |
assertThat(response.getBody().getStatus()).isEqualTo("QUEUED"); | |
// await until Order fully processed | |
var orderId = response.getBody().getId(); | |
await().until(() -> restTemplate.getForEntity("orderStatus/" + orderId).getBody().getStatus(), equalTo("PROCESSED")); | |
var processedOrder = restTemplate.getForEntity("orderStatus/" + orderId).getBody(); | |
// validate Payment | |
var paymentTransactionId = processedOrder.getPaymentTranscationId(); | |
var paymentStatus = restTemplate.getForEntity("http://externalPaymentGateway/paymentStatus/" + paymentTransactionId); | |
assertThat(paymentStatus).isEqualTo("PAYMENT_PROCESSED"); | |
// validate Shipment | |
var shipmentId = processedOrder.getShipmentId(); | |
var shipmentStatus = restTemplate.getForEntity("http://externalShipmentService/shipmentStatus/" + shipmentId); | |
assertThat(shipmentStatus).isEqualTo("QUEUED_FOR_SHIPMENT"); | |
} | |
} |
システムの非同期性が、OrderのQUEUEDという中間ステータスやOrderのアップデートのチェックの必要性に現れていることに気づくのではないでしょうか。外部のペイメントゲートウェイのトランザクションのように、特に下流のプロセスで完了までに長時間を要する場合、システムの応答性を向上するという点で、これは多くの場合システムの望ましい特性です。
Summary
まとめると、イベント駆動型の非同期システムのテストは、これまでのシステムのテストに比べて余計にコストがかかります。しかし、このコストは、自動テストやシステム全体の設計のための新しいパターンの開発に関連した一回限りのコストであるため、この種のアーキテクチャの選択をやめてはいけません。長い目で見ると、これらのシステムは、高度に分離されたコンポーネントのおかげで、モノリシックやP2Pのマイクロサービス・アーキテクチャよりも長持ちする傾向があります。しかしながら、ツールとそれをサポートする技術はまだ進化しており、従来のアプローチほど成熟していませんが、パターンは現れ始めています。
フィードバックを寄せてくれたAmber HouleとJesse Diazに感謝します。