paild tech blog

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

Rustで快適にテストしたいときに読む記事

こんにちは、お手伝いの大櫛です。

今回はRustのテスト体験を快適にするテスト系ライブラリ・ユーティリティをいくつか紹介します。

記事中、例としてコード片を示す箇所があります。今回はRust 1.73.0を用いていますが、今後のリリースにより動作が変わっている場合があります。また、各crateのバージョンについてはそれぞれの見出し直下に記載しています。

Fake

使用バージョン:v2.8.0

github.com

UUID生成処理やユーザーインプットを受け取る処理などにおいて、テスト時だけそれらの処理や値を差し替えるという手法はよく取られています。 それらを補助するcrateがFakeになります。見目にわかりやすい名前をしていますね。

例えば、UUIDv5をランダムに生成する場合は以下のようになります:

use fake::{uuid::UUIDv5, Fake};
use uuid::Uuid;

fn main() {
    let uuid: Uuid = UUIDv5.fake();
    println!("{uuid}");
}
// 出力例:922400ff-41d7-5a1f-a438-858d7800f34a

面白いものだと、日本人名もランダムに生成できます:

use fake::{faker::name::raw::Name, locales::JA_JP, Fake};

fn main() {
    let name: String = Name(JA_JP).fake();
    println!("{name}");
}
// 出力例:山下 湊翔

ここまでは他言語やエコシステムでも見たことのある機能ですが、Rust-specificな機能としてderive macroも用意されています。 ダミーの顧客を用意したいときは以下のように表現することができます:

use fake::{Dummy, Fake, Faker, faker::name::en::*};

#[derive(Debug, Dummy)]
pub struct Customer {
    #[dummy(faker = "0..u64::MAX")]
    pub id: u64,
    #[dummy(faker = "Name()")]
    pub name: String,
}

fn main() {
    let customer: Customer = Faker.fake();
    println!("{customer:?}");
}
// 出力例:Customer { id: 11826778090650625313, name: "Telly Gorczany" }

その他座標や住所、日時なども用意されています。日時についてはChronoやtime crateの型を使うこともできます。

他言語やエコシステムでもこの手のライブラリは結構用意されているので、それらとの差分を確認しつつ利用することになるのかな~という所感です。

mockito

使用バージョン:v1.2.0

github.com

テスト用の差し替えとして他にモックするという手段もあるでしょう。今回はHTTP serverのモックに特化したmockitoを紹介します。 恐らくmojito(モヒート)をもじったものだと思いますが、Java向けのフレームワークとして同名のものが存在しており、ちょっとググラビリティが低めです。

例として、reqwestのexampleを少しいじって渡されたURLにリクエストを送り、valueとしてあるはずのIPアドレスをパースして返す関数を用意します:

async fn get_ip(url: &str) -> Result<IpAddr, Box<dyn std::error::Error>> {
    let resp = reqwest::get(url)
        .await?
        .json::<HashMap<String, String>>()
        .await?;
    let addr = resp.get("origin");
    if let Some(ip) = addr {
        let ip: IpAddr = ip.parse()?;
        return Ok(ip);
    }
    Err("No IP address found".into())
}

get_ipのテスト時にhttpbinへ毎回リクエストすることを避けるため、ここをモック化してみましょう。

mockitoを使うと以下のようにテストできます:

async fn test_get_ip() {
    let mut server = mockito::Server::new();
    let url = server.url();

    let mock = server
        .mock("GET", "/ip")
        .with_status(200)
        .with_header("content-type", "application/json")
        .with_body("{ \"origin\": \"0.0.0.0\" }")
        .create();

    let result = get_ip(&format!("{url}/ip")).await;
    assert_eq!(result.unwrap(), IpAddr::from([0, 0, 0, 0]));

    mock.assert();
}

テストを実行すると期待通り 0.0.0.0 が返却されていることを確認できます:

running 1 test
test test_get_ip ... ok

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

