paild tech blog

法人カードのクラウド型発行・管理サービスpaild

rstestを使いこなす

お手伝いの@helloyukiです。パラメータ化テストをする際使えるrstestというクレートがあるのですが、このクレートが意外にいろいろなことができて感動したので記事にします。実務でどう使っているかもあわせて説明します。

rstestとは

rstestがどのような課題を解決するであろうクレートであるかと、もっとも簡単なユースケースを紹介します。

問題意識

テストを書いていると、ある関数に対して与える値をわずかに変えながら、似たようなテストを書くことがあると思います。たとえば閏年を判定するためのテストを素のRustで書こうとすると次のようになるかもしれません。このテストでは、年号と閏年か平年かのペアを持つベクターを定義し、それをforループで回すことで記述量を減らしています。

#[derive(Debug, Eq, PartialEq)]
enum Year {
    // 平年
    Common,
    // 閏年
    Leap,
}

fn year_kind(year: u32) -> Year {
    if year % 4 == 0 && year % 100 != 0 || year % 400 == 0 {
        Year::Leap
    } else {
        Year::Common
    }
}

#[cfg(test)]
mod tests {
    use rstest::rstest;

    use crate::{year_kind, Year};

    #[test]
    fn test_year_kind_normaly() {
        let expectations = vec![
            (2000, Year::Leap),
            (2024, Year::Leap),
            (2025, Year::Common),
            (2100, Year::Common),
        ];
        for (year, kind) in expectations {
            assert_eq!(year_kind(year), kind);
        }
    }
}

これでも確かに悪くはないのですが、forループを使用すると、たとえば何番目のassertionでテストが落ちたのか分かりにくくなるというデメリットがあります。テスト一般で言われる話として、テスト用の関数はできる限り小さく作り、関数の中に含まれるassertionの数もひとつ程度に抑えるというものがあると思います。

これは理想論ではありますが、このようにテストコードはできるだけ単純なものにしておき、いざテストが失敗したときに原因の切り分けをしやすいよう、テストコードには問題がないことをまずは示したいでしょう。コード本体に問題があるのだ、というのを特定しやすい状態にしておく必要がある、ということです。これを行っておくとメンテナンス性の高いテストコードになります。たとえばテストコード内におけるforループの使用は、きちんと計画的に記述しないとどのタイミングでテストが落ちたのかわかりにくくなる傾向にあります。可能であればテストケースを分けたいものです。

パラメータテスト

これを解消するのがrstestです。rstestはいわゆるテストフィクスチャ (test fixture) とパラメータテスト (parameterized testing)を提供するクレートです。ちなみにですが、フィクスチャというのは、そのテストを成立させるために必要となる前提条件のことを言います。

rstestで上述のテストを書き直すと次のようになります。

#[cfg(test)]
mod tests {
    use rstest::rstest;

    use crate::{year_kind, Year};

    #[rstest]
    #[test]
    #[case(2000, Year::Leap)]
    #[case(2024, Year::Leap)]
    #[case(2025, Year::Common)]
    #[case(2100, Year::Common)]
    fn test_year_kind(#[case] year: u32, #[case] kind: Year) {
        assert_eq!(year_kind(year), kind);
    }
}

#[rstest]というマクロはrstestがもつ機能群を利用するためのものです。#[case(...)]というマクロにより、どのような値をテスト用の関数に対して与えるかを定義できます。テスト用の関数でのパラメータ値の受け取りは、引数に#[case]というマクロを記述しつつ行うことができます。

rstestは裏でマクロを展開し、それぞれのテストケースに対して専用の関数を生成します。これにより、forループを使用したテストと比較すると、どのassertionが落ちたかを関数名から瞬時に知ることができるようになります。forループを利用したテストで問題であった、どのassertionが落ちているか瞬時にはわかりにくいという問題はこれによって解消されます。

normalyは一つの関数の中ですべてのassertionが行われるが、rstestを用いたものはテストケース分の関数が生成されていることがわかる

ちなみにですが、自動生成されるテストケースは「case_1」「case_2」のような自動連番がデフォルトですが、自分で名前をつけることもできます。

#[cfg(test)]
mod tests {
    use rstest::rstest;

    use crate::{year_kind, Year};

