🐚

[Rust] 信じられるのは SQL だけ... そうだスナップショットしよう

2024/12/08に公開

ざっくりいうと

実行される SQL がわからないと(私は)不安なので、Insta という神クレートを使って SQL をスナップショットしましょう、という内容です。
Rust の記事ですが、他の言語でも同じ様なことはできると思います 🦀

例えば、従業員管理システムにおいて、以下の様な検索機能を考えるとします。

従業員(検索対象)

  • ID
  • 名前
  • 契約形態(正社員/アルバイト)
  • 所属部署 ID(企画部/情報システム部など)
    • アルバイトの場合はなし

検索項目(各項目は検索に使用しない場合もある)

  • 名前
  • 契約形態(正社員の場合は所属部署での検索も可能)

ざっくりコードにするとこんな感じです。

search.rs
/// 検索項目
/// 各項目が Some であれば検索に利用し、None であれば利用しない
struct SearchFilter {
    /// 名前
    name: Option<String>,
    /// 契約形態 
    contract_type: Option<SearchFilterContractType>
}

enum SearchFilterContractType {
    /// 正社員
    FullTime {
        // 部署での検索が可能(指定しなくても良い)
        department_id: Option<Uuid>
    },
    /// アルバイト
    PartTime
}

/// 検索機能
async fn search(filter: SearchFilter) -> Vec<ToDo> {
    // 実装
}

Rust は enum の各項目の値の型が一致しなくても良いので、比較的わかりやすい構造になりますね。
ただし、これを SQL にする場合は検索項目に応じて動的に SQL を組み立てる必要があるため少し複雑になります。

まずは DB の定義に合わせて SeaORM の Model を定義します。

employee.rs
/// 契約形態を表す列挙型
#[derive(Clone, Debug, PartialEq, Eq, DeriveActiveEnum, Serialize, Deserialize)]
#[sea_orm(active_enum = "ContractType")]
#[serde(rename_all = "PascalCase")]
pub enum ContractType {
    #[sea_orm(string_value = "Full-Time")]
    FullTime,
    #[sea_orm(string_value = "Part-Time")]
    PartTime,
}

/// 従業員テーブル(Employees)に対応するモデル
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "employees")]
pub struct Model {
    /// 一意の識別子
    #[sea_orm(primary_key, auto_increment=false)]
    pub id: Uuid,
    /// 従業員の名前
    pub name: String,
    /// 契約形態(正社員/アルバイト)
    pub contract_type: ContractType,
    /// 所属部署 ID(アルバイトの場合は NULL)
    pub department_id: Option<Uuid>,
}

また、実装はおおよそこの様になると思います。

search.rs
/// 検索項目
/// 各項目が Some であれば検索に利用し、None であれば利用しない
struct SearchFilter {
    name: Option<String>,
    contract_type: Option<SearchFilterEmploymentType>
}

type Employee = employee::Model;

/// 検索機能
async fn search(filter: SearchFilter) -> Vec<Employee> {
    let db: DatabaseConnection = Database::connect("protocol://username:password@host/database").await?;

    employee::Entity::find()
        // 名前の項目が Some であれば条件を追加している
        .apply_if(filter.name.as_ref(), |stmt, name| {
            stmt.filter(employee::Column::Name.eq(name))
        })
        // 契約形態の項目が Some であれば条件を追加している
        .apply_if(filter.contract_type.as_ref(), |stmt, contract_type| {
            match contract_type {
                SearchFilterContractType::FullTime { department_id } => {
                    let mut stmt = stmt
                        .filter(employee::Column::ContractType.eq(employee::ContractType::FullTime));
                    // 正社員で部署 ID が指定されている場合は条件を追加
                    if let Some(department_id) = department_id {
                        stmt = stmt.filter(employee::Column::DepartmentId.eq(department_id));
                    }
                    stmt
                }
                SearchFilterContractType::PartTime => {
                    stmt.filter(employee::Column::ContractType.eq(employee::ContractType::PartTime))
                }
            }
        })
        .all(&db)
        .await
        .unwrap()
}

この規模であれば処理を読めばどういう SQL が実行されるか想像ははつくかもしれませんが、実際にはさらに複雑な条件を扱うこともあります。
そのため本題になりますが「検索条件に対して期待する SQL が実行されるか」というのをスナップショットでテストすることで確認とデグレ対策を行います。

スナップショットテスト

