ずっと欲しかったRustのWebAPIテストフレームワークを個人開発している話
自己紹介
アメリカのIT企業でRustを使ったWebAPI開発に携わっています。Rust以前はC++エンジニアとして経路探索WebサービスやMMORPGサーバー、証券取引システムの開発をしていました。2017年からRustでバックエンドの開発をしており、WebAPIテスト自動化の課題に長年取り組んできました。
なぜRustでWebAPIを開発するのか
Rustを選ぶ理由は主に3つあります:
- 🛡️ 安全性: Rustのメモリ安全性により、セグメンテーション違反やデータレースといった問題を コンパイル時に検出できます。C++で開発していた時代には、ダングリング参照やデータレースによる予期しないクラッシュに悩まされることがありましたが、Rustではこれらの問題を根本的に解決できます。これにより、本番環境での予期しないクラッシュを大幅に減らすことができます。
- ⚡ 生産性: 強力な型システムとパターンマッチング、そして優秀なエラーハンドリング機能により、堅牢なAPIを効率的に開発できます。また、cargoによる依存関係管理も非常に使いやすいです。
- 🚀 速度: Rustはゼロコスト抽象化を実現しており、C++に匹敵する実行速度を持ちながら、より安全なコードを書くことができます。WebAPIにおいて、高いスループットと低いレイテンシーを実現できます。
RustでのWeb開発について詳しく知りたい方は、筆者が発表したMapboxでの事例スライドも参考になります。
自動化されたWebAPIテストの重要性
現代のWebAPIは複雑なインフラストラクチャ上で動作しています。Envoy等のAPI GatewayやAWS CloudFrontのようなCDNレイヤーが存在するため、ユニットテストやインテグレーションテストでは検出できない問題が発生することがあります。
そのため、実際の本番環境に近い状態でのend-to-endテストが必要不可欠です。これにより、以下のような問題を早期に発見できます:
- API Gateway設定の問題
- CDNキャッシュの動作確認
- 認証・認可フローの検証
- レート制限の動作確認
- 実際のネットワーク条件下での性能確認
今までどうしてきた?
2019年頃: Postman
最初はPostmanとNewmanを使用していました。GUIベースで直感的に使えるのは良かったのですが、使い込むうちに様々な問題に直面しました。
テストのアサーションはJavaScriptで書けるものの、すべてのコードが巨大な一つのJSONファイルになってしまい、可読性や保守性が非常に悪くなります。また、複雑なテストのsetupやteardownを書くのが困難で、パラメータ化もiterationで可能ですが、プログラミング言語のように簡単で直感的にはできませんでした。
何より、RustでWebAPIを開発しているのに、テストだけ別の言語やツールを使うことに違和感がありました。同じ言語でAPIとテストの両方を書きたいというモチベーションが大きかったです。
2021年頃: Cargo + Tokio + Reqwest で自作
Postmanの限界を感じ、RustのエコシステムであるCargo、Tokio、Reqwestを使って単体テストの形式でend-to-endテストを書きました。これにより、複雑なテストロジックを実装できるようになりましたが、テストのパラメータ化やリトライ処理、環境毎にエンドポイントや変数を分ける仕組みをすべて自前で実装する必要がありました。
2024年頃: PlaywrightをAPIテストに使用
PlaywrightをAPIテスト用途で試してみました。レスポンスのスキーマ検査にはzodを使いました。Playwrightは評判通りとても使いやすくて良かったのですが、同じスキーマをRustとTypeScriptの両方で定義する必要があり、コードの重複が発生しました。
これらの経験から、RustでWebAPIテストに特化した軽量で使いやすいフレームワークが必要だと感じるようになりました。
他に検討した案
runnも検討しました(参考記事)。YAMLで言語agnosticにテストを記述できるアイデアはとても良いと思いましたが、複雑なテストを書くと結局YAMLでプログラミングすることになってしまうと思いました。それなら最初から純粋なプログラミング言語を使った方が、コード補完が効いたり、より柔軟にテストを記述できる思い採用しませんでした。
作っているWebAPIテストフレームワーク
現在開発中のフレームワークはtanu
と名付けました。tanuは筆者の愛犬tanukichiから取りました。テキトーです。
設計思想
tanuの設計にあたって、以下の点を重視しました:
- ⚙️ テスト実行ランタイム
tokio非同期ランタイム上でテストを実行する方式を採用しました。nextestのようなcargo test (libtest)を拡張する方式も検討しましたが、テストがバイナリに分かれるよりもtokioのタスクとして実行する方が柔軟に並列処理やリトライを実行できそうだったのでこちらにしました。 - 🍣 proc macroによるコード生成
#[tanu::test]
や#[tanu::main]
といったproc macroにより、最小限のボイラープレートでテストを記述できるようにしました。 - 🔧 RustエコシステムのGood Partsの組み合わせ
test-caseクレートやpretty_assertions、reqwest、color-eyre等のRustのテスト分野におけるGood partsを組み合わせ、また、一部は模倣し、Rust開発者が簡単にテストを書けるようにしました。 - 🖥️ 多様なインターフェース
複雑なコードなしにテストをCLI・TUIで実行可能な設計にしました。将来的にはGUIも検討しています。 - 💡 Playwrightからのインスピレーション
Playwrightのproject[2]コンセプトを参考にしつつ、より柔軟な設計を目指しました。Playwrightではサポートされていないproject毎に異なる変数を使用できるようにしたいと考えています。また、Playwrightのreporterのように出力を切り替えたり、pluginで拡張できるような仕組みも入れました。
インストール・使い方
cargo new your-api-tests
cd your-api-tests
cargo add tanu
cargo add tokio --features full
ボイラープレートはこれだけ
#[tanu::main]
#[tokio::main]
async fn main() -> tanu::eyre::Result<()> {
let runner = run();
let app = tanu::App::new();
app.run(runner).await?;
Ok(())
}
Hello world
async関数を#[tanu::test]
でアノテーションするだけで、テストとして認識されます。また、tanu::http::Client
はreqwestの薄いラッパーで、テストに必要なメトリクスを裏側で収集しながら、reqwestと同じコードでHTTPリクエストを簡単に送信できます。
use tanu::{check, eyre, http::Client};
#[tanu::test]
async fn get() -> eyre::Result<()> {
let http = Client::new();
let res = http.get("https://httpbin.org/get").send().await?;
check!(res.status().is_success());
Ok(())
}
パラメータ化テストで複数のテストケースを効率的に記述
#[tanu::test(200)]
#[tanu::test(404)]
#[tanu::test(500)]
async fn test_status_codes(expected_status: u16) -> eyre::Result<()> {
let client = Client::new();
let response = client
.get(&format!("https://httpbin.org/status/{expected_status}"))
.send()
.await?;
check_eq!(expected_status, response.status().as_u16());
Ok(())
}
宣言的な設定記述
テストに関する設定(リトライ、変数、フィルター)をTOMLで記述可能
[[projects]]
name = "default" # プロジェクト名
test_ignore = [] # テストをスキップするフィルター
retry.count = 0 # リトライ回数
retry.factor = 2.0 # バックオフ係数
retry.jitter = false # Jitterを有効にするか
retry.min_delay = "1s" # 最小遅延時間
retry.max_delay = "60s" # 最大遅延時間
foo = "bar" # プロジェクト変数
Project機能で複数環境へテスト実行
PlaywrightのProject[2:1]コンセプトを参考に、複数のプロジェクトを定義できます。これにより、異なる環境や設定でテストを実行できます。
[[projects]]
name = "dev" # プロジェクト名
test_ignore = [] # プロジェクトのテストをスキップするフィルター
base_url = "https://dev.example.com" # プロジェクト変数
foo = "bar" # プロジェクト変数
[[projects]]
name = "staging" # プロジェクト名
test_ignore = [] # プロジェクトのテストをスキップするフィルター
base_url = "https://staging.example.com" # プロジェクト変数
foo = "bar" # プロジェクト変数
[[projects]]
name = "production" # プロジェクト名
test_ignore = [] # プロジェクトのテストをスキップするフィルター
base_url = "https://production.example.com" # プロジェクト変数
foo = "bar" # プロジェクト変数
美しいバックトレース
color-eyreをデフォルトで使用し、エラー時のバックトレースをソースコードの行番号とともに美しく表示します。
eyreについては筆者のこちらの記事も参照ください。
CLIモード
TUIモード
TUIでテスト結果をリアルタイムに確認したり、リクエスト、レスポンスの詳細を確認できます。ヌルヌル動きます。
テストレポート (Allureとの連携)
Allureと連携して、テスト結果をHTML形式で詳細にレポートできます。
その他の特徴
- 細かいテスト実行制御: CLIでのテストのフィルタリングや、コンカレンシーの制御が可能
- anyhow/std Resultのサポート: eyre以外にもanyhowや標準のResultを使用したエラーハンドリングが可能
- Reporterでの出力制御: Reporterでは、テスト結果をJSONやHTMLなどの形式で出力可能
- Pluginでtanuを拡張: サードパーティがReporterプラグインを開発でき、tanuの出力結果を拡張できるようにしました。
ロードマップ
以下の機能を検討しています。
- gRPCとGraphQLのサポート: RESTFulなAPIだけでなく、gRPCやGraphQLのテストもサポートしたいと考えています。
-
reqwestからhyperへの移行: reqwestは便利ですが、実装がハイレベル過ぎて細かい制御が難しい場合があります。
tanu::http::Client
のインターフェイスは変えずに内部の実装をhyperに置き換えしたいと思います。 - 負荷テスト機能の追加: 現在のtanuは機能テストに特化していますが、パフォーマンステストも重要です。筆者は現在k6していますが、tanuで負荷テストも実行できたら便利だと思っています。
- テストダッシュボードの開発: テスト結果をもっと美しく視覚化するWebダッシュボードを開発したいと考えています。
- GUI機能の検討: 現在はCLIベースのツールですが、GUIが必要かどうか検討しています。開発者にとってはCLIやTUIの方が効率的ですが、QAエンジニアや非技術系のチームメンバーにとってはGUIの方が使いやすいかもしれません。
- TypeScript: Denoランタイムを組み込むことによって、TypeScriptで書かれたテストも実行できるようになるか試してみたいです。
おわりに
もしこのプロジェクトに興味を持たれた方がいらっしゃいましたら、ぜひGitHubリポジトリをチェックしてみてください。コントリビューションやフィードバックも大歓迎です!
もし、このプロジェクトをサポートしたい方がいましたら、Github Sponsorを検討してみてください。開発を継続するモチベーションになります。Sponsorの収益はもれなくtanuちゃんへのおやつ代になります。
-
Web APIテスト技法 - https://www.shoeisha.co.jp/book/detail/9784798179728 ↩︎
-
Playwright Projects - https://playwright.dev/docs/test-projects ↩︎ ↩︎
Discussion