proptestを使うとテストが捗る

Drawer Growthグループ所属エンジニアの中野です。先日、採用候補者の方が「Rustを勉強する際にキャディのTech Blogにお世話になった」という話をして下さりとても嬉しかったのですが、最近Rustに関するTech Blogを執筆できていなかったので久しぶりに筆を取りました。

今回は「proptestをうまく使うとテストが捗り、ドメインモデルもキレイにできる」というテーマで書きます。

TL;DR

proptestの活用で、テストの見通しが良くなりレビューが捗ります。AIによるコード生成も相まって、コードのレビュー量が増える際にもテストの見通しの良さは大事になります。

また、proptestを用いてテストを書くことで、ドメインを表現する型を見直すきっかけを得ることができます。

proptestとは

proptest crateのREADMEによると、proptestはproperty testing frameworkです。property testingについてはWikipediaにまとめられています。要は「特定の入力と出力の一致を確認するのではなく、ランダムに生成した多くの入力に対してプログラムを実行し、常に成り立つべき「性質(プロパティ)」を検証するテスト手法」です。これにより、幅広いケースを網羅的にテストでき、実装の正しさを効率的に確認できます。@t_wadaさんもProperty-based Testing の位置づけというスライドで、「Known unknown」へのアプローチ手法としてproperty-based testingを紹介しています。

*ここまでにproperty testingとproperty-based testingという2つの表記が登場しました。今回の文脈では両方とも同じ意味で利用しているので、以下proptest crateのREADMEに合わせてproperty testingと記載します。

proptestを使うとどう嬉しいのか

1. テストの見通しが良くなる

私のチームが管理するコードベースでは、proptestをproperty testingだけでなくexample based testing(普段よく書くテスト)を書くためにも利用しています。

例として、以下の構造体とメソッドがあるとします。このメソッドに対して、proptestを用いたパターン、用いないパターンそれぞれでテストを書いてみます。

use std::num::NonZeroU32;

use chrono::{DateTime, FixedOffset, Utc};
use derive_getters::Getters;
use derive_new::new;
use proptest::strategy::{BoxedStrategy, Strategy};
use proptest_derive::Arbitrary;
use uuid::Uuid;

