🔥

Rustでテーブル変更に対して壊れにくいテストを書く

2024/09/26に公開

目的

少しの変更で壊れやすいテストのことをFragile Test(壊れやすいテスト)といいます。色々な理由で壊れやすいテストが生まれますが、ここではテーブル変更で壊れやすいテストについて考えます。

カラムを全部指定するようにテストを書いていると、カラムが追加されると、すべてのテストを書き直す必要がでてきます。なるべくカラムを書かないようにテストを作るのが重要になります。

テーブル

ユーザーの情報を格納するusersテーブルを作成します。

users

users.sql
CREATE TABLE public.users (
  uuid UUID NOT NULL
  ,user_name TEXT NOT NULL
  ,user_email TEXT NOT NULL
  ,PRIMARY KEY (uuid) 
);

壊れやすいテスト

コード

ユーザーテーブルにデータをinsertするコードは以下ようになります。カラムを引数で持っています。

users.rs
use tokio_postgres::Client;
use uuid::Uuid;

const INSERT_SQL: &str =
    "INSERT INTO public.users (uuid, user_name, user_email) VALUES ($1, $2, $3)";

pub struct Users {
    pub uuid: Uuid,
    pub user_name: String,
    pub user_email: String,
}

impl Users {
    pub async fn insert_columns(
        client: &Client,
        uuid: &Uuid,
        user_name: &str,
        user_email: &str,
    ) -> anyhow::Result<()> {
        client
            .execute(INSERT_SQL, &[&uuid, &user_name, &user_email])
            .await?;
        Ok(())
    }
}

テストコード

データを作るためにすべての引数に値を渡しています。

#[cfg(test)]
mod tests {
    use uuid::Uuid;
    use crate::postgres::users::Users;

    // cargo test test_users_columns1 -- --nocapture --test-threads=1
    #[tokio::test]
    async fn test_users_columns1() -> anyhow::Result<()> {
        let conn = setup().await?;
        Users::insert_columns(
            &conn,
            &Uuid::now_v7(),
            "taro",
            "taro@example.com",
            &UserKbn::Normal,
        )
        .await?;
        Ok(())
    }

    // cargo test test_users_columns2 -- --nocapture --test-threads=1
    #[tokio::test]
    async fn test_users_columns2() -> anyhow::Result<()> {
        let conn = setup().await?;
        Users::insert_columns(
            &conn,
            &Uuid::now_v7(),
            "taro",
            "taro@example.com",
            &UserKbn::Normal,
        )
        .await?;
        Users::insert_columns(
            &conn,
            &Uuid::now_v7(),
            "jiro",
            "jiro@example.com",
            &UserKbn::Admin,
        )
        .await?;
        Ok(())
    }
}

ここでカラムを追加するとします。するとinsert_columnsの引数が変わってしまうので、テストのコードがすべてコンパイルできなくなります。テストの数が多いと修正も大変です。

壊れにくいテスト(Default)

次に壊れにくいテストをDefaultを使ってかきます。insertの引数にDefaultを持ったUsersの引数を持つことで、カラムが追加されても引数の形がかわりません。

コード

users.rs
use tokio_postgres::Client;
use uuid::Uuid;

const INSERT_SQL: &str =
    "INSERT INTO public.users (uuid, user_name, user_email) VALUES ($1, $2, $3)";

pub struct Users {
    pub uuid: Uuid,
    pub user_name: String,
    pub user_email: String,
}

impl Default for Users {
    fn default() -> Self {
        Self {
            uuid: Uuid::now_v7(),
            user_name: "taro".to_owned(),
            user_email: "taro@example.com".to_owned(),
        }
    }
}

impl Users {
    pub async fn insert(client: &Client, params: &Users) -> anyhow::Result<()> {
        client
            .execute(
                INSERT_SQL,
                &[
                    &params.uuid,
                    &params.user_name,
                    &params.user_email,
                ],
            )
            .await?;
        Ok(())
    }
}

テストコード

#[cfg(test)]
mod tests {
    use uuid::Uuid;
    use crate::postgres::users::Users;

