📖

re:Invent 2023: AWSによる.NETアプリケーションのテスト自動化戦略

2023/11/28に公開

はじめに

海外の様々な講演を日本語記事に書き起こすことで、隠れた良質な情報をもっと身近なものに。そんなコンセプトで進める本企画で今回取り上げるプレゼンテーションはこちら!

📖 AWS re:Invent 2023 - Test automation for .NET applications running on AWS (XNT308)

この動画では、AWSのSenior Microsoft Specialist Solutions ArchitectであるDror Helperが、テスト自動化の重要性と実践的な方法を解説します。マイクロサービスやAWS Lambdaを使用したアーキテクチャにおける、ユニットテスト、統合テスト、エンドツーエンドテストの違いや実装方法を具体的に紹介。非同期処理のテストや、クラウド環境特有の課題への対処法など、現場で即活用できる知見が満載です。TestHostやMockServerなどのツールの活用法も学べる、実践的なセッションです。
https://www.youtube.com/watch?v=-apwtOMItZ4
※ 動画から自動生成した記事になります。誤字脱字や誤った内容が記載される可能性がありますので、正確な情報は動画本編をご覧ください。

本編

テスト自動化の重要性:Dror Helperの経験から

Thumbnail 0

Thumbnail 10

今日は、テスト自動化について話します。次のようなシナリオを想像してみてください:何日も寝ずに働き続け、 ようやくアプリケーションをクラウドにデプロイしたところ、何か悪いことが起こります。サービスの1つが起動するとすぐにクラッシュしてしまい、その意味するところは分かっています。長時間のデバッグセッションで何が悪かったのかを突き止め、デバッグし、再デプロイし、また再度デバッグする必要があります。何百行ものコード、何千行ものログを読み、何が問題で、どう対処できるかを把握しなければなりません。

別のシナリオはどうでしょうか?新機能の開発に取り組んでいるとき、1ヶ月前に完成してテストした機能が動かなくなったという電話を受けたとします。なぜ動かなくなったのか調べようとした後、同僚の開発者が自分のコードで何かを変更し、誤ってあなたの機能を壊してしまったことに気づきます。

私はDror Helperと申します。AWSで働くSenior Microsoft Specialist Solutions Architectです。このような事態は私自身何度も経験しましたし、クライアントにも起こっているのを見てきました。これに対して何かできることがあるはずです。この講演では、これらの問題を解決する新しいAWSサービスをお見せするわけではありません。代わりに、自動化されたテストを使用してこれらの問題がプロジェクトで発生しないことを保証する戦略、方法をお見せします。

マイクロサービスとLambda関数のテスト課題

Thumbnail 120

Thumbnail 130

Thumbnail 140

Thumbnail 150

ASP.NET CoreのマイクロサービスベースのアーキテクチャとAWS Lambdaを使用して、自動テストを書く際の両者の主要な類似点と相違点をお見せします。 マイクロサービスアーキテクチャでのテストは簡単ではありません。通常、サービスがあり、そのサービスにはデータベースがあります。 しかし、それはマイクロサービスではなく、単なるサービスです。 おそらく、クライアントまたは最初のサービスから呼び出される別のサービスがあり、それにもデータベースがあります。これは比較的テストしやすいですが、非同期呼び出しもあります。 おそらくキューを使用して別のサービスにメッセージを送信し、そのサービスがバックグラウンドでそれらのメッセージを処理します。

Thumbnail 170

Thumbnail 180

Thumbnail 190

これらは自分のマシンでは動作しません。Kubernetesクラスター内、Dockerイメージ内、またはAWS上のElastic Container ServiceやElastic Kubernetes Serviceで動作します。 そして、多くの場合、Amazon S3のようなAWSサービスも使用してファイルを保存しています。 このようなアーキテクチャのほとんどは、外部サービスも呼び出します。おそらく別のチームや別の会社が作成したものです。 この種のアーキテクチャ(この時点で3つのサービスしかありません)をテストすることは、決して簡単ではありません。これらのコンポーネントの一部は自分のマシンでは動作しません。このシステムを正常にセットアップするのは難しく、何かが壊れた場合、どこで壊れたのか、何が問題なのかを見つけるのは困難です。

Thumbnail 220

Thumbnail 230

Thumbnail 240

Thumbnail 250

では、Lambda関数に話を移しましょう。AWS Lambdaのサーバーレスアーキテクチャはクラウドから始まります。通常、Lambda関数をトリガーするサービス、つまりあなたのコードを呼び出すものがあります。例えば、ファイルをアップロードするAmazon S3バケットがあるとしましょう。新しいファイルが到着すると、Lambda関数が起動し、そのファイルを処理して結果をデータベースに保存します。ここではAmazon DynamoDBとしましょう。他のユーザーはAmazon API Gatewayを通じてHTTPコールを行い、それらのファイルに関する情報を取得したり、何らかの操作を実行して結果を得たりすることができます。そしてこれらすべてがAWS内で実行されます。そこで疑問が生じます。テストはどこにあるのでしょうか?自分のマシンの外にあるものをテストするテストをどこで実行すればいいのでしょうか?

自動化テストの種類と目的

Thumbnail 290

ここで、なぜテストについて話すのかを説明しましょう。C#開発者の方はいらっしゃいますか?手を挙げてください。では、ユニットテストを書いている人は?なかなかの数ですね。私たちが自動化されたテストについて話すのは、テストを書くのがテスターの仕事だけではないからです。コードが正しく動作することを確認するのは開発チームの仕事です。コードが期待通りに動作することを保証するのは、あなたの仕事の一部なのです。それも今日だけでなく、明日も機能し続けることを。要求された機能を同じ品質で実装していることを確認したいのです。

それ以外にも、コードが正しく動作することを確認したい理由があります。開発者として本当に嫌だったのは、誰かに呼び出されて、1週間前や1ヶ月前に実装したものを修正するために引き戻され、前進が止まってしまうことでした。自動化されたテストを書くという考えを受け入れれば受け入れるほど、すでに完了したものを修正するために後戻りすることが少なくなり、より速く前進できるようになります。テストを使って前に進むのです。