    #[rstest]
    #[test]
    #[case::divided_by_100_and_400(2000, Year::Leap)]
    #[case::simply_divided_by_4(2024, Year::Leap)]
    #[case::not_divided_by_4(2025, Year::Common)]
    #[case::not_divided_by_400_and_divided_by_100(2100, Year::Common)]
    fn test_year_kind_named(#[case] year: u32, #[case] kind: Year) {
        assert_eq!(year_kind(year), kind);
    }
}

テストケース関数に名前をつけることもできる

rstestの機能紹介

ほとんどの話はドキュメントに載っていますので、詳細はドキュメントを見るとよいです。駆け足で機能紹介をします。

Value Lists

このテストは要するに、指定したふたつの値のすべての組み合わせをテストしてくれるものです。たとえば、A = 1, 2B = A, B, Cというふたつのパタメータがあった場合、(1, A), (1, B), (1, C), (2, A), (2, B), (2, C)という集合を生成し、すべてのケースをテストします。#[case]で記述するとまあまあな手間になりますが、これを回避できるというわけです。

たとえば入力フォームに入力された内容について簡単にバリデーションチェックをかける実装を用意したとしましょう。テスト用の関数に入力された値はすべて正当で、さらにすべての組み合わせに対して試したいものとします。適当な例ですが次のように実装できます。

struct InputForm {
    name: String,
    inquiry: String,
}

fn validate(input: &InputForm) -> bool {
    input.name.len() > 0 && input.inquiry.len() > 0
}

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

    #[rstest]
    fn should_accepct_all_corner_cases(
        #[values("customer", "complainer")] name: String,
        #[values(
            "I'm afraid but could you explain why my luggage didn't get delivered on schedule?",
            "Hey, what's going on my luggage!"
        )]
        inquiry: String,
    ) {
        assert!(validate(&InputForm { name, inquiry }))
    }
}

実行してみると、個別のテストケースがそれぞれ生成されていることがわかります。

File Path

ファイルパスを渡すこともできます。たとえばjsonでテスト用のinputなりoutputなりを用意していてそれを読み込み、assertionにかけたい場合などに便利です。特定のディレクトリにあるテストケースすべてを読み込んでくれる機能です。

下記はfilesというディレクトリ配下に1.json2.jsonというファイルを用意し、テスト関数にそれらを読み込ませている例です。

#[cfg(test)]
mod tests {
    use std::{fs::File, path::PathBuf};

    use rstest::rstest;

    #[rstest]
    fn should_read_file(#[files("files/*.json")] path: PathBuf) {
        assert!(File::open(path).is_ok());
    }
}

ファイル分だけテストケースが生成されていることがわかります。

Fixtureの使用

Fixtureのコアなアイディアは、テストに必要な依存関係の投入です。たとえば、データベースに接続するテストが仮にあったとして、データベース接続に必要な接続情報をセットアップしたものを投入する例などが考えられます。

#[fixture]というマクロでFixtureとして利用する関数を定義し、それを本体のテスト側で呼び出すことで利用できます。関数名とテスト用の関数の引数名が一致すると、そこに設定済みの値を流し込む実装になっているようです。

下記は簡単にFixtureを利用して実装してみた例です。複数テスト間で依存関係を解決し切った状態で同じ情報を各テストに分配できるようになるため、非常に便利です。

use std::net::Ipv6Addr;

struct Config {
    host: Ipv6Addr,
    port: u16,
    database: String,
    username: String,
    password: String,
}

struct Connection;

struct DB {
    conn: Connection,
}

impl DB {
    fn new(config: Config) -> Result<Self, &'static str> {
        Ok(Self {
            conn: Self::connect(config)?,
        })
    }

    fn connect(_config: Config) -> Result<Connection, &'static str> {
        Ok(Connection)
    }
}

#[cfg(test)]
mod tests {
    use std::net::Ipv6Addr;

    use rstest::{fixture, rstest};

    use crate::DB;

    #[fixture]
    fn db() -> DB {
        let config = super::Config {
            host: Ipv6Addr::LOCALHOST,
            port: 5432,
            database: "mydb".to_string(),
            username: "myuser".to_string(),
            password: "mypass".to_string(),
        };
        DB::new(config).unwrap()
    }