    // cargo test test_users_default1 -- --nocapture --test-threads=1
    #[tokio::test]
    async fn test_users_default1() -> anyhow::Result<()> {
        let conn = setup().await?;
        Users::insert(
            &conn,
            &Users {
                user_name: "taro".to_owned(),
                user_email: "taro@exeample.com".to_owned(),
                ..Default::default()
            },
        )
        .await?;
        Ok(())
    }

    // cargo test test_users_default2 -- --nocapture --test-threads=1
    #[tokio::test]
    async fn test_users_default2() -> anyhow::Result<()> {
        let conn = setup().await?;
        Users::insert(
            &conn,
            &Users {
                user_name: "taro".to_owned(),
                user_email: "taro@exeample.com".to_owned(),
                ..Default::default()
            },
        )
        .await?;
        Users::insert(
            &conn,
            &Users {
                user_name: "jiro".to_owned(),
                user_email: "jiro@exeample.com".to_owned(),
                ..Default::default()
            },
        )
        .await?;
        Ok(())
    }
}

これでカラムの追加などで壊れにくくなりました。しかしDefaultでは1つの値しか決めることができないです。状況によってはいくつかのデフォルト値があったり、他の値に応じてデフォルト値を変えたりできる柔軟性がほしいです。

壊れにくいテスト(Builder)

DefaultのかわりにBuilderを導入します。

コード

structにBuilderをderiveすることでUsersBuilderが作成されます。make関数でBuilderで未指定だった値にデフォルト値を設定しています。またメールは名前を参照して設定されます。

users.rs
use derive_builder::Builder;
use tokio_postgres::Client;
use uuid::Uuid;

const INSERT_SQL: &str =
    "INSERT INTO public.users (uuid, user_name, user_email) VALUES ($1, $2, $3)";

#[derive(Serialize, Deserialize, Debug, Clone, Builder, PartialEq, Eq)]
#[builder(setter(into))]
#[builder(default)]
#[builder(field(public))]
pub struct Users {
    pub uuid: Uuid,
    pub user_name: String,
    pub user_email: String,
}

impl Users {
    pub async fn insert(client: &Client, params: &Users) -> anyhow::Result<()> {
        client
            .execute(
                INSERT_SQL,
                &[
                    &params.uuid,
                    &params.user_name,
                    &params.user_email,
                ],
            )
            .await?;
        Ok(())
    }

    pub async fn make(client: &Client, builder: &mut UsersBuilder) -> anyhow::Result<()> {
        if builder.uuid.is_none() {
            builder.uuid = Some(Uuid::now_v7());
        }
        if builder.user_name.is_none() {
            builder.user_name = Some("taro".to_owned());
        }
        if builder.user_email.is_none() {
            builder.user_email = Some(format!(
                "{}@example.com",
                builder.user_name.as_ref().unwrap()
            ));
        }
        let params = builder.build()?;
        Self::insert(client, &params).await
    }
}

テストコード

#[cfg(test)]
mod tests {
    use crate::postgres::users::Users;

    // cargo test test_users_builder1 -- --nocapture --test-threads=1
    #[tokio::test]
    async fn test_users_builder1() -> anyhow::Result<()> {
        let conn = setup().await?;
        Users::make(&conn, &mut UsersBuilder::default().user_name("taro")).await?;
        let list = Users::select_all(&conn).await?;
        assert_eq!(list.len(), 1);
        Ok(())
    }

    // cargo test test_users_builder2 -- --nocapture --test-threads=1
    #[tokio::test]
    async fn test_users_builder2() -> anyhow::Result<()> {
        let conn = setup().await?;
        Users::make(&conn, &mut UsersBuilder::default().user_name("taro")).await?;
        Users::make(&conn, &mut UsersBuilder::default().user_name("jiro")).await?;
        let list = Users::select_all(&conn).await?;
        assert_eq!(list.len(), 2);
        Ok(())
    }
}

テストコードも記述量も減って見やすいです。
make関数は用途別に複数用意すれば、別のデフォルト値を設定することができます。

まとめ

テーブルのカラムが変更すると、テストでカラムを全部指定するようなコードを書いていると壊れやすいテストになります。DefaultやBuilderを使うことで壊れにくいテストを作ることができます。更にBuilderでは柔軟な初期化ができるので、Builderを使うのがおすすめです。

以下に動作するコードを置きました。enumを使ったもう少し複雑なコードになっています。
コード

Discussion