主に利用するクレートの Insta はスナップショットテストを補助するクレートでかなり多機能です。
例えば、API の JSON レスポンスのスナップショットをしたい場合、テスト中で insta のマクロを呼び出すことで別のファイルにスナップショットファイルが作成されます。
次に実行した際にそのスナップショットファイルからの変更がなければテストが成功、変更があればテストが失敗します。

#[test]
fn test_snapshot() {
    let response = api::get_user().await;
    insta::assert_json_snapshot!(response.body);
}
test.snap
{
    "id": "123e4567-e89b-12d3-a456-426614174000",
    "email": "root@example.com",
    "is_admin": true
}

補助機能として、よく利用するものとしては Redactions / Filters があります。例えば、ID の様にテストのたびに変わる値をマスクしたい場合は以下の様にします。

#[test]
fn test_snapshot() {
    let response = api::get_users().await;
    insta::assert_json_snapshot!(response.body, {
        ".id" => "[uuid]"
    });
}
test.snap
{
    "id": "[uuid]",
    "email": "root@example.com",
    "is_admin": true
}

例のコードのスナップショットテスト

例に戻って、検索機能のスナップショットテストを行います。SQL のスナップショットの例は Insta のドキュメントにはありませんが、標準の insta::assert_snapshot! でスナップショットをすることが可能です。

どれくらいのパターンを網羅すべきかは方針によりますが、簡略のため今回は以下のパターンのみテストします。

  • 検索項目がない場合
  • 正社員で部署 ID を指定した場合
search.rs
/// 検索項目
/// 各項目が Some であれば検索に利用し、None であれば利用しない
struct SearchFilter {
    name: Option<String>,
    contract_type: Option<SearchFilterEmploymentType>
}

type Employee = employee::Model;

/// 検索機能
async fn search(filter: SearchFilter) -> Vec<Employee> {
    let db: DatabaseConnection = Database::connect("protocol://username:password@host/database").await?;

    search_stmt(filter).all(&db).await.unwrap()
}

/// 検索 SQL
/// クエリスナップショットテストのために切り出し
fn search_stmt(filter: SearchFilter) -> Select<Employee> {
    employee::Entity::find()
        // 名前の項目が Some であれば条件を追加している
        .apply_if(filter.name.as_ref(), |stmt, name| {
            stmt.filter(employee::Column::Name.eq(name))
        })
        // 契約形態の項目が Some であれば条件を追加している
        .apply_if(filter.contract_type.as_ref(), |stmt, contract_type| {
            match contract_type {
                SearchFilterContractType::FullTime { department_id } => {
                    let mut stmt = stmt
                        .filter(employee::Column::ContractType.eq(employee::ContractType::FullTime));
                    // 正社員で部署 ID が指定されている場合は条件を追加
                    if let Some(department_id) = department_id {
                        stmt = stmt.filter(employee::Column::DepartmentId.eq(department_id));
                    }
                    stmt
                }
                SearchFilterContractType::PartTime => {
                    stmt.filter(employee::Column::ContractType.eq(employee::ContractType::PartTime))
                }
            }
        })
}

#[cfg(test)]
mod query_snapshot_test {
    use super::*;

    #[test]
    fn test_検索条件がない場合() {
        let stmt = search_stmt(SearchFilter {
            name: None,
            contract_type: None,
        });
        insta::assert_snapshot!(stmt.build(DbBackend::Postgres).to_string()); // Postgres と仮定
    }

    #[test]
    fn test_正社員で部署idを指定した場合() {
        let stmt = search_stmt(SearchFilter {
            name: None,
            contract_type: Some(SearchFilterContractType::FullTime { department_id: Some(uuid::Uuid::new_v4()) }),
        });
        insta::with_settings!({
            // Filters 機能で UUID をマスク
            filters => vec![
                (r"([0-9a-f]{8})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{12})", "[uuid]")
            ]
        }, insta::assert_snapshot!(stmt.build(DbBackend::Postgres).to_string())); // Postgres と仮定
    }
}

以下のようなスナップショットが生成されることが期待されます。

test_検索条件がない場合.snap
SELECT * FROM employees;
test_正社員で部署idを指定した場合.snap
SELECT * FROM employees WHERE contract_type = 'Full-Time' AND department_id = '[uuid]';

Rust でさらに安心安全に開発できるちょっとしたテクニック紹介でした。少しでも参考にしていただければ幸いです。

Discussion