#[derive(Debug, PartialEq, Arbitrary, new)]
struct OrderId(#[proptest(value = "Uuid::new_v4()")] Uuid);

#[derive(Debug, PartialEq, Arbitrary, new)]
struct OrderDetailId(#[proptest(value = "Uuid::new_v4()")] Uuid);

#[derive(Clone, Debug, PartialEq, Arbitrary, Eq, new)]
struct ProductCode(String);

#[derive(Debug, PartialEq, Arbitrary, new)]
struct Quantity(NonZeroU32);

#[derive(Debug, PartialEq, Arbitrary, new)]
struct Price(u32);

#[derive(Debug, PartialEq, Eq)]
struct FixedOffsetDateTime(DateTime<FixedOffset>);

impl FixedOffsetDateTime {
    fn now() -> Self {
        let now = Utc::now().fixed_offset();
        Self(now)
    }

    fn parse_from_rfc3339(s: &str) -> Result<Self, String> {
        let date_time = DateTime::parse_from_rfc3339(s)
            .map_err(|_e| format!("Fail to parse FixedOffsetDateTime from {s}"))?;
        Ok(Self(date_time))
    }
}

// テストを書く際にFixedOffsetDateTimeの値を生成するための実装
impl proptest::arbitrary::Arbitrary for FixedOffsetDateTime {
    type Parameters = ();
    type Strategy = BoxedStrategy<Self>;

    fn arbitrary_with(_args: Self::Parameters) -> Self::Strategy {
        // from 1970-01-01 upto 2170-01-01
        catalyst_arbitrary::strategy::fixed_offset_date_time()
            .prop_map(FixedOffsetDateTime)
            .boxed()
    }
}

#[derive(Debug, Arbitrary, new)]
struct OrderDetail {
    id: OrderDetailId,
    product: ProductCode,
    quantity: Quantity,
    price: Price,
}

#[derive(Debug, Arbitrary, new)]
struct Order {
    id: OrderId,
    shipping_address: String,
    shipping_date: FixedOffsetDateTime,
    details: Vec<OrderDetail>,
}

impl Order {
    fn change_shipping_date(self, shipping_date: FixedOffsetDateTime) -> Result<Self, String> {
        let sum_of_quantity = self
            .details
            .iter()
            .fold(0, |acc, order_detail| acc + order_detail.quantity.0.get());

        if sum_of_quantity > 10 {
            return Err(format!(
                "Cannot change shipping_date because sum of quantity is {sum_of_quantity} which \
                 is over 10"
            ));
        }

        Ok(Self {
            shipping_date,
            ..self
        })
    }
}

これからchange_shipping_dateメソッドのテストを書いて行きます。

まずはproptestを利用した例です。Arrangeする際に、テスト対象に関連するquantityだけ値を渡して初期化しているので、スッキリと記述でき、何に対するテストが書かれているのか容易に把握できます。

fn any<A: proptest::prelude::Arbitrary>() -> A {
    use proptest::strategy::ValueTree;
    let runner = &mut proptest::test_runner::TestRunner::deterministic();
    proptest::prelude::any::<A>()
        .new_tree(runner)
        .unwrap()
        .current()
}

#[test]
    fn test_change_shipping_date_with_proptest() {
        let mut order = any::<Order>();
        order.details = vec![
            OrderDetail {
                quantity: Quantity(NonZeroU32::new(1).unwrap()),
                ..any::<OrderDetail>()
            },
            OrderDetail {
                quantity: Quantity(NonZeroU32::new(9).unwrap()),
                ..any::<OrderDetail>()
            },
        ];
        let new_shipping_date =
            FixedOffsetDateTime::parse_from_rfc3339("2025-03-09T00:00:00+09:00").unwrap();

        let result = order.change_shipping_date(new_shipping_date);

        assert_eq!(
            result.unwrap().shipping_date,
            FixedOffsetDateTime::parse_from_rfc3339("2025-03-09T00:00:00+09:00").unwrap()
        );
    }

上のコードではテストを書くためのヘルパー関数としてany関数を実装しています。私が所属するチームでは、こういったヘルパー関数をcrateに切り出して実装し、共通で利用できるようにしています。

次にproptestを利用しない例です。Arrangeのパートでテスト対象に直接関係ない値も渡して初期化する必要があるので、テストを読んだ人がどの値に注目すべきか分かりづらくなってしまいます。

#[test]
fn test_change_shipping_date_without_proptest() {
    // Arrange
    let order = Order::new(
        OrderId::new(Uuid::new_v4()),
        "shipping_address".to_string(),
        FixedOffsetDateTime::parse_from_rfc3339("2000-01-02T00:00:00+09:00").unwrap(),
        vec![
            OrderDetail::new(
                OrderDetailId::new(Uuid::new_v4()),
                ProductCode::new("product_code".to_string()),
                Quantity::new(NonZeroU32::new(1).unwrap()),
                Price::new(100),
            ),
            OrderDetail::new(
                OrderDetailId::new(Uuid::new_v4()),
                ProductCode::new("product_code".to_string()),
                Quantity::new(NonZeroU32::new(2).unwrap()),
                Price::new(100),
            ),
        ],
    );
    let new_shipping_date =
        FixedOffsetDateTime::parse_from_rfc3339("2025-03-09T00:00:00+09:00").unwrap();

    // Act
    let result = order.change_shipping_date(new_shipping_date);

    // Assert
    assert_eq!(
        result.unwrap().shipping_date,
        FixedOffsetDateTime::parse_from_rfc3339("2025-03-09T00:00:00+09:00").unwrap()
    );
}

2. ドメインを表現するための型定義を見直す機会を得ることができる

より複雑なテスト、例えばdomain_serviceのテストを書きたい場合等、型定義が甘い状態でproptestを用いたテストを書いてしまうと、意図しない箇所でテストが失敗してしまうことがあります。以下のようにProductCodeの型を厳密にするのではなく、OrderDetailの初期化メソッド内でProductCodeのバリデーションをしてしまっている場合を考えます。

#[derive(Clone, Debug, PartialEq, Arbitrary, Eq, new)]
struct ProductCode(String);

impl OrderDetail {
    fn new(
        id: OrderDetailId,
        product: ProductCode,
        quantity: Quantity,
        price: Price,
    ) -> Result<Self, String> {
        // productCodeはPR-から始まる文字列である必要がある
        if !product.0.starts_with("PR-") {
            return Err("product code must start with PR-".to_string());
        }
        Ok(Self {
            id,
            product,
            quantity,
            price,
        })
    }
}

proptestを用いてOrderDetailを生成した際にorder_detail.productの値は"hgoieagjeag"などの適当な文字列になります。そのため、後続の処理で再度OrderDetailを初期化しようとした際にエラーが発生する可能性があります。この問題が発生すると、以下のように型でProductCodeを表現したほうが良いことに気づく事ができます。

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ProductCode(String);

impl FromStr for ProductCode {
    type Err = String;
    fn from_str(s: &str) -> Result<Self, String> {
        let id = s
            .starts_with("PR-")
            .then(|| ProductCode(s.to_string()))
            .ok_or_else(|| "Invalid ProductCode format".to_string())?;
        Ok(id)
    }
}

proptestを利用してテストがきれいに書けないときは、型定義を見直すサインかもしれません。もちろん、すべての値をnew type patternで表現する必要はないです。しかし、本来厳密な型定義が必要である箇所に気づく機会をテストを書きながら得ることができるのは嬉しいポイントです。

上記のProductCodeに対してproptest::prelude::Arbitraryを実装するサンプルは以下です。

impl proptest::prelude::Arbitrary for ProductCode {
    type Parameters = ();
    type Strategy = BoxedStrategy<Self>;

    fn arbitrary_with(_args: Self::Parameters) -> Self::Strategy {
        const ALPHABET: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ123456789";
        let alphabet = ALPHABET.as_bytes().to_owned();
        proptest::collection::vec(
            proptest::prelude::any::<u8>()
                .prop_map(move |i| alphabet[i as usize % alphabet.len()] as char),
            12,
        )
        .prop_map(|id| {
            let id_string: String = id.into_iter().collect();
            ProductCode("PR-".to_string() + &id_string)
        })
        .boxed()
    }
}

まとめ

AIによって生成されるコードの量が増えるに従い、エンジニアは今までに以上にコードをレビューする必要が発生しそうです。その際に、テストの見通しが良いとレビューが楽に、正確になるのではないでしょうか?