    #[rstest]
    fn should_connect_database(db: DB) {
        // データベースを使ったテスト
    }

    // その他のテストでも同様にDBを利用できる
}

他のプログラミング言語で行うようなbefore(テスト前の準備)/after(テスト後の後片付け)の設定は、この#[fixture]とFixtureに渡す構造体にDropトレイトを実装することで、擬似的に実現できるのではないかと私は考えています。というか、実務上はそういう使い方をしています。たとえばデータベースの場合、最後にテーブルを丸ごと片付けさせたい場合、次のように追加でDropトレイトを実装しています。[*1]

impl Drop for DB {
    fn drop(&mut self) {
        // テーブルの削除など
    }
}

なお、データベース接続のように一旦一度つないでおけば十分、というケースにおいては、#[once]というアトリビュート#[fixture]に対して追加できます。これを使うと、テスト実行時に一度だけFixtureの関数が呼び出されて保持され、その後はその返り値への参照を通じてアクセスすることにより、初期化を一度だけに限定できるようです。

// ...

    #[fixture]
    #[once]
    fn db() -> DB {
        let config = super::Config {
            host: Ipv6Addr::LOCALHOST,
            port: 5432,
            database: "mydb".to_string(),
            username: "myuser".to_string(),
            password: "mypass".to_string(),
        };
        DB::new(config).unwrap()
    }

// ...

    #[rstest]
    fn should_connect_database(db: &DB) {
        // データベースを使ったテスト
    }

// ...

tokio::testとの組み合わせ

asyncを使うようなテストもrstestで対応できます。近年はほとんどtokio上で動作するクレートを利用することが多いので、tokio::testに絞った話を書きます。

#[rstest]マクロ自体は、#[tokio::test]のマクロと併用可能です。したがって、次のように記述するだけでasync関連のテストを記述できます。一点注意ですが、マクロの解決には順序関係がある都合、#[rstest]#[tokio::test]の順でアトリビュートを並べなければなりません。

// 逆にするとコンパイルエラーになる。
#[rstest]
#[tokio::test]
async fn test() { ... }

#[fixture]も同様にasync化できます。Fixtureを上手にasync化するには若干のコツがいるので、少しだけサンプルコードを交えながら解説します。データベースを模した構造体を返すFixtureを考えてみます。関数自体はasyncとしていますが、実際の中身は普通の同期処理です。下記のように書くことができます。

#[cfg(test)]
mod tests {
    use rstest::{fixture, rstest};
    use std::collections::HashMap;

    #[derive(Eq, PartialEq, Hash, Clone)]
    struct ArticleId(String);

    struct Article {
        id: ArticleId,
        title: String,
        body: String,
    }

    struct Database {
        articles: HashMap<ArticleId, Article>,
    }

    #[fixture]
    async fn database() -> Database {
        let mut articles = HashMap::new();
        let article1 = Article {
            id: ArticleId("1".to_string()),
            title: "First article".to_string(),
            body: "First article body".to_string(),
        };
        let article2 = Article {
            id: ArticleId("2".to_string()),
            title: "Second article".to_string(),
            body: "Second article body".to_string(),
        };
        let article3 = Article {
            id: ArticleId("3".to_string()),
            title: "Third article".to_string(),
            body: "Third article body".to_string(),
        };
        articles.insert(article1.id.clone(), article1);
        articles.insert(article2.id.clone(), article2);
        articles.insert(article3.id.clone(), article3);
        Database { articles }
    }

    #[rstest]
    #[tokio::test]
    async fn test_get_articles(#[future(awt)] database: Database) {
        assert_eq!(
            database
                .articles
                .get(&ArticleId("1".to_string()))
                .unwrap()
                .title,
            "First article"
        );
    }
}

一点注意点として、#[future(awt)] database: Databaseという引数が、テスト本体の関数に追加されています。Fixtureそれ自体は、関数名と引数名が一致しさえすれば勝手に呼び出されるのですが、.awaitを行った状態でFixtureの値を取り出したい場合、#[future(awt)]というアトリビュートをテスト本体の関数につけてやることで、値を取り出しできます。

サンプルコード

github.com

参考

*1:推奨される手段であるのかはちょっと自信がありませんが、テストコードの中なので大丈夫かなと思って使っています。