Rustは、わずか数百行で高速かつ型安全なHTTPサーバーを提供します。しかし、そのサーバーをテストするための高速なフィードバックループは提供されません。コンパイルサイクルは長く、単一のトレイト変更でcargo testはすべてを再実行します。また、ほとんどのRust HTTPフレームワークでは、一度も呼び出していないうちから各エンドポイントに対して個別の統合テストを書く必要があります。単なるバイナリではなくAPIを出荷したいのであれば、Rustツールチェインの外にあり、実行中のサーバーと通信できるツールが必要です。
このガイドでは、Apidog内での完全なRust APIテストワークフローを解説します。具体的には、AxumまたはActixサーバーにApidogを連携させ、エンドポイントに対するリクエストを構築し、SerdeでシリアライズされたJSONを検証し、JWT認証を処理し、ハンドラーを完成させる間にフロントエンドが作業を進められるようにエンドポイントをモック化し、そしてこれらすべてをCIテストシナリオとしてパッケージ化する方法です。最終的には、cargo build --releaseが完了する前に契約のずれを検出できる、再利用可能なApidogプロジェクトが手に入ります。
Postmanやcurlのワークフローから移行してきた場合、Apidogのデザインファースト機能も無料で利用できます。保存されたリクエストから生成されるOpenAPI仕様、共有可能なモックURL、およびチーム環境です。Postmanからの移行に関する話は別の記事として飛ばし、この記事ではRustに焦点を当てます。
TL;DR
- Rustサーバーをローカルで実行し(AxumまたはActix-webプロジェクトに対して
cargo run)、ベースURLhttp://localhost:3000をApidog環境として追加し、任意のシークレットを変数として保存します。 GET /healthzに対する最初のリクエストを作成し、保存し、そのフォルダ内のすべてのハンドラーで認証とベースURLを再利用します。- JSONエンドポイントの場合、Serde構造体をApidogのボディエディターに貼り付け、送信後に実行されるテストスクリプトでレスポンスの形状をアサートします。
- 保護されたルートの場合、JWTを一度発行し、
{{token}}として保存し、フォルダレベルでBearer認証を適用して、すべてのテストがそれを継承するようにします。 - Apidogで未完成のRustハンドラーをモック化することで、ハンドラーがクリーンにコンパイルされる前にフロントエンドチームが実際のレスポンスをレンダリングできるようにします。
- 作業セットをテストシナリオとして保存し、エクスポートして、
apidog-cliを使用してCIで実行します。これにより、ハンドラーに触れるすべてのPRはマージ前に契約チェックを受けるようになります。
Rustツールチェイン外でRust APIをテストする理由
cargo testは優れています。しかし、遅く、Rust以外のチームメイトには不透明で、HTTPではなくコードを中心に構築されています。ハンドラーが適切なステータスコード、適切なJSON形状、適切なヘッダー、そして入力が不正な場合に適切なエラーメッセージを返すことを検証したい場合、各ケースに対して新しいtower::ServiceExt::oneshot呼び出しを書くことになります。そして、ハンドラーの変更に合わせてそのテストを保守します。さらに、フロントエンドがモックをヒットできるように、JavaScriptでもう一度書くことになります。
Apidogは、実行中のサーバーの上に単一の契約レイヤーを提供します。リクエストは一度だけ作成されます。アサーションはリクエストの隣に存在します。フロントエンドのチームメイトは同じプロジェクトを開き、あなたと同じリクエストを見ることができます。今から3週間後にSerdeが#[serde(rename_all = "camelCase")]属性を持つようになったとき、壊れるテストはApidogのテストであり、本番に出荷されるテストではありません。
RustワークフローにApidogを追加する具体的な3つの理由:
- 契約チェックがビルドから切り離されます。 Apidogは実行中のバイナリに対して動作します。エンドポイントが
200を返し続けることを検証するためにrustcを待つ必要がなくなります。 - モックは共有可能です。 別のタイムゾーンにいるフロントエンド開発者は、「ハンドラーはまだ完成していません」というSlackメッセージではなく、適切なJSONを返すURLを受け取ることができます。
- 無料でOpenAPIを生成。 Apidogは、保存されたリクエストからOpenAPI 3.1ドキュメントを生成できます。これにより、すべてのルートに
utoipaやaideのアノテーションを書くことなく、型付きクライアントを必要とする誰にでもそれを渡すことができます。
ステップ1:RustサーバーをApidog環境として追加する
Rust APIを開始します。Axumプロジェクトの場合、ボイラープレートは次のとおりです。
use axum::{routing::get, Router};
use tokio::net::TcpListener;
#[tokio::main]
async fn main() {
let app = Router::new().route("/healthz", get(|| async { "ok" }));
let listener = TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
Apidogを開き、新しいプロジェクトを作成し、次に環境管理(右上ドロップダウン)を開いて、`Rust Local`という名前の環境を追加します。
| 変数 | 値 |
|---|---|
baseUrl |
http://localhost:3000 |
token |
今は空のままにする |
apiVersion |
v1 |
デプロイされたベースURLを持つRust Stagingという2番目の環境を追加します。Apidogは環境ごとに変数をスコープするため、ドロップダウンを1回クリックするだけでローカルからステージングに切り替えることができます。保存されたリクエストを検索して置換する必要はありません。
ステップ2:最初のエンドポイントを叩く
プロジェクト内にRust APIというフォルダを作成し、次に新しいリクエストを作成します。
- メソッド:
GET - URL:
{{baseUrl}}/healthz
送信をクリックします。サーバーが実行中であれば、ボディがokである200を受け取ります。これをhealth-checkとして保存します。これは最もシンプルなスモークテストであり、より興味深いものを書く前に環境とベースURLが機能することを確認します。
接続拒否エラーが発生した場合、サーバーが0.0.0.0にバインドされていないか、ポートが間違っています。RustのデフォルトであるTcpListener::bind("127.0.0.1:3000")は、別のインターフェース上のlocalhostに解決されるものからのリクエストを拒否します。ローカル開発のために0.0.0.0にバインドして、ApidogやDockerコンテナがそれに到達できるようにしてください。
ステップ3:Serdeを使用したJSONリクエストとレスポンスのテスト
最も一般的なRust APIの形状は、Serde構造体によって裏付けられたJSON入力、JSON出力のハンドラーです。POST /usersルートを追加します。
use axum::{extract::Json, routing::post, Router};
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
struct CreateUser {
name: String,
email: String,
}
#[derive(Serialize)]
struct User {
id: u64,
name: String,
email: String,
}
async fn create_user(Json(payload): Json<CreateUser>) -> Json<User> {
Json(User { id: 1, name: payload.name, email: payload.email })
}
let app = Router::new().route("/users", post(create_user));
Apidogで、リクエストを作成します。
- メソッド:
POST - URL:
{{baseUrl}}/users - ボディ (JSON):
{
"name": "Ada Lovelace",
"email": "ada@example.com"
}
送信します。User JSONが返されます。これをcreate-userとして保存します。
次に、「テスト」タブを開き、アサーションを追加します。
pm.test("Status is 200", () => {
pm.expect(pm.response.code).to.eql(200);
});
pm.test("Body has id, name, email", () => {
const body = pm.response.json();
pm.expect(body).to.have.property("id");
pm.expect(body.name).to.eql("Ada Lovelace");
pm.expect(body.email).to.match(/^[^@]+@[^@]+$/);
});
次回、誰かが構造体に#[serde(rename_all = "camelCase")]を追加し、レスポンスの形状がuser_idからuserIdに変わった場合、この変更がデプロイされる前にこのテストは失敗します。これがApidogが提供する契約であり、cargo testが提供しないものです。なぜなら、cargo testはRustコードをRust型に対して実行するため、どちらの形状でも喜んでパスするからです。
ステップ4:Serdeのリジェクションケースをカバーする
RustのJSON処理で興味深いのは、不正な入力に対してSerdeがどのように振る舞うかです。デフォルトでは、Axumは詳細なしの422 Unprocessable Entityを返します。意図的にスキーマを壊す3つのリクエストを作成します。
| リクエスト | ボディ | 期待される結果 |
|---|---|---|
create-user-missing-email |
{ "name": "Ada" } |
422、ボディにmissing field emailの記述 |
create-user-extra-field |
{ "name": "Ada", "email": "a@b.c", "admin": true } |
#[serde(deny_unknown_fields)]がない場合は200、そうでない場合は422 |
create-user-wrong-type |
{ "name": 1, "email": "a@b.c" } |
422、invalid type: integerの記述 |
テストで各ステータスコードをアサートします。これは、実際のバリデーションポリシーを文書化する最も安価な方法です。後でdeny_unknown_fieldsをオンにした場合、2番目のテストが赤くなり、公開契約が変更されたことを知らせてくれます。
ステップ5:JWTで保護されたルートをテストする
ほとんどのプロダクションRust APIは、認証ミドルウェアの背後にハンドラーを隠しています。Axumのaxum-extra JWTエクストラクターは一般的なパターンです。
use axum_extra::extract::cookie::PrivateCookieJar;
use jsonwebtoken::{decode, DecodingKey, Validation};
async fn me(jar: PrivateCookieJar) -> Result<Json<User>, StatusCode> {
let token = jar.get("token").ok_or(StatusCode::UNAUTHORIZED)?;
let claims = decode::<Claims>(token.value(), &DecodingKey::from_secret(b"secret"), &Validation::default())
.map_err(|_| StatusCode::UNAUTHORIZED)?;
Ok(Json(User { id: claims.claims.sub, name: "Ada".into(), email: "ada@example.com".into() }))
}
Apidogでは、テスト実行ごとにJWTを手作業で作成する必要はありません。フォルダに「プリリクエストスクリプト」を作成します。
const jwt = require("jsonwebtoken");
const token = jwt.sign(
{ sub: 1, exp: Math.floor(Date.now() / 1000) + 3600 },
"secret"
);
pm.environment.set("token", token);
フォルダ設定を開き、「認証」を「Bearerトークン」、値を{{token}}に設定します。これにより、フォルダ内のすべてのリクエストは新しいJWTに署名し、提示するようになります。古いトークンによるバグはテスト実行からなくなります。アサーション側に関するより詳細な手順については、APIでのJWT認証をテストする方法を参照してください。
ステップ6:ストリーミングとServer-Sent Eventsのテスト
Rustのウェブフレームワークはファーストクラスのストリーミング機能を持ちます。AxumのSseレスポンスはfutures::Streamをラップし、text/event-streamチャンクを出力します。ワイヤフォーマットは、接続のクローズまたは明示的な完了イベントによって終了する、フレームごとのdata: { ... }\n\nです。
これを消費するリクエストは任意のGETリクエストと同じように見えますが、Content-Typeがtext/event-streamの場合、Apidogのレスポンスパネルはストリーミングモードに切り替わります。各フレームが到着すると、タイムスタンプ付きで表示されます。これは、バックプレッシャーの問題やフラッシュの不足をデバッグする際に必要な表示です。
何をアサートするか:
- 最初のチャンクが宣伝しているSLA内に到着すること。Apidogはストリーミングパネルでチャンクごとのレイテンシを表示します。
- 接続が閉じる前に特定のイベント名が発行されること(例:
event: done)。 - ストリームの合計持続時間が制限されていること。
while let Some(event) = stream.next().awaitから抜け出すのを忘れたハンドラーは永遠にストリームを続けます。Apidogはこれを表示し、リクエスト設定でハードタイムアウトを設定してテストを失敗させることができます。
エンドポイントがSSEではなくWebSocketを使用する場合、Apidogには別のWebSocketリクエストタイプがあります。パターンは同じです。一度接続を構築し、メッセージシーケンスを保存し、レスポンスをアサートします。
ステップ7:並行フロントエンド開発のためにRust APIをモック化する
フロントエンドはRustのコンパイル時間にブロックされることはめったにありません。まだ存在しないハンドラーによってブロックされます。Apidogのモックを使用すると、ハンドラーがデプロイされる前に、あなたとフロントエンドが合意した契約を返す安定したURLを公開できます。
create-userを右クリックし、「スマートモック」を選択して有効にします。Apidogはhttps://mock.apidog.com/m1/<projectId>/usersで合成されたUserレスポンスを提供するようになります。モックボディは保存された例と一致します。モックURLは同じボディ形状を受け入れるため、フロントエンドは実際のRustサーバーであるかのようにそれに対してPOSTを実行できます。
動的なモックには、「高度なモック」に切り替えてスクリプトを記述します。
return {
id: Math.floor(Math.random() * 10000),
name: body.name,
email: body.email,
createdAt: new Date().toISOString()
};
このモックは、フロントエンドが送信するものに対して、生成されたidとタイムスタンプで応答します。Rustハンドラーが準備できたら、フロントエンドはそのベースURLをhttp://localhost:3000に戻すだけで、他には何も変更はありません。このパターンについては、チームはSpring Boot APIの構築とテストおよび一般的なAPIテストワークフローもカバーしています。考え方は同じですが、ランタイムが異なります。
ステップ8:CIテストシナリオとして保存する
Apidogのテストシナリオは、共有変数を持つリクエストを連鎖させ、ヘッドレスで実行します。シナリオを構築します。
health-check、200をアサート。create-user、200をアサートし、body.idを変数にキャプチャ。create-user-missing-email、422をアサート。me(JWTプリリクエスト付き)、200をアサートし、返されたidがキャプチャされたidと一致することをアサート。- SSEリクエスト、ストリームが5秒以内に完了することをアサート。
シナリオをJSONとしてエクスポートし、tests/apidog/の下にリポジトリにコミットし、CIから呼び出します。
- name: Run API contract tests
run: |
cargo build --release
./target/release/myserver &
sleep 2
apidog-cli run tests/apidog/contract.json --env "Rust Local"
ハンドラーに触れるすべてのPRは、完全な契約スイートを持つライブRustバイナリに対して実行されるようになります。Serdeのリネーム、ステータスコードの変更、またはJWT検証の調整が公開形状を壊した場合でも、マージボタンが緑色になる前にCIがそれを検出します。
ステップ9:保存されたリクエストからOpenAPIを生成する
リクエストセットが安定したら、Apidogのエクスポートメニューを開き、OpenAPI 3.1を選択します。送信したボディを例として含む、保存されたすべてのリクエストをカバーする仕様ドキュメントが生成されます。これを型付きクライアント(TypeScript、Swift、Kotlin、Python)を生成する誰にでも渡せば、彼らは6ヶ月前に手書きされた.yamlではなく、今日のRustサーバーが返すものと一致する契約を手に入れることができます。
仕様をRustリポジトリにチェックインしたい場合は、CIからapidog-cli exportを実行し、それをopenapi.jsonに書き込みます。次のcargo buildは変更されませんが、APIのすべての利用者はディスク上の真実を手に入れます。
FAQ
ApidogはAxumとActix-webの両方で動作しますか? はい。ApidogはHTTPを扱います。Rustではありません。リクエストに応答するものであれば何でも(Axum、Actix-web、Rocket、Warp、Poem、Loco)同じように動作します。唯一のRust固有の考慮事項は、ローカルテストのために127.0.0.1ではなく0.0.0.0にバインドすることです。
パニックを起こすハンドラーはどのようにテストしますか? ルーターの前にtower-httpのCatchPanicLayerを使用してサーバーを実行します。パニックはJSONボディを含む500に変換されます。パニックパスをトリガーするApidogリクエストを構築し、500をアサートします。パニックをラップしない場合、接続が切断され、Apidogはネットワークエラーを報告します。これも有効な契約テストです。
Docker内のRustバイナリに対してApidogを実行できますか? はい。baseUrlをコンテナの公開ポートに向ければ完了です。コンテナがDocker Compose内で実行されている場合、Apidogランナーに同じネットワークを与えるか、ホストのマッピングされたポートを使用してください。
gRPCについてはどうですか? ApidogにはgRPCリクエストタイプがあります。.protoファイルをインポートし、サービスとメソッドを選択し、リクエストペイロードを入力して送信します。認証、環境、テストシナリオのパターンはRESTと同一です。
テストシナリオはcargo testを置き換えますか? いいえ。Rustコードの単体テストはRust内に残ります。Apidogは実行中の表面、つまりHTTP契約をテストします。これら2つの層は異なるバグを検出します。単体テストは壊れた関数を検出し、Apidogテストは壊れたレスポンス形状、不足しているCORSヘッダー、または400が422になったなどの問題を発見します。両方が必要です。
ApidogはRustのオープンソースプロジェクトで無料ですか? はい。Apidogクライアントは個人および小規模チーム向けには無料です。テストシナリオ、モック、OpenAPIエクスポートは無料ティアの一部です。公開Rust APIを保守している場合、リポジトリにプロジェクトファイルを同梱することで、クローンした誰もがテストスイートを利用できます。
まとめ
Rust APIには、コンパイラーを待たないフィードバックループが必要です。Apidogのリクエストコレクションは、実際のHTTP、実際のアサーション、フロントエンド用の実際のモック、そしてライブバイナリに対して実行されるCIシナリオというループを提供します。上記のリクエストを一度作成すれば、AxumまたはActixハンドラーに対する将来のすべての変更は、実行時の予期せぬ事態ではなく、制御されたテスト実行になります。
Apidogをダウンロードし、Rustサーバーに向けてください。セットアップは10分もかかりません。その見返りとして、cargoから切り離された、自分で管理できる契約と、「ハンドラーはいつ完成しますか」と聞かなくなるフロントエンドチームが得られます。
