🧪

Rust | SQLx の macro `sqlx::test` でテストを実装する

2024/09/01に公開

SQLx とは

SQLx は Rust のデーベース接続用のクレートです。

SQLx の詳細や特徴については、いくつか記事を公開しているので割愛します。

参考までに...👨‍💻👨‍💻👨‍💻

sqlx::test について

テスト用のDBを作成してくれる

sqlx::test では、テスト実行時にテスト用のデータベースを作成し、
そのコネクションプールを引数として受け取ることができます🙆‍♂️

また、テスト終了時には作成されたデータベースを削除してくれます🙆‍♂️🙆‍♂️

テストしたゴミデータが残らない、とても便利な仕様と言えるでしょう。

なお、環境変数に DATABASE_URL を設定する必要があります。

以下のデータベースに対応しています。

データベース DATABASE_URL の有無
Postgres 必要
MySQL 必要
SQLite 不要[1]

fixtures 機能

fixtures でテスト実行時にあらかじめデータベースにレコードをインサートするためのクエリを実行することができます。

テストファイルと同じディレクトリに ./fixtures/users.sql のようにファイルを格納することで、fixtures を活用できます。

use sqlx::PgPool;

#[sqlx::test(fixtures("users", "posts"))]
async fn test_create_comment(pool: PgPool) -> sqlx::Result<()> {
    // Test
    Ok(())
}

sqlx::test でテストを実装する

以前実装した SQLx を使ったトランザクション実装のソースコード のテストを実装してみます。

Cargo.tml

Cargo.toml
[dependencies]
dotenvy = "0.15.7"
sqlx = { version = "0.8.1", features = ["postgres", "runtime-tokio", "chrono"] }
tokio = { version = "1.40.0", features = ["full"] }

main.rs

関数 create_order_slip のテストを test_create_order_slip として、
関数 create_order_slip_details のテストを test_create_order_slip_details として実装しています。

(トランザクションの関係で test_create_order_slip_details の中でも create_order_slip を実行してはいますが...)

main.rs
use sqlx::postgres::PgPoolOptions;
use sqlx::types::chrono::NaiveDateTime;
use sqlx::{PgPool, Postgres, Transaction};
use std::env;

#[tokio::main]
async fn main() {
    // 環境変数を読み取り、URLを生成
    dotenvy::dotenv().expect("Failed to read .env file");
    let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");

    // DBコネクションを生成
    let db = PgPoolOptions::new()
        .max_connections(10)
        .connect(&database_url)
        .await
        .unwrap_or_else(|_| panic!("Cannot connect to the database"));

    // トランザクションを生成する
    let mut tx = db.begin().await.expect("transaction error.");

    // 注文伝票データを作成する
    match create_order_slip(&mut tx, "注文書").await {
        Ok(os) => {
            // 明細行データを生成
            let details = vec![
                NewOrderSlipDetail {
                    order_slip_id: os.id,
                    product_name: "えんぴつ".to_string(),
                    product_count: 10,
                },
                NewOrderSlipDetail {
                    order_slip_id: os.id,
                    product_name: "消しゴム".to_string(),
                    product_count: 3,
                },
                NewOrderSlipDetail {
                    order_slip_id: os.id,
                    product_name: "ボールペン".to_string(),
                    product_count: 5,
                },
            ];

            // 注文伝票に紐づく明細行データを作成する
            match create_order_slip_details(&mut tx, details).await {
                Ok(_) => println!("done."),
                Err(e) => println!("{:?}", e),
            }
        }
        Err(e) => println!("{:?}", e),
    }

    // コミット
    // ここで rollback() を指定することも可能
    let _ = tx.commit().await.unwrap_or_else(|e| {
        println!("{:?}", e);
    });
}

#[derive(Debug)]
struct OrderSlip {
    id: i32,
    title: String,
    created_at: NaiveDateTime,
    updated_at: NaiveDateTime,
}

#[derive(Debug)]
struct OrderSlipDetail {
    id: i32,
    order_slip_id: i32,
    product_name: String,
    product_count: i32,
    created_at: NaiveDateTime,
    updated_at: NaiveDateTime,
}

struct NewOrderSlipDetail {
    order_slip_id: i32,
    product_name: String,
    product_count: i32,
}

async fn create_order_slip(
    tx: &mut Transaction<'_, Postgres>,
    title: &str,
) -> Result<OrderSlip, sqlx::Error> {
    sqlx::query_as!(
        OrderSlip,
        r#"
            insert into
                order_slips (title)
            values
                ($1)
            returning *
        "#,
        title.to_string()
    )
    .fetch_one(&mut **tx)
    .await
}