Thumbnail 330

Thumbnail 340

さて、自動化されたテストには、達成しようとしていることに応じて、さまざまな種類があります。システム全体、ワークフロー全体、エンドツーエンドをテストするテストがあります。このようなテストは、自動化チームがある場合によく目にします。彼らはアプリケーション全体、すべてのマイクロサービス、システム全体をセットアップし、一方の端に何かを入れて、もう一方の端で何かが起こったかを確認します。しかし、相互作用ベースのテストもあります。システムの異なる部分が互いに正しく通信できることを確認したい、つまり、一方から送られたメッセージが他方で正しく受信されることを確認したいのです。

Thumbnail 370

Thumbnail 380

そして最後に、テストしたいロジックがあります。これらの各層は、異なるタイプのテストによってテストされます。ロジックをテストするユニットテスト、相互作用をテストする統合テスト、そしてシステム全体をテストするエンドツーエンドテストがあります。これらの名称は、誰と話すか、どの本を読むかによって変わる傾向がありますが、このセッションでは、これらの名称を使用します。

テスティングピラミッドと効果的なテスト戦略

Thumbnail 420

ここにいる方で、このテスティングピラミッドを見たことがある人はいますか? 数人いますね。テスティングピラミッドは、2004年、つまり約20年前にMike Cohnが提唱した概念です。その考え方は、ピラミッドの上に行けば行くほど、書くべきテストの数が少なくなるというものです。プロジェクトによって変わりますが、その理由は上に行くほどテストの範囲が大きくなるからです。 これは良いことのように聞こえますし、実際そうです。なぜなら、コードのテスト範囲が広がるほど、より実世界に近いシナリオを実装し、自動的にテストしていることになるからです。

Thumbnail 450

しかし、テストの範囲が大きくなればなるほど、テストできるシナリオの数、特にコーナーケースの数が少なくなります。あるサービスが誤動作してクラッシュしたというテストは、エンドツーエンドでは非常に難しいですが、ユニットテストでは非常に簡単です。だから、すべてのレベルのテストが必要なのです。実行時間は上に行くほど増加します。 これも驚くことではありません。ユニットテストは非常に小さなテストで1秒以内に実行されますが、エンドツーエンドテストは数分かかることがあります。それほど長くないように聞こえるかもしれませんが、10分のテストが6つあれば1時間かかってしまいます。

Thumbnail 480

私はわかりませんが、皆さんはコードを修正するたびに1時間待つ気はないでしょう。また、1日に7回か8回しかテストを実行できないということは、生産性を損なうことになります。時間の有効な使い方とは言えません。 そして最後に、メンテナンスのオーバーヘッドです。先ほど言ったように、テストを書くことは私たちの仕事ではありません。テストを書くことはあなたの仕事ではありません。コードが正しく動作することを確認する必要があります。だから、テストにかける時間をできるだけ少なくしたいのです。問題は、テストを書くことではありません。テストを書くのは簡単です。

問題は、コードに変更を加えたときに、テストも変更するか、あるいは引き続き機能させる必要があることです。基本的に、バグがある場合にのみテストが失敗するようにしたいのです。そして、ピラミッドの上に行けば行くほど、そのようなテストのメンテナンスは難しくなります。エンドツーエンドテストは、特定のシナリオの中のあらゆる箇所で失敗する可能性があります。ユニットテストは非常に具体的な理由でのみ失敗するだけでなく、非常に小さなコードの一部なので、削除や修正が簡単です。

Thumbnail 560

さらに、メンテナンスには何が間違っているかを見つけることも含まれます。テストが失敗したとき、何が間違っているのかすぐに知りたいものです。ユニットテストなら簡単です。エンドツーエンドテストでは、おそらくログを読む必要があります。エンドツーエンドテストが失敗した理由を理解するのに数時間かかることもあります。だから、これら3つのレベルすべてが必要なのです。良いテストには、次のような特性が必要です。読みやすく、メンテナンス可能であることです。なぜなら、テストを読むのは、 書いた今日ではなく、明日、1ヶ月後、1年後にテストが失敗したときだからです。

Thumbnail 580

テストを実装した後、時間が経って忘れてしまっても、そのテストが何をするのかを理解できるようにしたいものです。また、テストが失敗した場合、他の開発者もそのテストの内容を理解できるようにしたいですね。テストは決定論的で予測可能である必要があります。これは一言で言えば、信頼性があるということです。テストを信頼し、テストが失敗したときには、システムに何か問題があり、修正が必要だということがわかるようでなければなりません。時々失敗して、時々成功するようなテストがあると、失敗したときにどうしますか?ただ再実行するだけですよね。そうなると、そのテストは意味をなさなくなってしまいます。いくつかのテストが失敗したり成功したりを繰り返すと、テストスイート全体の有効性が失われてしまいます。

Thumbnail 630

同じテストを1,000回連続で実行しても、全く同じ結果が得られるようにしたいものです。テストをどのような順序で実行しても、同じ結果が得られるようにしたいですね。自分のマシン、同僚の開発者のマシン、ビルドサーバーでテストを実行しても、全く同じ結果が得られるようにしたいものです。

Thumbnail 660

最後に、テストはボタン一つで簡単に実行できるようにすべきです。テストに必要なものを初期化し、テストを実行し、その後クリーンアップするようにしましょう。テストシステムのセットアップ方法を説明するWordドキュメントは必要ありません。そういうものを見たことがありますが。会社の全員がテストを実行できるようにしたいものです。本質的には、問題が発生した場合に、誰もがそのテストを理解し、問題を修正する前や後にテストを実行できるように、その問題を全チームに委ねているのです。

ユニットテストの基本と構造

Thumbnail 670

Thumbnail 700

