SQLxでテスト実行時にDBのデータを管理する

RDBをデータストアとして使うWebアプリケーション(のバックエンド)の開発で、テストを通じてDBに作成したレコードを自動で削除できると、開発環境のDBが汚れず、また他のテストによって変更されたDBの状態に影響されないので便利。Rustではこれをどうやるのかを調べていた。

SQLxのsqlx::test

SQLxには0.6.1からsqlx::testというattribute macroが追加されている1。これを使うと、テスト実行時に次のような機能を利用できる。

  • テスト実行時にマイグレーションを実行する
  • テスト実行、終了時にテスト用DBを作成、削除する
  • テスト実行時にフィクスチャをDBに投入する

SQLxのリポジトリのexamplesに、簡単なREST APIのテストにおけるsqlx::testのわかりやすい利用例が置いてあるので、使いかたを知りたければこれを見るとよい。

https://github.com/launchbadge/sqlx/tree/main/examples/postgres/axum-social-with-tests

次のような流れで使う。

  • 非同期のテスト関数に#[sqlx::test]を付与する
    • #[sqlx::test]#[tokio::test] のように非同期関数をテストとして実行できる機能もある
  • #[sqlx::test]の引数としてfixturesでテスト実行時にあらかじめDBにレコードを投入するためのクエリを実行する
    • テストファイルと同じディレクトリにfixtures/users.sqlのようなファイルを置く
  • テスト関数は引数としてsqlxが管理するコネクションプールを受け取る
    • このプールを通じてDBに書き込んだレコードはテスト終了時にDBごと削除される
// examples/postgres/axum-social-with-tests/tests/post.rs から引用

#[sqlx::test(fixtures("users"))]
async fn test_create_post(db: PgPool) {
    let mut app = http::app(db);

    // Happy path!
    let mut resp1 = app
        .borrow_mut()
        .oneshot(Request::post("/v1/post").json(json! {
            {
                "auth": {
                    "username": "alice",
                    "password": "rustacean since 2015"
                },
                "content": "This new computer is blazing fast!"
            }
        }))
        .await
        .unwrap();

    assert_eq!(resp1.status(), StatusCode::OK);

    let resp1_json = response_json(&mut resp1).await;

    assert_eq!(resp1_json["username"], "alice");
    assert_eq!(resp1_json["content"], "This new computer is blazing fast!");

    // ...
}

sqlx::testをいつ使うか

sqlx::testはおおむね便利そうだが、テスト関数ごとにDBを作成、フィクスチャ投入、削除を実行する2。テストが増えると速度は落ちそう。

その点を踏まえたWebアプリのテストでの使いどころは、やはり先に述べたようなエンドポイントをテストする場合といえそう。このようなテストは、テストサイズの観点における中テスト (Medium test)として、他のプロセスで動くRDBも利用しつつ、1つのエンドポイントが期待通り動くかを確認するのに役立つ。一方で、もう少し細かいモジュールのテストは、オンメモリだけで完結したり、外部依存があればモックを使って実行するような小テスト (Small test)としてテストすることが多い。エンドポイントに対する主要なテストだけを中テストに任せれば、DB自体の作成と削除が走る回数を相対的に少なく抑えることはできそうだった。

sqlx::testを使うアプリの設計

テストのしやすい設計という観点では、[sqlx::test]を付与した関数に渡るコネクションプールをアプリの実体(axum::Routerとか)に注入できるようにする必要がある。これもAxumなどを使ってアプリを作るときは、アプリに具体的なコネクションプールやリポジトリを注入して、axum::extract::Stateのようなハンドラから使える状態に持たせておけば問題ないはず。イメージとしては次のようなコード。

// アプリの状態
#[derive(Clone)]
pub struct AppState {
    // ハンドラ間でなんらかのリポジトリを共有する
    pub repository: Arc<Repository>,
}

// アプリ本体
pub fn app(repository: Repository) -> Router {
    let state = AppState {
        repository: Arc::new(repository),
    };

    // axum::Router
    Router::new()
        .route("/products/:id", get(get_product))
        .with_state(state)
}

// ハンドラ
pub async fn get_product(
    State(state): State<AppState>,
    Path(id): Path<u32>,
) -> impl IntoResponse {
    // 状態からリポジトリを取得してアプリ固有のロジックを実行する
    let products_app = ProductApp::new(state.repository.clone());
    
    // ...
}

// 引数にsqlx::PgPoolを受けるテスト関数
#[sqlx::test(fixtures("products"))]
async fn get_product(pool: PgPool) {
    // コネクションプールをもとにリポジトリを初期化できるようにしておいて、
    // SQLxのテスト用コネクションプールでリポジトリを初期化する
    let repository = Repository::new(pool);
    let app = app(repository);

    let response = app
        .oneshot(
            Request::builder()
                .uri("/products/1")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();

    // ...
}

  1. migrate featureを有効にする必要がある
  2. たぶん実装は https://github.com/launchbadge/sqlx/blob/v0.7.1/sqlx-core/src/testing/mod.rs#L201-L230 のあたり