mock.assert() できちんとモックが呼ばれているかをアサートできたり、エラーケースももちろんモックできます。結構便利。

ちなみに、汎用的なモックライブラリとしてmockallというcrateが存在します(例えばGoでいうとgomockに近いかも?)。 いわゆるレイヤードアーキテクチャの各層の振る舞いをモックしたいときにとても便利です。 日本語での解説記事もいくつか出ているのでここでは詳しく紹介しませんが、利用機会は多いと思いますのでこちらもお世話になることが多いはずです。

github.com

さらに余談ですが、モック化しやすい設計としてDIは切っても切り離せない概念です。 paild tech blogでは、過去にRustでのDIについて解説した記事もあるので参考にどうぞ!!(宣伝)

techblog.paild.co.jp

insta

使用バージョン:v1.34.0

github.com

次はスナップショットテスト用ライブラリ、instaのご紹介です。名前がおしゃん。 テスト自体はinstaをライブラリとしてimportすることで利用可能ですが、cargo subcommandとしてcargo-instaをinstallしておくとより幸せになれます。

cargo install cargo-insta --locked

以下のようにクールな関数を用意して、その出力をテスト対象としてみます。

fn cool_function() -> String {
    "something cool output".to_string()
}

#[test]
fn test() {
    insta::assert_debug_snapshot!(cool_function());
}

普通のテストと同じように cargo test でテストできます。 初回はもちろん正とするスナップショットを持っていないので失敗します。

test cool_test ... FAILED

failures:

---- cool_test stdout ----
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Snapshot Summary ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Snapshot file: src/snapshots/testing__cool_test.snap
Snapshot: cool_test
Source: src/main.rs:9
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Expression: cool_function()
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
+new results
────────────┬───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
          0 │+"something cool output"
────────────┴───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
To update snapshots run `cargo insta review`
Stopped on the first failure. Run `cargo insta test` to run all snapshots.
thread 'cool_test' panicked at /insta-1.34.0/src/runtime.rs:563:9:
snapshot assertion for 'cool_test' failed in line 9

今回はこの出力を期待したいので、 cargo insta review を通して保存してみます。

Reviewing [1/2] testing@0.1.0:
Snapshot file: src/snapshots/testing__cool_test.snap
Snapshot: cool_test
Source: src/main.rs:9
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Expression: cool_function()
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
+new results
────────────┬───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
          0 │+"something cool output"
────────────┴───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────

  a accept     keep the new snapshot
  r reject     reject the new snapshot
  s skip       keep both for now
  i hide info  toggles extended snapshot info
  d hide diff  toggle snapshot diff

ここでacceptを選べば出力を保存できます。その他場合に応じてrejectやskipなどを利用できます。

もう一度テストを実行してみると成功を確認できます:

running 1 test
test cool_test ... ok

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

この状態で出力を変更すると、diffとともに失敗を教えてくれます:

Snapshot file: src/snapshots/testing__cool_test.snap
Snapshot: cool_test
Source: src/main.rs:9
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Expression: cool_function()
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
-old snapshot
+new results
────────────┬───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
    0       │-"something cool output"
          0 │+"something weird output"
────────────┴───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────

また、VS Code用の拡張機能も用意されており、エディタ上からスナップショットのaccept/rejectなどを操作することも、スナップショットの差分を確認することもできます。至れり尽くせり。

insta用のVS Codeコマンド一覧
insta用のVS Codeコマンド一覧

スナップショットの差分確認
スナップショットの差分確認

Rustの利用範囲を拡大するにつれ、用意したいテストの幅も出てくると思います。 スナップショットテストを導入したい際にはぜひinstaをチェックしてみることをおすすめします。

まとめ

個人的に使い勝手がよいと思うテスト用crateをいくつか紹介しました。

Rustの採用を検討する際、他言語と比べて思うようにテストを用意できないというのは一つ大きなブロッカーに成り得ます。 色々な記事やドキュメントを参考にしつつ、皆様がハッピーなテストライフを送れますよう祈っております。