では、最初のタイプのテスト、unit testについて詳しく見ていきましょう。これは、人々が話題にする最も一般的で知られているタイプのテストです。Unit testはロジックのテストに焦点を当てています。これらのテストの目的は、日々の作業で書いたコードが実際に正しく機能することを確認することです。Unit testは、コードの小さな部分、クラスや関連する数個のクラスをテストする必要があり、外部の依存関係をすべて取り除きたいものです。Unit testからdatabaseを呼び出すようなことはしたくありません。

Thumbnail 710

これを達成するために、コードの一部を分離し、unit testing frameworkを使用します。その部分を依存関係とともに初期化し、mocking frameworkを使用して外部の依存関係をすべて偽装します。ロジックだけに焦点を当てたいのです。ここにunit testの例があります。多くの方が、これに似たようなものを書いたことがあるでしょう。Unit testは本質的に、クラス内にあるメソッドで、時にtest fixtureと呼ばれます。このメソッドを特別なものにしているのは、テストとしてマークする属性が付いているという事実だけです。

Thumbnail 790

この場合、特定のメソッドをテストとしてマークするために「Test」という単語を使用します。これにより、テストランナーがこれらのメソッドを見つけて実行できるようになります。Visual Studio内から、コマンドラインから、またはNUnitテストを実行できる任意のソフトウェアから実行できます。通常、何も返さず、publicでvoidです。テスト名があり、良いテスト名を書けば、テストが失敗したときにその理由を理解しやすくなります。

ユニットテストの構造は通常、Arrange(準備)、Act(実行)、Assert(検証)という3つの明確な部分に分かれており、Triple AまたはAAAとも呼ばれます。Arrange部分では、システムを初期化し、フェイクオブジェクトを作成します。例えば、データベースを呼び出すはずのフェイクリポジトリを作成するかもしれません。特定のメソッドが特定のパラメータで呼び出されたときにnullを返すようにこのフェイクオブジェクトを設定し、オブジェクトがデータベースに存在しないシナリオをエミュレートします。

Thumbnail 900

Act部分では、テストしようとしているシナリオを実行します。最後に、Assert部分で結果をテストします。この場合、オブジェクトがデータベースに存在しない場合、メソッドは404 Not Foundの結果を返すべきだと検証するかもしれません。この構造により、テストが読みやすく理解しやすくなります。

Lambda関数のユニットテスト:クラウドとの関係

Lambda関数に移ると、C# Lambdaを書く方法は複数あります。

Thumbnail 940

C# Lambdaを書くために、S3の新しいファイルによってトリガーされ、データをDynamoDBに保存するLambda関数を見てみましょう。S3から読み取るために必要な依存関係を初期化するデフォルトコンストラクタがあります。S3クライアントとDynamoDBクライアントがあり、そしてコードが存在する関数ハンドラがあります。

Thumbnail 950

これをテストするには、外部依存関係を排除する必要があります。実際のS3バケットやDynamoDBは必要ありません。S3の場合、ユニットテストフレームワークで置き換えることができます。テストはS3からトリガーすることなく、Lambda関数を実行します。DynamoDBについては、モッキングフレームワークを使用して偽のDynamoDBクライアントを作成します。このアプローチにより、実際のサービスを呼び出すことなくコードをテストできます。

Thumbnail 970

そのためには、依存性注入を可能にするコンストラクタを作成する必要があります。これにより、テスト内から偽のオブジェクトをコードにプッシュできるので、実際のS3やDynamoDBサービスを呼び出すことはありません。テストは次のようになります。先ほど見たテストと似ていますが、コードが異なるだけで、基本的な考え方は同じです。

Thumbnail 990

Thumbnail 1000

テストメソッドは、Lambda関数が非同期シグネチャを持つため、非同期タスクになっています。arrangeセクションでは、偽のS3クライアントと偽のDynamoDBクライアントを作成します。また、空のイベントと必要なLambdaコンテキストを作成し、Lambda関数を初期化します。この構造のおかげで、Lambda関数のユニットテストは非常に簡単です。単に'new'キーワードを使用してインスタンスを作成するだけです。

Thumbnail 1010

Thumbnail 1030

actセクションでは、関数ハンドラーを呼び出します。assertセクションでは、モッキングフレームワークを使用して、DynamoDBクライアントがDynamoDBにデータを保存するために呼び出されなかったことを確認します。本質的に、このテストは、何らかの理由で空のイベントを受け取った場合(問題、エラー、または意図的な場合)、データベースに何も保存すべきでないことを検証しています。それだけのことです。

では、ユニットテストはクラウドとどのような関係があるのでしょうか?答えは、まったく関係がありません。そしてそれは良いことなのです。AWSアカウントを持っていなくても、何かをプロビジョニングする必要もなく、自分のマシンでこれらのテストを実行できます。自分のマシンの快適な環境で、ロジックが機能することを確認できます。しかし、これは始まりに過ぎず、良い基盤となります。自動化テストを1種類だけ書くのであれば、おそらくユニットテストが最適でしょう。なぜなら、書くコードのほとんどがユニットテストでテストできるからです。ただし、それだけでは十分ではありません。

インテグレーションテスト:外部依存関係の扱い方

Thumbnail 1090

Thumbnail 1100

Thumbnail 1110

疑似的なASP.NET Coreアプリケーションやサービスを振り返ってみると、一方にクライアントがあり、もう一方の下部にはデータベースやAWSサービスなどがあります。そして、私たちが書くコードであるロジックがあります。これはコントローラーから下の部分で、先ほどユニットテストでテストしたものです。しかし、考慮すべき他の部分もあります。プレゼンテーション層があり、これはメッセージが処理されたり、HTTPリクエストが処理されてから、コントローラーのコードに到達するまでのすべてのプロセスです。また、データ層もあり、これは外部とのやり取りに関するすべてのロジックです。これら二つもテストする必要があります。

Thumbnail 1130

下の部分から始めると、リポジトリやクライアント、つまりHTTP clientやAWS SDKを使ってサービスを呼び出したり、ORマッピングを使ってデータベースを呼び出したりする部分が正しく動作することをテストする必要があります。特に複雑な操作の場合、DynamoDBに何かを保存する方法が不明確だったり、非常に複雑なクエリを作成した場合、それらが正しく動作することを確認したいものです。テストせずに放置したくはありません。

