re:Invent 2024: AmazonのBuy with Primeチームによる GraphQL API進化とテスト自動化
はじめに
海外の様々な講演を日本語記事に書き起こすことで、隠れた良質な情報をもっと身近なものに。そんなコンセプトで進める本企画で今回取り上げるプレゼンテーションはこちら!
📖 AWS re:Invent 2024 - Fast evolving GraphQL schema with federation (BWP304)
この動画では、AmazonのBuy with Primeチームが、急速に進化するGraphQL APIの開発とテストについて解説しています。Schema Projectionを活用したバージョン管理手法により、異なるバージョンのAPIを1つのベースSchemaで管理し、複数のバックエンドチームが独立して開発できる仕組みを実現しました。また、GraphQL Constraint DirectivesとGenerative AIを組み合わせたテスト自動生成の手法も紹介されています。特に、Amazon Bedrockを活用したPrompt Engineeringによって、プログラムによる列挙では見落としがちなテストケースも網羅的に生成できるようになった点が注目です。Buy with Primeの導入により購入者のコンバージョン率が約20%向上したという具体的な成果も示されています。
※ 画像をクリックすると、動画中の該当シーンに遷移します。
re:Invent 2024関連の書き起こし記事については、こちらのSpreadsheet に情報をまとめています。合わせてご確認ください!
本編
Buy with Primeの概要とプレゼンテーションの導入
みなさん、こんにちは。B Prime 304: Fast evolving GraphQL schema with federationへようこそ。もし間違った会場に来てしまった方がいても、セキュリティが扉を施錠してしまいましたので、1時間みなさんと一緒です。今日は非常に興味深い情報をシェアさせていただきます。私と一緒に登壇する素晴らしい2名をご紹介します。私はSolutions ArchitectのJosephです。お二人には自己紹介をお願いします。
私はDavid Ramosです。AmazonのBuy with Primeチームで Principal Engineerを務めています。Amazonには13年在籍していますので、この話は後ほど飲みながらでもお話しできればと思います。こんにちは、Matt Quinlanです。AmazonのSenior Software Engineerです。Amazonと米国で6年、Buy with PrimeチームでGraphQL APIの開発に3年携わっています。私のアクセントが気になった方もいるかもしれませんが、イギリス英語ではなく、洗練されたオーストラリア英語です。繰り返しになりますが、私はJosephです。この二人のおかげで私も良く見えるというわけです。
まずは簡単なアジェンダからご説明します。Buy with Primeについて、ご存じない方のために簡単にご紹介します。その後、MattにGraphQL Schema Federationについて説明してもらい、最後にDavidに締めくくってもらいます。時間が残れば質疑応答の時間を設けます。もし時間が足りない場合は、Bar Happy Hourや会場の外でBuy with Primeについてもっと詳しくお話しできればと思います。
Buy with Primeの特徴とGraphQLサービスの役割
始める前に、会場の皆さんに少し質問させてください。Prime会員の方、Amazon.comをご存知の方、あるいはAmazonでショッピングをしたことがある方はいらっしゃいますか?会場の皆さんですね。Buy with Primeで私たちが実現しようとしているのは、PrimeをAmazon.comの外部に拡張することです。つまり、お気に入りの商店やD2Cストアのウェブサイトで、そのPrimeロゴを見ることができるようになります。 お馴染みのPrimeロゴが表示され、配送予定日が表示され、さらにカスタマーサービスやスムーズな返品など、Amazonならではの特典も利用できます。
データによると、Buy with Primeは既に導入している加盟店に多くのメリットをもたらしています。Buy with Primeを導入した加盟店では、購入者のコンバージョン率が約20%向上したことが示されています。 また平均して、4件の注文のうち3件がBuy with Prime経由の注文だったということです。つまり、新規購入者を惹きつけ、私たちのサービスを導入した加盟店のエンゲージメントを高めているのです。これは加盟店だけでなく、そのストアフロントを訪れる購入者にとっても大きなメリットとなっています。まさにWin-Winの関係です。
私たちは皆さんご存知のPrime配送、返品、カスタマーサービス、そして多くのサービスを提供しています。Buy with Primeのウェブサイトで詳細をご確認いただけます。しかし、そのバッジの裏側、そして加盟店のウェブサイトで目にするユーザーインターフェース体験の裏には、多くのテクノロジーが存在し、非常に複雑な統合が見えない部分で行われています。Buy with Prime APIの主要なコンポーネントの一つが、私たちのGraphQLサービスです。
このGraphQLサービスのおかげで、お客様は私たちのAPIを通じてプログラムでBuy with Primeとやり取りすることができます。これにより、お客様はAmazon.comの外でもPrimeサービスを提供することで、ビジネスを加速することができます。私たちのAPIを使用することで、お客様は商品カタログのインポート、配送予定時間の表示、注文の照会、返品の同期など、様々なことが可能になります。
これがBuy with Primeの簡単な概要です。詳細については、ウェブサイトをご確認いただくか、このプレゼンテーション後にお声がけください。それでは、GraphQLサービスとその詳細な部分について深く掘り下げていくために、Mattにバトンタッチしたいと思います。ありがとう、Joseph。
GraphQLスキーマの進化と変更管理の課題
では、Buy with PrimeのためのシングルGraphQL APIをどのように提供したのでしょうか? そして重要なのは、これらの異なるバックエンドサービスに対して、どのようにしてスケールを実現し、リリースの品質を管理したのでしょうか? また、どのように異なる種類のお客様をサポートしているのでしょうか?Buy with Primeには、マネージドのお客様、SaaSのお客様、そしてそれぞれ固有の要件を持つカスタマイズされた統合があります。私たちは、これらのお客様に提供するAPIが予測可能な形で進化し、彼らの体験を損なわないようにしたいと考えています。そしてその背後には、複数のバックエンドチームが存在します。 これらのチームが独立して運営でき、スケールアップし、カスタマイズされたより良いサービスをお客様に提供できるようにしたいと考えています。
私たちがGraphQLを選んだ理由は、これらのAPIを組み合わせるのに適したモデルだからです。タイプに対して強力な型定義を持ち、お客様にとって非常に予測可能な体験を提供することができます。GraphQLは非常に強い意見を持った仕様であり、異なるチームからの貢献を組み合わせることを可能にしました。しかし、これらの異なる種類のお客様をサポートし、Buy with Prime製品を急速に展開しようとしたとき、スキーマの変更管理、スキーマのフェデレーション、そしてリリース管理に関する問題がありました。そこで今日は、まずスキーマの変更の問題から始めて、それをどのように解決したかについてお話ししたいと思います。
これらの問題について今日お話しするにあたり、まずは簡単な例から始めて、徐々に発展させていきたいと思います。私のシンプルな例として、Productタイプがあります。これにはtitle、price、create date、last updateというフィールドがあります。このProductに対するqueryと、そのProductを使用してOrderを作成するためのmutationがあります。まずはProduct自体から始めましょう。いくつかの変更を加えて、どのように展開されるか見ていきましょう。最初の一般的な変更は、GraphQLタイプにフィールドを追加することです。これは標準的で、通常は後方互換性があります。フィールドを追加できるのは、顧客は必要なものだけをリクエストするからです。まだ存在しないdescriptionフィールドを追加しても、たとえその顧客がレスポンスの厳密なデシリアライズを行っていたとしても、顧客の体験を壊すことはありません。
これは他のことを始めると、より複雑になります。フィールドを削除したい場合はどうでしょうか?両方の日付フィールドを削除したい場合、顧客がまだそれらをリクエストしているなら、400 Bad Requestエラーが発生します。顧客に事前に連絡して、「これらのフィールドを非推奨にします」と伝え、使用を中止するよう依頼する必要があります。削除する前に、リクエストを監視して、それらが使用されていないことを確認する必要があります。さらに複雑になることもあります - フィールドの型を変更したい場合はどうでしょうか?create dateとlast updateをintegerからstringに変更したいとします。これらのフィールドタイプを変更したい場合、顧客に新しいフィールドへの移行を促し、新規顧客が古いフィールドを使用していないことを確認する必要があります。タイプの変更の管理は非常に複雑なので、フィールドに新しい名前を付ける必要があるかもしれません。
幅広い顧客ベースとのコミュニケーションは困難です。メールを監視していない、またはAPI仕様を確認しに来ない顧客とどうやってコミュニケーションを取ればいいのでしょうか?開発者は、一度統合を構築すると、APIページを定期的に再確認することはありません。このような移行を試みるには、何らかのバージョニングシステムが必要ですが、GraphQLはバージョニングを避けることに強い意見を持っています。そのため、かなり難しくなります。GraphQLの仕様は実際とても役立ちます。そこには多くの優れたアドバイスが含まれています。認可の委譲についてのアドバイスもその一つで、私たちはBuy with Primeでそれを実践しており、とても便利です。また、多くのクロスカット関心事についてオプションを提供しています。バージョニングを避けることに強い意見を持っていますが、私たち自身でそれを行う自由も与えられています。
GraphQLスキーマのバージョニング戦略とSchema Projection
GraphQL仕様のバージョニング方法とそのオプションについて見ていきましょう。これらのオプションを理解するために、クライアントの数とAPIの変動性という2つの要因を考慮します。
APIがどれくらいの頻度で変更されるかを考える必要があります。クライアントの数が1つか2つと非常に少なく、APIが頻繁に変更されない場合、GraphQL Federationからの推奨事項は単純明快です。管理が容易で、変更も簡単で、それらのクライアントとのコミュニケーションも取りやすく、顧客と密接に関わることができます。
顧客やクライアントの数が増え、それらの顧客とのコミュニケーションが難しくなってくると、Schemaを簡単に進化させることができなくなります。 その解決策として、新しいSchemaと新しいEndpointを作成し、V1 EndpointとV2 Endpointを用意することが考えられます。APIの変更を頻繁に行わない場合は、これでも何とか管理できるかもしれません。年に1回や2年に1回程度の変更であれば、新しいAPIを立ち上げてそれらを維持するコストを吸収できるかもしれません。
Primeのように、毎日顧客について新しい発見があり、APIの変更が頻繁に必要となる場合、 顧客のニーズに応えるために新しいAPIを頻繁にリリースしなければなりません。そこで、このVersioningの問題を解決するために、私たちはSchema Projectionと呼ぶ別のソリューションを開発する必要がありました。 Schema Projectionは、基本的にはSchema定義の間に位置する設定で、Projection Configを使用して一連のVersion Schemaを生成する小さなビルドツールです。
元のProductの例を使って、どのような形になるのか説明させていただきます。 ここでVersion 1のProductは、title、price、create date、last updateのみを持つバージョンでした。Version 2では、いくつかの変更を加えました。description フィールドを追加し、create dateとlast updateの戻り値の型をstringに変更しました。これらの情報をすべて含む基本のSchemaを作成し、それを使ってこれらのVersion Schemaを生成できます。
この基本Schemaについて、もう少し詳しく見ていきましょう。 私は2つの新しいAuthoring Directiveを追加しました:AddedディレクティブとRemoveディレクティブです。descriptionのstringにAddedディレクティブを追加して、Version 2からdescriptionを含めることを示しています。create dateについては、整数だったcreate dateとlast updateを削除し、代わりにstring型のcreate date stringとlast update stringを追加して、それらの名前をcreate dateとlast updateにエイリアスしています。
これは単一のSchemaを管理するよりも冗長に見えるかもしれませんが、基本SchemaとBase Type定義を再利用することができます。また、変更の発見可能性が向上し、このType定義に取り組むエンジニアは、1つの場所で定義された1つのTypeと、そのTypeに適用されるすべての変更を確認できます。実際、このType定義を使用して、複数のバージョンをサポートするData FetcherやResolverコードを書くことができます。すべてのフィールドを最適化して返すData Resolverを書き、それを使ってクライアントのクエリに対応することができます。これについては後ほど説明させていただきます。
Schema Projectionの実装と利点
次に、実際の設定について見ていきましょう。これは前のスライドに基づく設定例です。このプロジェクション設定では、Product のバージョン1とバージョン2の定義を示しています。バージョン1とバージョン2、そして先ほどの基本スキーマを使用して、私たちのバージョンを生成します。ここで生成されるバージョンが、私たちの望む出力となります。バージョン2の定義には、いくつかの追加要素があることにお気づきでしょう。このバージョン2の定義では、新しい source ディレクティブが追加されています。added ディレクティブと removed ディレクティブが作成時のディレクティブだったのに対し、source ディレクティブは実行時の意味を持つ実行ディレクティブです。
このsourceディレクティブを使用し、GraphQLライブラリのデフォルトデータフェッチャーの概念を活用することで、顧客のリクエストに対する汎用的または再利用可能な判断を行うことができます。簡単なクエリ例を使って、デフォルトデータフェッチャーを見てみましょう。顧客がProductのタイトルと作成日をクエリしているとします。GraphQLの解決では通常、親から子へ、兄弟要素は並列にネストされた形で解決されていきます。まずクエリとProduct定義を解決し、その後でそのProductのタイトルと作成日を解決します。
デフォルトデータフェッチャーの概念について詳しく見ていきましょう。一般的なGraphQLライブラリでは、何かを解決しようとする際に、まずカスタムデータフェッチャーが存在するかどうかを確認します。クエリでProductを探す場合、これはルートレベルの操作であり、ほぼ確実にカスタムデータフェッチャーが存在します。
ここでは、Productを解決するためのカスタムデータフェッチャーを使用します。カスタムリゾルバーを見つけて使用することになります。以前に作成した基本型定義にバージョン1とバージョン2の属性が含まれていたため、このデータフェッチャーを使用して、それらの属性を楽観的に解決できます。
次に、Product型のサブフィールドを見ていきます。このためのカスタムデータフェッチャーを作成していないため、デフォルトデータフェッチャーを使用します。このデフォルトデータフェッチャーをオーバーライドして、追加の処理として、このフィールドに関連付けられたsource実行ディレクティブがあるかどうかを確認します。titleの場合、バージョン間で変更や修正がないため、単にプロパティ名を通じてtitleを解決します。create dateの場合、バージョン2で変更され、新しいsourceディレクティブが追加されています。デフォルトデータフェッチャーでの実行時にこれに遭遇した場合、createDateStringを通じてデータを探します。格納されているcreateDateStringを探し、それを使用してcreate dateに対する顧客のリクエストに応答します。
このVersioningの話をまとめますと、基本となるSchemaを1つ定義することで、各チームが管理・作成している型の変更を1か所で定義・確認できるようになりました。これにより、その特定のVersionに対して単一のData Fetcher を作成することも可能になり、型全体を最適な形で返し、デフォルトまたは汎用的なData Fetcherに任せてVersion間での戻り値の型を管理できるようになりました。これによって、クライアントの要望に特化したSchemaを開発できるようになりました。先ほど申し上げたように、Buy with Primeでは様々なクライアントに対してSchemaの変更を素早く適用する必要がありましたが、今ではそれらのVersionと変更を1か所で管理できるようになっています。
フェデレーテッドアーキテクチャとComposed Layer
さて、Schema のVersioningの方法は分かりましたが、これを複数のBacking Serviceにわたってどのように管理すればよいのでしょうか?Buy with Primeには顧客体験を管理する多くのチームがあり、それぞれが独立して活動しています。では、これらのチームがSchemaを作成し、テストし、リリースする能力をどのように管理すればよいのでしょうか?基本的な定義に戻って考えてみましょう。GraphQLのProduct MutationとProduct定義があり、これらはVersion 2の管理用に更新され、Version 2のAuthoring Directiveを持っています。ここでは単純なインフラ構成があります:GraphQL APIと、ProductとOrderのBacking Serviceです。これらのServiceは現在ほぼ独立しており、データの解決は GraphQL API自体が行っています。
ここで、もう少し複雑にしてみましょう。Inventory Serviceという新しいServiceを導入します。InventoryはProductの拡張です。ProductにはInventoryがあり、これを定義する方法はいくつかあります。GraphQLの仕様自体を使用して「Product Typeを拡張し、Inventoryを追加する」という方法があり、この場合InventoryにはIDと数量があります。あるいは、アノテーションやDirectiveベースのFederationフレームワークを使用して、Productに対してExtendsを設定し、このInventoryが他のProductの定義をどのように拡張するかについてカスタムビルドロジックを持たせることもできます。そうすれば、GraphQL APIはInventoryへのリクエストをInventory Serviceに委譲するだけで済みます。
この従来のモデルには、私たちにとっていくつかの問題がありました。1つ目は、Product Microserviceが Composed APIの外部でも独立して動作し、独立したサービスとして提供される必要があったことです。Product Serviceは他の場所で別のユースケースをサポートする可能性があるため、Composed GraphQL APIを経由せずにProduct Microserviceを呼び出してInventory情報を取得できる必要があります。2つ目の問題は、Inventoryの解決がProduct Schema と解決方法の面でやや結合していることです。Inventoryを解決する際には、Product Keyが何であるかを理解しているか、Inventoryを解決するためのProduct IDを持っているという前提がありますが、この関係は逆になる可能性もあります。ProductがInventory Keyを持っていてInventoryについて知っているが、InventoryはProductについて必ずしも知る必要がないかもしれません。
本当に問題なのは、システムを拡張してFederationを行い、このように接続・構成を始めると、テストとデプロイメントが非常に複雑になることです。Inventoryの変更をComposed Layerに反映させたい場合、それがProductと連携して動作することを確認する必要がありますが、Composed Layerに到達するまでテストができません。つまり、Authoringは分散化されていても、CI/CDは分散化されていないのです。これらの問題を解決するため、私たちはGraphQLのCompositionとGraphQL Service Layerを下位に押し下げています。Composed Layerは最上位だけでなく、システム内の特定のノードにも存在するようになっています。
私のProduct Serviceには、Product Queryを処理する責任を持つComposed Layerがあり、このComposed Layerにはいくつかの利点があります。最も大きな利点の1つは、CI/CDとテストの容易さです。Productチームは、Composed APIとは独立してInventoryとの関係を管理・開発し、リリース前にすべてが正常に動作することを確認できます。一方で欠点としては、Domain Service Layerに新しいサービスを追加することになるという点があります。
もう1つの方法として、Composed Nodeだけでなく、すべてのNodeにGraphQL Reverse Proxyを追加することもできます。つまり、各サービスがResource指向のサービスシステムとGraphQL指向のサービスシステムの両方を持つことになります。では、この増加するコンピュートリソースをどのように管理すればよいのでしょうか?私たちはFargateベースのシステムを使用しており、 1つのFargate Task内で複数のコンテナを実行する機能を活用できます。一度作成して各所に配置できる設定駆動型のReverse ProxyであるGraphQLコンテナと、よりカスタマイズされたバックエンドサービスコンテナを配置することができます。
Fargateでは、Elastic Network Interfaceを活用してこれらのシステム間でゼロミリ秒の遅延でコミュニケーションを取ることができるため、レイテンシーのコストは発生しません。また、これらは同じTask内のコンテナなので、コンピュートコストも増加しません。このアプローチの欠点は、GraphQLコンテナとバックエンドサービスコンテナが同じTaskの一部であるため、同じ割合でスケールすることです。それぞれに独自のコンピュートとメモリのフットプリントを与えることはできますが、Taskをスケールアップするたびに、両方が一緒にスケールすることになります。
1つの選択肢として、これらのコンテナを独立したサービスに分割しつつ、同じCluster内で実行することができます。これには、それぞれに対して個別にApplication Load BalancerやTarget Groupをセットアップする必要がありますが、同じVPC内に配置され、最小限のレイテンシーコストでコンテナからバックエンドサービスコンテナへルーティングすることができます。これがFargateインフラストラクチャ側の話の流れです。
アーキテクチャができたところで、Schemaの構成をどのように管理すればよいのでしょうか?私にはSchemaとバージョンSchemaがありますが、これらをどのように組み合わせるのでしょうか?InventoryのSchemaから始まり、ProductのSchema、そしてComposed Schemaへと、それらがどのように組み合わさるのかを例を挙げて説明していきます。これは非常にシンプルなProduct Schemaの定義です。1つのバージョンだけを持ち、単にProduct Typeを拡張しています。Projection設定も非常にシンプルで、バージョン1を持つと指定するだけです。 結果として、出力Schemaは1つのバージョンとなり、入力と全く同じように見えます。何も変更していません。
Projectionの状態管理とリリースプロセス
次にProductが登場し、Base Schemaで定義された2つのバージョンを持つProductの定義があります。 そして、Product Definition Schema Projection Configurationがあります。新しいバージョン3を定義しました。このバージョン3にはInventory Definitionが含まれています。InventoryのConfigurationを含み、すべてのバージョンアノテーションがVersionから来ているため、バージョン2に適用されたものはここでバージョン3にも適用されます。これにより、説明文やCreate Date、Last Updateを含むバージョン2の変更と、新たに追加された合成されたInventoryフィールドを含むOutput Schemaが生成されました。
この合成レイヤーで同じことを続けることができます。Product Definitionを含め、Orderのために定義したものを含め、そして完全なSchemaを生成することができます。アーキテクチャと合成をSchema Projectionに押し下げましたが、ここでCall Delegationの問題が発生します。異なるバージョンと異なるURLを持つ合成レイヤーから、ProductレイヤーやInventoryレイヤーにどのように呼び出しを行えばよいのでしょうか?
Projection Configurationに少し追加を行います。IncludesにURLを追加して、Productを解決する際にこのProduct URLを通じて解決するように指定できます。Produced Schemaを生成する際、新しいExecution Directiveを追加します。以前はSource Execution Directiveを持っていましたが、今度はRemote Execution Directiveを持ち、これは基本的にこのProduct Definitionを実行する際、このURLでこのバージョンを実行するように指示します。以前使用していたDefault Data Fetcherを活用することができます。
以前のDefault Custom Data Fetcherは、Custom Resolverをチェックし、なければデフォルトのものを使用します。デフォルトのData Fetcherを使用する前に、Remote Directiveをチェックします。 Push Down Compositionを使用しているため、別のレイヤーがすでにRemote Directiveを使用してこのデータを設定している可能性があるので、まずデータが設定されていないことを確認する必要があります。設定されていない場合、Remote Reviewer Roleを使用してダウンストリームシステムにリクエストを委譲できます。Remote Directiveがない場合は、Source Directiveをチェックします。
このFederationモデルからどのようなメリットが得られるでしょうか?各チームが独自にSchemaのバージョンを定義し、自身のSchemaリリースを管理できるようになりました。チームは異なるSchemaを組み合わせることができ、それらのSchemaの異なるバージョンを適切な方法で組み合わせることができます。この合成をアーキテクチャに反映させました。つまり、Schema Projectionだけでなく、依存関係ツリーに沿って呼び出しを委譲する形で、アーキテクチャにも合成を押し下げたのです。
このアプローチにより、チームは自身で検証とテストを行うことができ、これは重要なメリットです。Push Downチームは、Composedレイヤーに到達する前に、すべての変更を検証することができます。 さらに、実行ディレクティブを使用することで、実行レイヤーを比較的汎用的で設定駆動型にすることができました。各バッキングサービスの全てのFargateインスタンスに配置していたこのGraphQL Reverse Proxyシステムは、基本的に同一のものであり、設定によって制御されるため、バッキングサービスチームにとって扱いやすくなっています。
フェデレーテッドアーキテクチャを採用し、新バージョンの変更をリリースできるようになった今、安定性をどのように確保すればよいのでしょうか? これはBuy with Primeにおいて大きな課題となりました。私たちには、APIに早期に参加した採用者が何人かおり、彼らのニーズに応えてきました。ビジネスが成長し、新しい顧客を獲得するにつれて、それぞれの要件に対応する必要が出てきました。そこで、既存の顧客に影響を与えることなく、APIを進化させる必要がありました。Fargateを使用してバージョン管理する機能はありましたが、既存の顧客に影響を与えないようにし、予測可能で安定した体験を提供する必要がありました。
例を振り返ってみましょう。オーサリングディレクティブを持つProduct定義と、Product Version 3の作成とImagery Version 1の含有を指定し、Imagery Version 1を特定のURLにルーティングするProjection定義があります。これがPush Downアーキテクチャです。 問題について議論を始めるために、このアーキテクチャを単純化してみましょう。 GraphQLのProduct Inventoryコールに焦点を当て、バッキングサービス部分ではなく、GraphQLの側面だけを考えてさらに単純化します。 顧客がProductとInventoryのリクエストを行うと、GraphQL APIを通じてProduct APIに、そしてInventory APIにルーティングされ、必要なInventoryデータを取得します。
これらは先ほどのProjectionです。 Product Version 3を含むComposed Projectionがあり、ComposedはVersion 1です。中間には、Inventory Version 1を含むProduct Version 3があります。そして端には、Inventory Version 1が推移的に Composedレイヤーに組み込まれています。顧客がコールを行うと、Version 1を使用してGraphQL APIにコールを行い、それがProduct APIへのVersion 3コールに変換され、さらにInventory APIへのVersion 1コールに変換されます。
しかし、Inventoryチームが Version 1 APIに変更を加えた場合、意図的であれ偶発的であれ、どうなるでしょうか?後方互換性のない変更を行い、顧客体験を損なう可能性がある場合はどうでしょうか?私たちはチームに独立してスケールする能力と、構築するものに対するオーナーシップを与えましたが、そのオーナーシップの拡大により、顧客体験に影響を与える可能性が生まれました。これに対処するため、Projectionに状態の概念を導入しました。状態は必要に応じて意味のある関連性を持たせることができます。この例では、DraftとReleaseという2つの状態を考えてみましょう。Release状態には、このリリースの安全性を管理するのに役立つ2つの重要な特性があります。 第一のルールは、親Projectionは、Releaseされた Projectionのみを含むことができ、Draftやその他の状態のProjectionは含めることができないということです。第二のルールは、Releaseされた Projectionへの変更は、ビルドを破壊するということです。
これらを組み合わせる際にカスタムビルドルールを追加しました。リリース済みのものについてスキーマに変更が検出された場合、ビルドは失敗し、その理由が意図的なものかどうかを確認して変更を管理する必要があります。このようにして、変更をビルド時に検知できるようになりました。
インベントリチーム、イメージチーム、プロダクトチーム、グラフィカルチームがそれぞれのProjectionにリリース日を持っている場合、合成された Graphical APIは、リリース日を持つProduct APIのみを含むことができます。同様に、Product Graphical APIは、リリース状態にあるImagery APIのみを含むことができます。では、インベントリチームが変更を加えたい場合はどうすればよいのでしょうか?この場合、バージョニングシステム、Product Projection Composition、Projectionのリリースを活用して管理することができます。
インベントリチームはオーサリングディレクティブを追加し、変更を加え、バージョン2を作成してドラフト状態に設定します。独自のGraphQL APIを持っているため、このドラフト状態で独立してテストと検証を行うことができます。必要なテストを完了し、状態に問題がないことを確認したら、リリース済みとしてマークすることができます。これにより、このAPIは利用可能な状態となり、消費準備が整います。
Product APIは、このリリース済みのAPIを利用できるようになります。インベントリバージョン2を使用する新しいProduct バージョン4を作成できます。既存のバージョンでインベントリを更新または増分することもできましたが、それはすでにリリース済みとしてマークされていたため、ビルドが失敗していたでしょう。そのため、新しいバージョンを作成するのが最も簡単な方法です。合成されたAPIは、インベントリバージョン2が必要なためバージョン2をリリースし、Product バージョン4を経由してそれを推移的に取得することができます。
このバージョンスキーマと明示的な合成の使用は、ロールアウトの観点で大きな利点があります。バージョニングなしでロールアウトする場合、これらの異なるシステムでの変更を同期させる必要があります。Subgraphでロールアウトする際、その変更に対応できていないクエリを実行しているお客様がいる可能性があり、また合成されたAPIではスキーマの同期デプロイメントが必要となり、これは管理が非常に困難です。このアプローチにより、より順次的なロールアウトが可能になります。
Schema変更、バージョニング、リリース管理について、重要なポイントをまとめさせていただきます。まず第一に、単一のベースSchemaがあります。すべての変更はこのSchemaで作成され、管理されます。 これにより、一箇所でSchemaの進化を確認し、追跡・管理することができます。第二に、ベースSchemaからの抽象化または参照としてProjectionを作成しました。このProjection上でルールセットを定義し、バージョンSchemaを生成します。さらに、Projection設定は他のProjectionを組み合わせることができます。 つまり、InventoryのProjectionとProductのProjectionを定義し、ProductにInventoryのProjectionを含めることで、合成されたProjectionを作成できるのです。
第三に、Projectionにステートを含めました。 これにより、Projectionのリリースとライフサイクルを管理することができます。これらのツールを活用することで、 独立して運用したい多数のバックエンドサービスを管理し、それぞれ異なるニーズを持ち、新機能を今すぐ必要としている多くのお客様に対応することができます。そして、それらの新機能を今すぐにロールアウトすることができるのです。
Generative AIを活用したAPIテストケース生成
では、これらのSchemaのテスト方法について、Davidに説明を譲りたいと思います。ありがとうございました。皆さん、こんにちは。本日は、 Generative AIを活用してテストコードのカバレッジを向上させる取り組みについてお話しさせていただきます。ご覧いただいたように、私たちのAPIサーフェスは、異なるバージョンやProjectionにわたって大きく拡張されています。そのため、サービスの品質とパフォーマンスを持続的に管理するために、自動テストを活用したいと考えています。まだ調査の初期段階ではありますが、AWS Bedrockを使用して学んだテクニックと教訓の一部を共有させていただきたいと思います。これらは皆さんの業務にも応用できるものです。まずは問題提起から始めましょう:特定のAPI定義に対して、合成されたメソッドを持続可能な方法で包括的にテストするにはどうすればよいでしょうか?包括的というとき、私は3つの重要な領域について話しています。
APIの成功パス、失敗モード、エラー条件のテストです。パラメータの境界値を探索し、合成されたAPIシナリオも検証したいと考えています。これらを持続可能性とバランスを取りながら進める必要があります。考慮すべき要因がいくつかあります。まず開発コスト、そしてより重要な長期的な保守コストです。また、テスト実行に必要なリソースも考慮する必要があります。そして恐らく最も重要なのは、テストが失敗した際の理解のしやすさです。テストの問題なのか、基盤となるサービスの問題なのかを素早く判断できる必要があります。
AIを活用することで、これらの徹底的なテストを作成する能力を高めながら、より保守性が高く解釈しやすいものにしたいと考えています。このバランスはAPI全体のテストを通じて維持する必要があります。その詳細に入る前に、私たちのAPI検証アプローチについて簡単に説明させていただきます。フィールド制約に対してGraphQL Constraint Directivesを活用しており、これにより宣言的な検証が可能になり、クライアントとの共有データ契約を確立できます。これらのDirectivesは、下流での不整合を最小限に抑えることでデータの整合性を向上させ、API Projection全体でエラーレスポンスを標準化します。
以前に示した Schema の例の1つを、今度は Directive を活用して見ていきましょう。文字列の長さに対する最小値と最大値の制約や、特定の正規表現パターンとのマッチングを定義できることがわかります。ここでは示していませんが、数値などの他のプリミティブ型に対しても同様のことができます。この基礎的な説明が済んだところで、私たちの Generative AI プロセスで使用する2つのステップについて理解を深めていきましょう。最初のステップでは、API 定義から Test Case の説明を生成し、デジタル指示やコンテキストを追加します。2番目のステップでは、これらの説明を実際のテストコードに変換します。
このプロセスを2つのステップに分割している理由はいくつかあります。まず、「何を」と「どのように」を分離することで、Test Case のカバレッジとテストコード生成を個別に最適化できる柔軟性が得られます。また、一方に制約を課すことなく、両者を最適化することができます。さらに、テストエンジニアが設計時に活用するベストプラクティスに沿っており、コーディングの前にテストを計画するというアプローチとも合致します。以降のスライドでは、Test Case 生成の2つのアプローチを検討し、その後コード生成について詳しく見ていきます。
包括的な API テストのための従来の手法による実装方法を見ていきましょう。私たちの目標は、API のあらゆる側面を体系的にカバーすることです。戦略は概念的には単純で、API Schema 全体を通じて各オペレーションの引数とバリデーション条件を確認していきます。可能な組み合わせごとに Integration Test を作成したいと考えています。このアプローチでは、Happy Path だけでなく、エッジケース、境界条件、エラーシナリオもテストすることができます。徹底的で体系的なアプローチですが、これから見ていくように、独自の課題も伴います。
こちらが私たちの Schema を実装した簡略化された Python コードです。これは擬似コードに近いものですが、メインの generate_tests 関数は Schema 内の各オペレーションを反復処理し、各オペレーションに対して Happy Path のケースを生成し、入力のテストに深く踏み込みます。test_inputs 関数をより詳しく見ていくと、ここで事態は興味深く、そして複雑になってきます。各引数を検証し、必須フィールドの欠落や不正なデータ型といった基本的なシナリオのテストを生成します。ここでの主な課題は、ネストされた複合型の処理です。これらに対しては、テスト生成プロセスを再帰的に適用する必要があります。この再帰により、テストを徹底的に行うことができますが、同時にテストスイートの複雑さも大幅に増加します。これらの入力構造にデータフィールド間の特別な関係性やその他の考慮事項がある場合、この時点でハードコードする必要があり、初期コーディングとメンテナンスのコストが増加します。
では、このトレードオフについて見ていきましょう。プラスの面として、API Schema の包括的かつ体系的なカバレッジという主要な目標を達成できます。Test Case の生成は概ね決定論的で再現可能であり、実行時間も比較的短いです。外部サービスに依存しないため、テストプロセスは自己完結的で信頼性が高く保たれます。
しかしながら、考慮すべき重要な課題もあります。実装には時間がかかり、テスト生成ロジックの正確性を確保するには最初の段階で相当な労力が必要です。構文ツリーのトラバースの複雑さにより、コードの保守や更新が困難になります。このアプローチの硬直性により、予期せぬシナリオや特に複雑なシナリオ(テスト時には非常に扱いが難しい)を簡単に想定することができません。カバレッジは包括的ですが、実際のエラーを発見する可能性が高い入力を賢く選択できているとは限りません。
この問題提起から再び始めて、 今度はGenerative AIを活用していきましょう。ここでの違いは、Large Language Modelの能力を活用してAPIスキーマを解釈し、包括的なテストスイートを生成することです。もう一度、 シンプルなPythonの実装から始めましょう。この関数は、AIを活用したテスト駆動プロセスの主要な部分をすべて含んでいます。GraphQLスキーマを入力として受け取り、包括的なテストセットを返します。
これを詳しく見ていきましょう。describe schema関数は、AI支援アプローチの最初のステップです。 これはGraphQLスキーマの簡潔な説明を作成します。完全な定義をそのまま使用することもできますが、この要約版では、型、フィールド、引数、バリデーションルールといったテスト生成に重要な要素を強調しています。これにより、AIに対してAPIの構造と制約に関する必要不可欠なコンテキストのみを提供し、スキーマの複雑さに圧倒されることなく、関連性の高いテストを生成できるようになります。
次のステップは、AI用の詳細なプロンプトを作成することです。このcreate prompt関数は、スキーマのコンテキストとテスト対象の特定のオペレーションを受け取り、AI用の包括的な指示セットを生成します。このプロンプトには、スキーマの説明に続いて、AIに生成してほしいテストケースの種類に関する明確な指示が含まれます。これには、正常系のケースだけでなく、エッジケースや必須コンポーネントが欠落しているケースも含まれます。プロンプトエンジニアリングについては後ほど詳しく説明しますが、今はコードの説明を続けましょう。
ask_ai関数は、 慎重に作成したプロンプトをAIモデルに送信します。この場合、Amazon BedrockのClaude 3.5用のconverse APIを使用しています。Language Modelは、提供された情報に基づいてテストケースのセットを生成します。 最後に、おそらく最も重要な部分の1つですが、受け取ったテストを検証します。validate test関数は、AI生成のテストケース記述の構造と内容の検証に焦点を当てています。ここでのポイントは、まだコードを生成していないため実際にテストを実行することはできませんが、テストケースの記述が適切に形成され、API スキーマと整合していることを確認することです。
それでは、Prompt Engineeringについて説明しましょう。AI駆動型アプローチの鍵となるのがPromptの構造です。ここには4つの重要な要素があります:APIの定義であるスキーマのコンテキスト、テスト対象となる操作、テスト要件(成功条件とエラー条件の両方をカバーし、最小値・最大値の境界値やその直前・直後の値を含む)、そして最後にLLMの出力をプログラムで利用できるようにする出力フォーマットです。
しかし、さらに改善することができます。要件の1つがAPIの包括的なテストケースを生成することだったことを思い出してください。これを実現するために、私たちはPromptに変更を加えて、LLMを繰り返し呼び出すことで新しいケースを段階的に生成できることを発見しました。特に最新のモデルを使用する場合、このアプローチによってあらゆるシナリオやエッジケースを確実にカバーできることがわかりました。ここで見ていただけるように、Promptに変更を加えました - 生成済みのテストや既存のテストの作業セットを中間状態として渡すようになっています。
また、LLMに新しいテストケースを生成するよう明示的に依頼し、生成が完了したと判断した時点でそれを示すように指示しています。
さらに改善の余地があります。ここでは、Chain of Thoughtと呼ばれる高度なPrompting技術をアプローチに組み込む方法について説明します。Chain of Thoughtは、AIに推論プロセスを分解し、考え方を段階的に説明するよう促す手法です。Promptを構造化することで、テスト生成の指示を拡張しました。これは、数学の問題で生徒に解き方を示すように求めるのと同じように、AIに作業過程を示すよう求めているのです。まずLLMに操作を分析してテストの目的を特定するよう依頼することで、テストケースの生成を始める前に何をテストする必要があるかを完全に理解していることを確認します。LLMが各テストケースの理由を説明する過程で、最初は考慮していなかった追加のシナリオやエッジケースが見つかることもあります。また、私たちコード作成者にとっても、AIの思考プロセスとその出力に至る過程を理解できるため透明性が確保されます。これを基にPromptやその他の指示を更新することができます。最後に、経験豊富なQAエンジニアが取るような構造化されたテスト設計アプローチを模倣しています。
ここで、Generative AIアプローチのトレードオフについて考えてみましょう。プラスの面として、この手法は高い適応性を提供します。AIは大規模なコード変更を必要とせずに、異なるAPI構造や異なるAPI言語にも素早く対応できます。また、創造的なテスト生成に優れており、人間が見落としがちなテストケースを考え出すことができます。このアプローチでは、すべてを一からコーディングするのではなく、AIの既存の知識を活用するため、初期開発時間を大幅に短縮できます。さらに、プログラムによるアプローチでは扱いが難しいAPIの条件の組み合わせにおける複雑なシナリオも得意としています。加えて、テストの実行結果や発見されたバグ、新しいエッジケースをフィードバックすることで、AIが時間とともに学習し改善していく可能性もあります。
トレードオフについて、重要な考慮点の1つはPrompt Engineeringに必要な専門知識です。AIから最良の結果を得るには効果的なプロンプトを作成するスキルが必要となりますが、これは現在コーディングを行っている多くのチームにとって新しい課題です。また、結果の一貫性の問題も考慮する必要があります。プログラムによる列挙の完全な決定論的な性質とは異なり、AIは実行するたびに若干異なるテストセットを生成する可能性があり、これは時間とともに再現性に影響を与える可能性があります。さらに、外部サービスへの依存があり、これによってコントロール外の潜在的な障害ポイントが発生します。最後に、生成されるテストの品質は、使用するAIモデルとそれが類似タスクでどの程度トレーニングされているかによって異なる可能性があります。
テストコード生成とGenerative AIアプローチの展望
では、テストケースからテストコードへの変換方法について見ていきましょう。この作業には、TemplatingとGenerative AIの両方のアプローチを検討しました。興味深いことに、これらの手法は似たようなコアプロセスに従っています。 Template-basedのコード生成では、APIテストケースの記述に加えてテストコードのテンプレートをTemplate Engineに提供し、それを使用してテストコードを生成します。 LLM-basedの生成では、APIテストケースの記述は依然として必要ですが、今度はテンプレートの代わりにLLMとサンプルテストコード、そしてテストケース記述生成と同様のプロンプトを使用します。Templatingは事前定義された構造に厳密に依存する一方、Generative AIはより多くのエラーを引き起こすリスクはありますが、大きな柔軟性と適応性を提供します。
エラーを最小限に抑える方法の1つは、生成にフィードバックループを導入することです。これは全体的なアプローチにレイテンシーを追加しますが、結果の品質を大幅に向上させます。そして、これはTemplatingでは自動的に行うことができない機能です。
それでは、エンドツーエンドの例を見ていきましょう。 ここでは、以前のProduct Typeに基づいた新しいCreateProduct MutationとProductInput、PriceInput Typeを含むGraphQLスキーマを見ることができます。右側の例は、 CreateProduct Mutationに対してGenerate AIテストケースを実行した際に期待される結果です。各テストケースには、Product Infrastructureに一致する完全な入力フィールドセットが含まれています。最初のテストは有効なProductが作成できることを確認し、2番目のテストはタイトルの長さのバリデーションをテストします。これらのテストケースはスキーマとMutationの定義から直接導き出されています。ここでも、成功と失敗の両方の操作をカバーすることを確認したいと思います。
このスライドでは、テストケースの記述からPythonインテグレーションテストへの変換方法を示しています。 左側には、操作、入力データ、期待される結果を指定するJSON形式のテストケースがあります。右側では、対応するPythonテストを見ることができます。このテストはGraphQL Mutationを構築し、入力変数を設定し、クエリを実行します。その後、レスポンスが期待と一致することを確認します。つまり、最初のケースではProductが正しいデータで正常に作成されることを確認し、2番目のケースでは適切なエラーレスポンスが得られることを確認します。
議論のまとめとして、このアプローチの最も価値のある側面を示すいくつかの重要なポイントに焦点を当てたいと思います。まず第一に、GraphQL Constraint Directivesは、クライアントにAPIの使用方法を伝えるだけでなく、自動テスト作成にも役立つ強力なツールであるということです。次に、プログラムによるテストケース作成とGenerative AIによるテストケース作成の違い、そしてテストコード生成について説明しました。ここでは、GenAIがテンプレート技術の代替手段となることを示しました。第三に、適切なPrompt Engineeringが非常に重要であることを確認しました。プロンプトを編集することで、生成されるテストケースの品質と網羅性を大幅に改善できます。最後に、AIはテスト戦略に創造性をもたらしますが、プログラムによる列挙を完全に無視すべきではありません。私たちが気づいたのは、GenAIとプログラムによる列挙を組み合わせることで、実際に相乗効果が得られるということです。プログラムによるアプローチは、既知のシナリオと制約を体系的にカバーし、AIはその隙間を埋めることができます。
次のステップとして、以下のような取り組みを検討しています。まず、大規模なスキーマのチャンク化技術を探求しています。実際の現場のAPIは複雑で、数百から数千のオペレーションやタイプを持つことがあります。これらを管理可能なセグメントに分割することで、AIの注目を特定の領域に集中させ、生成されるテストの品質を向上させ、APIコールのトークン制限にも対応できます。次に、単一のAPIテストを超えて、Multi-API Interactionシナリオへと移行しています。これにより、APIを個別にテストするだけでなく、統合の問題もカバーできるテストケースを生成できます。第三に、テストコードの実行結果をテストケース記述の作成にフィードバックする、より堅牢なフィードバックループの構築を目指しています。最後に、手動で呼び出す必要がなく、スキーマの更新や新しいバグ、その他の要因に応じてGenerative AIが自動的にトリガーされ、テストを拡張できるContext-Aware Test Evolutionの開発を始めたいと考えています。
本日のプレゼンテーションにご参加いただき、ありがとうございました。
※ こちらの記事は Amazon Bedrock を利用することで全て自動で作成しています。
※ 生成AI記事によるインターネット汚染の懸念を踏まえ、本記事ではセッション動画を情報量をほぼ変化させずに文字と画像に変換することで、できるだけオリジナルコンテンツそのものの価値を維持しつつ、多言語でのAccessibilityやGooglabilityを高められればと考えています。
Discussion