async fn create_order_slip_details(
    tx: &mut Transaction<'_, Postgres>,
    details: Vec<NewOrderSlipDetail>,
) -> Result<(), sqlx::Error> {
    for d in details {
        let _ = sqlx::query!(
            r#"
                insert into
                    order_slip_details (order_slip_id, product_name, product_count)
                values
                    ($1, $2, $3)
            "#,
            d.order_slip_id,
            d.product_name,
            d.product_count
        )
        .execute(&mut **tx)
        .await?;
    }

    Ok(())
}

#[sqlx::test()]
async fn test_create_order_slip(db: PgPool) {
    let mut tx = db.begin().await.expect("transaction error.");

    let created = create_order_slip(&mut tx, "[TEST] 注文書").await;
    println!("{:?}", created);

    let _ = tx.commit().await.unwrap_or_else(|e| {
        println!("{:?}", e);
    });

    assert_eq!(created.is_ok(), true);
}

#[sqlx::test()]
async fn test_create_order_slip_details(db: PgPool) {
    let mut tx = db.begin().await.expect("transaction error.");

    match create_order_slip(&mut tx, "[TEST] 注文書").await {
        Ok(os) => {
            // 明細行データを生成
            let details = vec![
                NewOrderSlipDetail {
                    order_slip_id: os.id,
                    product_name: "マッキー".to_string(),
                    product_count: 10,
                },
                NewOrderSlipDetail {
                    order_slip_id: os.id,
                    product_name: "色鉛筆".to_string(),
                    product_count: 3,
                },
                NewOrderSlipDetail {
                    order_slip_id: os.id,
                    product_name: "ノートブック".to_string(),
                    product_count: 5,
                },
            ];

            // 注文伝票に紐づく明細行データを作成する
            match create_order_slip_details(&mut tx, details).await {
                Ok(_) => println!("done."),
                Err(e) => println!("{:?}", e),
            }
        }
        Err(e) => println!("{:?}", e),
    }

    let _ = tx.commit().await.unwrap_or_else(|e| {
        println!("{:?}", e);
    });

    assert_eq!(true, true);
}

テストを実行してみます。

実行結果は以下です。

% cargo test -- --nocapture
running 2 tests
Ok(OrderSlip { id: 1, title: "[TEST] 注文書", created_at: 2024-09-01T11:53:35.916197, updated_at: 2024-09-01T11:53:35.916197 })
done.
test test_create_order_slip ... ok
test test_create_order_slip_details ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.61s

ID が 1 で、タイトルが [TEST] 注文書 であることが分かります。

また、何度実行しても同じ結果となることから、テスト時にデータベースが作成されており、ゴミデータが残っていないことも分かります!

fixtures を試す

fixtures を試すために、関数 get_order_slips とそのテストを追加で実装しました。

async fn get_order_slips(db: &PgPool) -> Vec<OrderSlip> {
    let selected = sqlx::query_as!(
        OrderSlip,
        r#"
            select
                *
            from
                order_slips
        "#,
    )
    .fetch_all(db)
    .await;

    selected.unwrap_or_else(|_| Vec::new())
}
#[sqlx::test(fixtures("order_slips"))]
async fn test_get_order_slips(db: PgPool) {
    let got = get_order_slips(&db).await;
    println!("{:?}", got);

    assert_eq!(got[0].title == "[fixtures] 注文書", true);
}

そして、fixtures フォルダを以下の構成で作成します。

src
├── fixtures
│   └── order_slips.sql
└── main.rs
order_slips.sql
insert into order_slips (title)
values
    ('[fixtures] 注文書');

テストの実行結果です。

% cargo test test_get_order_slips -- --nocapture
running 1 test
[OrderSlip { id: 1, title: "[fixtures] 注文書", created_at: 2024-09-01T11:54:14.750440, updated_at: 2024-09-01T11:54:14.750440 }]
test test_get_order_slips ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 2 filtered out; finished in 1.26s

order_slips.sql でインサートしたレコードが取得されていることが分かります!

ちなみに、#[sqlx::test()] の場合はレコードが存在しないため、パニックが発生します。
これは期待通りの結果ですね。

fixtures の配置について

今回は src 直下に fixtures を作成しましたが、
SQLx のサンプルコードを見ていると tests フォルダ内のテストで使用されていたため tests の直下に fixtures が作成されていました。

まとめ

データベースまわりのテストもしっかりと実装していきましょう🔥🔥

参考

脚注
  1. ドキュメント「SQLite defaults to target/sqlx/test-dbs/<path>.sqlite where <path> is the path of the test function converted to a filesystem path (:: replaced with /).」 と記載があります。 ↩︎

コラボスタイル Developers

Discussion