Thumbnail 1160

Thumbnail 1170

これらのコンポーネントをテストするために、Lambda関数の場合でも、引き続きユニットテストフレームワークを使用します。Lambda関数も、同じコードであれば同じ層を持っているでしょうし、そうでなくても、Lambda関数単体ではデータを先に送らない限り意味がありません。ユニットテストフレームワークを使用するのは、コードを実行できるからです。これもまたコードなのです。サービス全体をテストしているわけではなく、外部とのやり取りを行う部分だけをテストします。なぜなら、ロジックの部分はすでにユニットテストでテスト済みだからです。

Thumbnail 1200

今回は、外部の依存関係をフェイクにしません。なぜなら、それらがテストの焦点であり、これらのテストを書く理由だからです。実際の依存関係が必要です。これが、インテグレーションテストと呼ばれるものです。

Thumbnail 1210

インテグレーションテストは、ユニットテストとまったく同じように見えますが、ショッピングカートリポジトリをフェイクにしない点が異なります。実際のDynamoDBデータベースを呼び出す本物のリポジトリを作成します。2つのショッピングカートを作成し、クエリが正しい結果を返すことを確認します。これが可能なのは、ユニットテストフレームワークの別の機能を使用しているからです。関数を実行できるだけでなく、セットアップとティアダウンのための特別な関数も持っています。セットアップは各テストの前に実行され、ティアダウンは各テストの後に実行されます。

クラウドサービスのテスト:実際のサービスvsエミュレーター

Thumbnail 1250

外部依存関係を使用する際、私はこれらのセットアップとティアダウン関数を使いたいと強く思います。DynamoDBデータベースをどのように初期化するかは気にせず、テストのことだけを気にします。そのため、既存のデータベースへの接続を取得するコードをセットアップに置きました。また、テスト間でクリーンアップするコードも追加しました。なぜなら、一つのテストが次のテストに影響を与えるべきではなく、どのような順序でも実行できるようにすべきだからです。このようにすることで、実際のオブジェクトと実際の依存関係でテストすることができます。

Thumbnail 1300

しかし、外部依存関係には問題があります。 まず、複雑な初期化があります。データベースをセットアップし、情報を入力し、テスト間でクリーンアップする必要があります。この複雑な初期化は、常に自動化したりテスト内から実行したりすることが可能とは限りません。また、時間もかかり、数分に及ぶ可能性もあります。さらに、外部依存関係を使用する際には、キューのような非同期操作があります。また、非常にコストがかかり、統合テストは数秒から数分かかる可能性があります。テストの実行に1分かかるだけでも、6つのテストで1時間かかることになり、これは苦痛です。

Thumbnail 1360

最後に、外部依存関係を使用する際の本当の危険である共有状態の問題があります。会社全体で1つのデータベースを使用し、全員がテストを実行する場合、2人の開発者が同時に同じテストを実行すると、誰かが明らかな理由もなく失敗することになります。一方が、もう一方が見ることを期待していたアイテムを誤って削除してしまうかもしれません。あるいは、あなたがテストを実行しようとしている間にビルドサーバーがビルドを実行し、一方または両方が失敗する可能性があります。外部依存関係を扱う際には、この共有状態の問題を解決する必要があります。

Thumbnail 1410

Thumbnail 1420

テスト、コード、そしてそのコードがデータベースに対して読み取りや書き込み、あるいはその両方を行う場合を想像してみましょう。多くの企業では、データベースのセットアップが難しいため、 ビルドマシン、開発マシン、そしてユニットテストフレームワークが、データセンターやクラウドのどこかで実行されているデータベースと統合テストを実行しています。これは問題です。なぜなら、全員があなたのデータベースに読み書きすることになり、それは望ましくないからです。データベースは他の人と共有せず、自分のマシンに置きたいものです。

Thumbnail 1460

Thumbnail 1470

DynamoDBやSQL Serverなど、使用するものを自分のマシンにインストールすることはできますが、それだけでは十分ではありません。テストの前にデータベースを起動し、テストの実行が終わったらすぐにシャットダウンして、開発を続けたり別のテストを実行したりする際に問題を引き起こす可能性のあるものを何も残さないようにしたいのです。 これを実現する一つの方法は、Dockerを使用することです。私は外部依存関係をセットアップするためにDockerを使用することで大きな成功を収めてきました。 そして、テストが終わったらすぐにそれらをシャットダウンします。

このコードの例をご覧ください。これは簡単な例ですが、ポイントは、setupメソッドと同様に、すべてのユニットテストフレームワークには、テスト実行前に一度だけ行う必要のあるセットアップの概念があるということです。これには、one-time setupアトリビュートを使用します。同様に、tear downにもone-time tear downアトリビュートがあります。この場合、外部プロセスを使用して、Docker HubからMongoDBイメージを取得し、起動して、データベースが起動して情報を受け取る準備ができるまで待つコマンドラインを実行しています。

私のマシンでは、このプロセスに最大2分かかります。すべてのテストの実行が終わると、このDockerイメージを破棄し、テスト中にのみ実行される独自のデータベースを確保します。テストを初めて実行する際は少し時間がかかりますが、その後は同じデータベースインスタンスを使って、すべてのテストを次々と実行します。ただし、このアプローチは特定の依存関係でのみ機能し、すべてに適用できるわけではありません。特にクラウドサービスについて話す場合はそうです。

実際のサービスとエミュレーターの選択基準

Thumbnail 1540

Thumbnail 1560

Thumbnail 1580

さて、クラウドサービスについて話さなければなりません。そのLambdaの例でDynamoDBを使用している場合はどうなるでしょうか?DynamoDBに物事を保存する必要があります。S3バケットを取り除くのは既に行いました。それをテストに置き換えるのは簡単です。しかし、問題は、私のマシンでコードが実行され、テストが実行される間、AWSアカウント(願わくは開発用アカウント)を呼び出し、そこにテーブルを作成することです。そして、そこに書き込みや読み取りを始めます。これは、同じテストやテストグループを実行する他の人も同じことをするということを意味し、私たちが並行してテストを実行すると、お互いにつまずいてしまうのです。これは苦痛で、望ましくありません。

Thumbnail 1610

開発者ごとに複数のアカウントを持つのは、チームの開発者の数によってはかなりコストがかかります。そのため、テスト実行を互いに分離する別の方法が必要です。一つの簡単な方法は、各テスト実行ごとに異なるテーブルを作成することです。テーブル名の末尾にタイムスタンプやグリッドを追加します。これは他の多くのAWSサービスでも機能します。すべてのテストを実行する前に、one-time setupで初期化し、作成します。テストを実行し、終了したら削除します。これにより、複数のテスト実行が互いに影響を与えることはありません。これが一つのオプションです。

Thumbnail 1640

Thumbnail 1660

もう一つのオプション、二つ目のオプションは、ローカルエミュレートサービスを使用することです。ローカルエミュレートサービスとは何でしょうか?基本的に、AWSサービスの良い模倣をするものです。DynamoDBの場合、DynamoDB localがあります。これはJavaコードの一部で、ダウンロードしてDockerを使ってマシン上で実行できます。これはDynamoDBデータベースの非常に良い模倣を行います。クラウドサービスと同じように呼び出すことができます。さまざまなAWSサービスに対する商用ソリューションもありますし、GitHubにも同様のプロジェクトがたくさんあります。AWSサービスのローカルエミュレートバージョンを見つけることができ、それらをローカルに持っていれば、ほとんどのものはコードから呼び出すか、Dockerイメージ内で起動することができます。

Thumbnail 1700

これらのエミュレートされたサービスを使用することで、複数のビルドとテストのラウンドが同じインスタンスを使用しないようにすることができます。誰もが自分のマシン上で自分専用のAWSを利用できるのです。ただし、これらのエミュレートされたサービスの効果は場合によって異なります。一部のサービスは、エミュレート対象のサービスの機能全てを実装しているわけではありません。また、定義上、これらは自分のマシン上で動作するため、AWSのロールや権限を再現することはできません。そのため、その分野の問題を発見するのに頼ることはできません。

Thumbnail 1740

Thumbnail 1770

では、この2つのうちどちらを選ぶべきでしょうか?実際のサービスを使用するか、それともエミュレートされたバージョンを使用するか、どのように決めればいいのでしょうか?テスト目的でAWSで自分のコードを実行するべきか、それともGitHubで見つけたローカルバージョン、他社から購入したもの、あるいはAWSから入手したものを使用するべきでしょうか?この2つには違いがあります。実際のサービスを使用する場合、それは実際の本物のサービスなので非常に正確です。 権限やAPIを含め、本番環境で動作するのと全く同じように動作します。期待通りに全ての呼び出しが機能します。初期化も非常に簡単で、おそらく必要なのは、本番用ではなくテスト用のS3バケット、キュー、またはDynamoDBテーブルに書き込むように、少し異なる設定を使用することだけです。

Thumbnail 1800

デメリットは、無料ではないということです。まあ、フリーティアがあるので無料ではあります。しかし、数百人の開発者がいる大きな会社の場合、コストがかさむ可能性があります。特に、大規模な開発チームが同時に複数のテストを実行している場合、フリーティアだけでは全てのテストニーズには不十分かもしれません。

このような場合、ビルドシステムが常に稼働していると、そのコストが目に見えてきます。おそらく、先ほど言及したサービスがそうでしょう。大きな金額ではありませんが、確実にお金がかかります。また、クラウドサービスを呼び出すため、レイテンシーも発生します。これも大した時間ではありませんが、いくつかのコードベースで見たように1,000のテストがある場合、その時間は蓄積される傾向があります。結局のところ、AWSサービスが正しく動作することを確認するのはあなたの仕事ではないことを覚えておく必要があります。そのためのエンジニアがいます。あなたの仕事は、自分のコードがそれらのサービスを正しく呼び出していることを確認することです。そして、再び課題となるのは共有状態です。異なるユーザーや異なる自動ビルドシステムによって実行される異なるテストが互いに干渉しないようにするにはどうすればよいでしょうか?

Thumbnail 1860

Thumbnail 1890

一方で、エミュレーターを使用する場合、エミュレーターは非常に一貫性がありますが、使用する特定のプロジェクトによって異なります。保存したり読み取ったりするデータをメモリに保存したり、何らかのトリックを使用したりする傾向があるため、おそらく全く同じように動作するでしょう。これはあなたのマシンやビルドサーバー上で実行されるコードなので、非常に高速です。また、各テスト実行が独自のエミュレーターを使用するため、異なるテスト実行を分離できます。 欠点としては、機能が部分的であることです。その部分的な機能にはIAMロールが含まれる可能性があります。また、システムの一部となる可能性もあります。S3のようなサービスは、すべてのAPIを正確に模倣するのが難しいことに気づきました。おそらくDynamoDBよりも難しい問題だからでしょう。そのため、プロジェクトにとって良いアイデアかどうかを確認する必要があります。

Thumbnail 1920

ここでの課題は通常、初期化です。なぜなら、新しいテーブル、バケット、またはキューを作成するためにAWS SDKを呼び出すだけではないからです。おそらく、Dockerを使用するか、コードから呼び出すか、または別のプロセスを開始して、特定の依存関係であるエミュレーターを起動する必要があります。また、テスト終了後にクリーンアップできる必要があります。テストが終了した後も残っていては困ります。

どちらを選択すればよいでしょうか?良いニュースは、コード内でセットアップとティアダウンのメソッドや設定を使用していれば、一方から他方に移行することを決めても、テストは同じままであるということです。初期化の方法と、コードをどこに向けるかを決めるだけの問題です。これは話す必要のあるキューで、それをあなたのマシンに向けます。そして、クラウドに移行したい場合は、クラウドで作成したキューと連携するように指示するだけです。したがって、両者間の移行は比較的簡単です。

選択する際に考慮すべきもう一つの点は、開発チームの規模です。実際のサービスを使用することを選択し、数百人の開発者がいる場合、単一または複数のAWSアカウントでどのように実現できるかを考える必要があります。ある時点で、作成できるものの数に制限がかかり始めるからです。実際のAWSサービスを使用する場合、一部の操作が結果整合性を持つことを覚えておいてください。S3バケットを削除したばかりの場合、しばらくの間残っている可能性があります。これらのことを念頭に置いてください。エミュレーターの場合、通常即時的です。なぜなら、APIを模倣するだけで、実際の操作は行わないからです。

Thumbnail 2030

非同期処理のテスト:キューとSNSの扱い方

さて、これは比較的シンプルですね。では、非同期処理についてはどうでしょうか?非同期処理とは、何かがすぐには起こらない場合のことです。通常、私たちのテストはとてもナイーブです。テストが実行され、最後まで到達し、結果をチェックすることを期待します。しかし、非同期処理の場合、それは不可能です。あるいは、少なくとも可能に見えるかもしれません。しかし、自分のマシンでは通過するのに、ビルドサーバーでは失敗するテストになってしまい、再び非決定的で信頼できないテストになってしまいます。

通常、キューを使用する場合、自分のマシンでテストを実行すると、Simple Queue Service (AWS SQS)を使用していても、すべてが完璧に動作します。すべてがすぐに起こり、テストは通過します。デプロイすると、自動ビルドシステムが起動し、テストは失敗します。そして、もはやテストを信頼できなくなってしまいます。そのため、何か対策を講じる必要があります。

Thumbnail 2090

この場合、通常、開発マシンとAWSクラウドがあり、キューは単独ではありません。その後ろに処理を行うサービスやLambda関数があります。そこで、インテグレーションテストの第一の仕事は、これを2つの異なる部分に分けることです。最初の部分は、新しいメッセージをキューに入れるまでに起こるすべてのことです。私のコードとそのキューの間で起こる様々なシナリオをテストする必要があります。

Thumbnail 2120

また、2つ目の部分があります:キューから新しいメッセージが到着したときに、私のコンシューマーで何が起こるかです。 これらはおそらく両方とも同期的で、すぐにそれについて取り上げます。異なる依存関係を分割することで、本質的に問題を解決しやすくし、テストをより保守しやすくします。まだエンドツーエンドテストには至っていないので、常に異なる依存関係を分割し、一度に多くのコードを実行しようとしないようにしてください。

Thumbnail 2160

Thumbnail 2170

Thumbnail 2180

でも、このキューについて私に何ができるでしょうか?このキューは定義上、非同期です。例えば、Amazon SQSにキューイングされたメッセージは通常すぐに表示されますが、それは保証されていません。特にテストでは、すべてが完璧に動作するように見えても、デプロイ後に何か問題が発生することがあります。では、どうすればいいのでしょうか?テスト、Lambda関数、そしてキューがあるとしましょう。テストでは、準備段階でLambda関数の新しいインスタンスを作成します。そして実行段階で、そのLambda関数を呼び出します。新しいメッセージがキューに入りますが、すぐにキューからメッセージを取得しようとするのではなく、メッセージをチェックするビジーループを実行します。新しいメッセージが到着したら、期待する内容と比較します。

Thumbnail 2210

メッセージが取得できない場合は、あらかじめ決めた時間や試行回数の制限(例えば30秒、1分、または100回)に達するまで再試行します。制限に達した場合、テストは失敗します。このようにして、秒単位の抑制のような非決定的なものを、このビジーループを使って決定的なものにすることができます。キューはこの方法で扱えます。なぜなら、キューは私を待ってくれるからです。キューに入れたメッセージは、取得しようとするときにいつでも確実にそこにあります。

Thumbnail 2240

Thumbnail 2250

この方法は、すべてのサービスや依存関係に適用できるわけではありません。例えば、Amazon SNS(Simple Notification Service)では、メッセージは失敗して忘れられてしまいます。テストを初期化し、Lambda関数を呼び出すと、SNSにメッセージが通知されます。SNSは誰かがリッスンしているところにメッセージを転送しますが、まだ検証部分に到達していないので誰もリッスンしていません。ここでも時間ベースのテストになってしまい、それは決定的でも信頼できるものでもありません。

Thumbnail 2270

Thumbnail 2280

Thumbnail 2300

そこで、代わりに次のようにします。Lambda関数を作成して実行するコードを実行する前に、キューを作成します。これはエミュレートされたものでも、SNSトピックに接続された実際のキューでもよく、メッセージを待機させます。キューの扱い方はすでに知っています。メッセージが到着するまで、または正しいメッセージが到着するまでポーリングします。このようにして、非決定的なものを決定的なものにすることができます。必ずしもキューである必要はありません。他のものも使えます。Lambdaを置いてデータをデータベースに保存し、どの呼び出しが到着したかをデータベースでチェックすることもできます。テストしようとしているものや、その簡単さや難しさによって異なります。

Thumbnail 2340

テスト専用のインフラを追加で設定することもできます。これは本番システムにデプロイするものではありませんが、少なくともテストでは、キューを作成してそこからメッセージを取得できます。これで話は終わりでしょうか?いいえ、まだ少なくとも1つのタイプのテストをカバーする必要があります。Lambda関数を見ると、ハンドラーの前(Lambdaがトリガーされる前)のコード、実際のロジック(私が書いているコード)、そしてリポジトリ(私のコードの外部で次の処理を呼び出すすべてのもの)に分けることができます。

エンドツーエンドテスト:Lambda関数とマイクロサービスの場合

Thumbnail 2360

ロジックはユニットテストで検証されており、他の人を呼び出す部分は既に統合テストで検証済みです。しかし、まだテストしていない部分があります。Lambda が起動する前に何が起こるかをテストしていないのです。結果が出た後の動作はある程度テストしましたが、このワークフロー全体が最初から最後まで実際に機能するかどうかは確信が持てません。優れたユニットテストや統合テストを書いても、そのシステムやLambda関数を実行してみると、期待していたイベントが実際には少し異なる形で届くかもしれません。そうなると、新しいファイルが到着するたびにLambda関数がクラッシュしてしまう可能性があります。そんなことは避けたいですね。完了を報告する前に、確実に動作することを確認したいのです。

これまでのユニットテストと統合テストは、実行環境を気にしません。Lambda関数なのか、コンソールアプリケーションなのか、ASP.NET Coreのマイクロサービスなのかは関係ありません。しかし、エンドツーエンドテストではより複雑になります。ここでは、基盤となるホスティング技術を考慮します。それが重要で、テストの一部なのです。

Thumbnail 2440

では、自分のマシンでそれができるでしょうか?Lambda関数を自分のマシンで実行できるでしょうか?答えはイエスですが、自動テストには適していないかもしれません。実際にそれで成功している企業も見てきました。しかし、SAM localを使ってDockerの中でLambda関数を実行するなど、自分のマシンでLambda関数を動かす問題点は、エンドツーエンドテストの目的を損なってしまうことです。

エミュレートすればするほど、テストの範囲は狭まります。エンドツーエンドテストでは、メッセージや設定、さまざまなサービスが実際にどのように連携するか、そしてもちろん権限についても気にかけます。Lambda関数に適切な権限を設定し忘れているかもしれません。それを知る必要がありますが、自分のマシンではできません。実際のアプリケーションが必要なのです。そのため、エンドツーエンドテストは他の2つとは異なります。統合テストとユニットテストはデプロイ前に実行できますし、そうすべきです。一方、エンドツーエンドテストはデプロイ後に実行します。クラウドに移行したときに何が起こるかをテストするからです。

Thumbnail 2520

Thumbnail 2540

したがって、Lambda関数の場合、まず好みの方法でクラウドにデプロイします。SAM、CloudFormation、Terraform、何でも構いません。クラウドにデプロイしたら、クラウドからではなく、自分のマシンやビルドサーバーから実行して、そのデプロイメントをテストします。そしてテスト中、実行段階で、実際のファイルをS3に配置します。Lambda関数を直接呼び出すわけではありません。それをテストしたいわけではないからです。自分のシステムをテストしたいのです。一方に何かを置いて、もう一方で結果を集めるのです。

Thumbnail 2570

では、S3にファイルを置いて、システムが期待通りに動作しているかを確認するために、もう一方からのメッセージをポーリングし始めます。テストはこんな感じになります。ここでも単体テストフレームワークを使っていますが、これは単にコードを実行する簡単な方法だからです。 このユニットテストは実際には単体テストではなく、AWSで実行される本物のエンドツーエンドテストです。テスト自体は私のマシンで実行されますが、実際のLambdaはAWSで実行されます。なぜなら、AWS SDKを使ってS3にファイルを保存し、AWS SDKを使ってメッセージをポーリングするからです。

Thumbnail 2620

これは道路の延長のようなものです。スライドの中にメッセージが戻ってくるまでループするコードを表示するスペースが足りないからです。また、正しいバケット名と正しいSQSキュー名またはURLを使用していることを確認する必要があります。その一つの方法は、テスト実行時にこれらを入力変数として渡すことです。しかし、例えばCloudFormationを使用している場合、 初期化時、つまり一度だけのセットアップ時に、CloudFormationテンプレートを見つけてそこからすべての情報を取得するテストがあります。

Thumbnail 2650

Thumbnail 2660

そうすることで、常に正しいインスタンスを使用していることがわかります。また、異なるデプロイメントを並行して実行できます。それぞれが異なるデータベース周りのインフラを使用するからです。これが私のLambdaのエンドツーエンドテストのやり方です。マイクロサービスの場合、システム全体をエンドツーエンドでテストする際には、 テストの範囲が少し大きくなります。他のサービスを呼び出すサービスがあり、それらのサービスがさらに他のサービスを呼び出します。すべてがデータベースを持っています。 そして時には、テスト目的で自分のシステムにない外部のもの、他のチームのコード、他の会社のコードやサービスを呼び出すこともあります。

サービスレベルテスト:ASP.NET CoreとTestHostの活用

おそらく、頻繁にやり取りするとコストがかかるでしょう。しかし、システム全体が機能することを確認するためには、これらのテストが必要です。ただし、マイクロサービスでエンドツーエンドを使用したい場合、特に私のコードが十分に大きい、あるいは大きすぎる場合は、重要なシナリオに限定します。重要なものや壊れやすいものだけにします。単体テスト、統合テスト、エンドツーエンドテストの間の何かが必要です。

Thumbnail 2710

Thumbnail 2720

その「何か」がサービスレベルテストです。サービスレベルテストは、私たちのテスト戦略の重要な部分です。これらのテストは、単一のサービスを取り上げ、そのサービスに対するすべての要件を正しく実装していることを確認します。 通常、これらのテストには単体テストフレームワークを使用します。 これは物事を実行する簡単な方法を提供するからです。ただし、コードを直接呼び出す代わりに、HTTPや他の通信形式を使用してサービスを呼び出します。ASP.NET Coreの場合、おそらくHTTPを使用することになるでしょう。

依存関係に関しては、他のサービス、AWSサービス、データベースについては既に対処方法を知っています。これらは各サービスごとに具体的に設定できます。通常、1つのサービスには1つか2つ、あるいは3つくらいの依存関係があり、無数にあるわけではありません。課題は他のサービスへの対応にあり、これについてはすぐに取り上げます。

Thumbnail 2780

まず、テストから予測可能で簡単な方法でそのサービスを実行できる必要があります。テストの外部で実行されているかのように呼び出せるようにしたいのです。複雑な方法でデプロイしてから、別のプロセスから終了しようとするのは避けたいところです。幸いなことに、ASP.NETチームがTestHostを提供してくれています。 .NET 5.0以前を使用している場合、TestHostを使用して上から下まで実際のサービスを作成できます。実際のAWSサービスやエミュレートされたAWSサービスを指すように設定を変更できます。これは統合テストと同じようなものです。TestHostがポートを選択してくれるので、マシン上で既に使用されているポートとの競合を避けられます。テストに使用できる通常のHTTP clientが得られます。

.NET 6.0以降を使用している場合は、WebApplicationFactoryで同じ結果を得られます。コードは少し異なりますが、同じことを実現します。つまり、サービスを作成し、テスト用に設定を調整し、そしてテストで使用できるclientを作成するのです。

Thumbnail 2840

Thumbnail 2850

Thumbnail 2860

このアプローチは本質的にサービスレベルのテストを構成します。 このclientを使用して、新しいリソース(クーポンなど)を作成するためのHTTP POSTオペレーションを実行し、その後HTTP GETを使用してそれらのリソースを取得して検証できます。 これにより、APIを包括的にテストでき、トップレベルからデータベースまで、そして再び上に戻るまでの全体をカバーできます。

Thumbnail 2890

実際には、他のチームやサービスが私のコードを呼び出す際に使用するclientライブラリを作成することがよくあります。このHTTP clientをそれらのclientライブラリで使用し、また提供するコードが期待通りに動作することをテストできます。これは、予期しないURL形式や実装のミスなどの問題を捉えるのに役立ちます。 これらのテストにより、トップからボトムまでのサービスレベルの要件を検証し、すべてが正しく機能することを確認できます。

Thumbnail 2910

Thumbnail 2930

他のサービスを扱う際、モッキングフレームワークに似ていますが、サービス全体を偽装するプロジェクトがあります。 そのようなプロジェクトの一つがMockServerで、コードから初期化できます。もう一つの選択肢は、同様の目的を果たすWireMock.Netです。 これらの偽装サーバーは、テストを実行する前に初期化します。期待値や動作を設定し、特定のパス、変数、パラメータに対してサーバーがどのように応答すべきかを指定できます。

Thumbnail 2960

Thumbnail 2980

特定のテスト用に期待されるメッセージを設定した後、自分のサービスを呼び出します。そうすると、サービスは初期化して設定した偽装サーバーを呼び出します。偽装サーバーは指定された値を返し、サービスの結果を検証できるようになります。 さらに、偽装サーバーに問い合わせて、特定の呼び出し(例えば、特定のパラメータを持つHTTP GETリクエスト)を受け取ったかどうかを確認することもできます。

テスト戦略のバランス:効率的なアプローチ

Thumbnail 3000

テスト自動化に関する議論をまとめると、様々な目的に応じた異なるタイプのテストを探ってきました。ロジックのテストに焦点を当てるユニットテストから、サービスレベルテストや統合テストのようなより包括的なテストアプローチまで扱いました。これらのテスト方法はそれぞれ、マイクロサービスやサーバーレスアプリケーションの信頼性と正確性を確保する上で重要な役割を果たしています。

Thumbnail 3010

Thumbnail 3020

Thumbnail 3030

これらのテスト方法についてもう少し詳しく見ていきましょう。 異なる部分間の相互作用をテストする統合テストがあります。サービスレベルテストもあり、これはコードの規模によってはLambda関数にも適用できます。 Lambda関数を使ったパイプラインやワークフロー全体の長さによっては、それを分割したいかもしれません。最後に、主要なシナリオに対するエンドツーエンドテストがあります。

効率的であるためには、同じことを二度テストしないようにしましょう。ユニットテストでロジックをテスト済みなら、統合テストで再度行う必要はありません。統合テストがあれば、データベースで期待通りのメッセージを受け取るかどうかを厳密にテストする必要はありません。なぜなら、それは統合テストで行うからです。メッセージについても同じことが言えます。これはサービステストやエンドツーエンドテストで行います。要は、異なるタイプのテスト間でバランスを取り、問題がある箇所や、コードが変更されやすい箇所に時間を投資することが大切なのです。

私は、エンドツーエンドのテストを一切行わず、ユニットテスト、インテグレーションテスト、サービステストのみを行っている企業と仕事をしたことがあります。彼らは非常に成功を収めていました。プロジェクトによって異なりますし、何が最適かを決めて確認するのはあなた次第です。すべてのテストを行っても何か問題が起きた場合、その問題を解決するために今まで使っていなかった新しいタイプのテストがあるかもしれません。

サンプルコードとフィードバックの重要性

Thumbnail 3110

さて、私は多くのコードサンプルを紹介し、すべてのデモを行いました。私が紹介したすべての異なるコードは、2つのリポジトリのいずれかに存在します。1つ目は ASP.NET Core microservices test sample です。これには3つのサービスがあります:2つの HTTP ASP.NET Core サービスと、キューと DynamoDB および MongoDB で接続された1つのバックグラウンドワーカーです。これは、ユニットテスト、インテグレーションテスト、サービスレベルテストを使用してテストする方法を示しています。サービスレベルテストでは、BDD のために SpecFlow も使用しています。

2つ目のリポジトリは serverless test sample で、さまざまな言語のサンプルが含まれています。Java、C#、.NET、TypeScript があります。異なるシナリオとそのテスト方法、非同期テストシナリオ、さまざまな AWS サービス、Lambda 関数の異なる記述方法を見ることができます。あなたの仕事は、そこに行って、あなたのプロジェクトに役立つコードの書き方やテストの方法のアイデアを見つけることです。

最後に、非常に重要なことがあります:感想を教えることを忘れないでください。アプリケーションに行ってフィードバックを提供してください。AWS では、フィードバックを非常に重視しています。このセッションについてあなたがどう思ったのか知りたいです。あなたが必要としているもの、あなたにとって役立ったもの、そして月曜日にあなたのプロジェクトで実装しようと思っていることを聞きたいです。ですので、アプリケーションに行って、フィードバックを提供し、気に入ったこと、気に入らなかったこと、そしてどのように改善できるかを教えてください。


※ こちらの記事は Amazon Bedrock を様々なタスクで利用することで全て自動で作成しています。
※ どこかの機会で記事作成の試行錯誤についても記事化する予定ですが、直近技術的な部分でご興味がある場合はTwitterの方にDMください。

Discussion