Rustでメンテしやすいテストを書く

17 min read読了の目安(約16100字

背景

「面倒だからテストを書きたくない」となってしまうのをどうにかしたいと思ったのがきっかけです。
なぜ、面倒になってしまうのかを考えたところ、書いていくうちに似たようなコードが至るところに
増殖していき、修正するときに手を付けられなくなって放置してしまうパターンがどうやら多いようです。

なので、最初のうちから「こういう重複が発生したらこう対処する」というのを決めておくことで
改善できるのではないかと思い、記事にまとめて頭の中を整理してみることにしました。

お題

Developer eXperience Day 2021でt_wadaさんが発表した、
「テストコードのリファクタリングが目指すもの」を題材にRustで書いていきます。
(なぜRustにしたかというと、勉強したい言語であったことと、Rustでテストを扱った記事が
少なかったためです)

https://dxd2021.cto-a.org/program/time-table/a-1

整数閉区間を示すクラス(や構造体)を作りたい。
整数閉区間は下端点と上端点を持ち、整数閉区間の文字列表記を返せる
(例: 下端点3、上端点7の整数閉区間の文字列表記は"[3, 7]")
また、整数閉区間は指定した整数を含むかどうかを判定できる。

環境

rustc 1.54.0-nightly
rustup 1.24.1
cargo 1.53.0-nightly
テスト名に日本語を使用したりするので1.53以降にしています。
nightlyへの切り替えは↓こちらを参考にしました。
rustupでrustをセットアップ

最終形

時間が惜しいという方のために、最終形を先に示してしまいます。

tests/int_closed_range_test.rs
#[cfg(test)]
extern crate speculate;
extern crate rstest;

use speculate::speculate;
use rstest::*;
use int_close_range::IntClosedRange;

speculate! {
    describe "IntClosedRangeは整数閉区間を表す" {
        #[fixture(lower=3, upper=7)]
        fn fixture(lower: i32, upper: i32) -> IntClosedRange {
            IntClosedRange::new(lower, upper)
        }
        describe "IntClosedRangeは下端点と上端点を持つ" {
            #[rstest]
            fn lowerメソッドは整数閉区間の下端点を返す(fixture: IntClosedRange) {
                println!("整数閉区間が[3, 7]のとき、下端点は3である");
                assert_eq!(3, fixture.lower());
            }

            #[rstest]
            fn upperメソッドは整数閉区間の上端点を返す(fixture: IntClosedRange) {
                println!("整数閉区間が[3, 7]のとき、上端点は7である");
                assert_eq!(7, fixture.upper());
            }
        }
        describe "IntClosedRangeは整数閉区間の文字列表記を返す" {
            #[rstest(range, expected,
                case(fixture(3, 7), "[3, 7]"),
                case(fixture(-2, 3), "[-2, 3]")
            )]
            fn notationメソッドは整数閉区間の文字列表記を返す(range: IntClosedRange, expected: String) {
                println!("下端点が{}、上端点が{}のとき、文字列表記は{}である", range.lower(), range.upper(), expected);
                assert_eq!(expected, range.notation());
            }
        }
        describe "IntClosedRangeは指定した整数を含むか判定できる" {
            #[rstest(arg, expected,
                case(1, false),
                case(3, true),
                case(5, true),
                case(7, true),
                case(9, false)
            )]
            fn includesメソッドは指定した値が区間内に含まれるか判定しboolを返す(fixture: IntClosedRange, arg: i32, expected: bool) {
                println!("整数閉区間[3, 7]において、includes({}) == {}", arg, expected);
                assert_eq!(expected, fixture.includes(arg));
            }
        }
    }
}
src/int_closed_range.rs
pub mod int_close_range {

    pub struct IntClosedRange {
        lower: i32,
        upper: i32,
    }

    impl IntClosedRange {
        pub fn new(lower: i32, upper: i32) -> Self {
            Self{lower, upper}
        }
        pub fn lower(&self) -> i32 {
            self.lower
        }
        pub fn upper(&self) -> i32 {
            self.upper
        }
        pub fn notation(&self) -> String {
            format!("[{}, {}]", self.lower(), self.upper())
        }
        pub fn includes(&self, arg: i32) -> bool {
            self.lower <= arg && arg <= self.upper
        }
    }
}

準備

Rustは標準でテストフレームワークが用意されていますが、動画で説明されているような
記述ができないため、外部のクレートを利用します。

Cargo.toml
[dev-dependencies]
speculate = "*"
rstest = "*"

speculateはテストコード全体の構造を記述するため、
rstestはパラメタライズドテストを実現するのに使用しています。
参考:
rstest
Rustでパラメーター化テスト
speculate.rs
Rustでテストするならspeculate.rsを使うといいかもしれない

ToDoをテストコードに書く

動画では、まずはじめに具体的なテストコードを書ききってから構造化などのリファクタリングをしていますが、
実務でそれをやると、途中でダレてしまいそうなので、ある程度先を見越して、修正量が少なくなるよう
書いていきます。

tests/int_closed_range_test.rs
#[cfg(test)]
extern crate speculate;
extern crate rstest;

use speculate::speculate;
use rstest::*;

speculate! {
    describe "IntClosedRangeは整数閉区間を表す" {
        describe "IntClosedRangeは下端点と上端点を持つ" {}
        describe "IntClosedRangeは整数閉区間の文字列表記を返す" {}
        describe "IntClosedRangeは指定した整数を含むか判定できる" {}
    }
}

speculateを使用しておおまかな構造を最初に作ってしまいます。
describeでテストをグループ化できるので、これでToDoを記述し、該当するテストを中に書いていきます。
最初にこのように書いておくことで、テストコードが何を確認するために書いたものかが
分かりやすくなります。

最初のテスト

動画の流れにしたがって、下端点のテストを最初に書いてみます。

int_closed_range_test.rs
speculate! {
    describe "IntClosedRangeは整数閉区間を表す" {
        describe "IntClosedRangeは下端点と上端点を持つ" {
	    #[rstest]
            fn lower_and_upper() {
                assert_eq!(3, range.lower());
	}
        describe "IntClosedRangeは整数閉区間の文字列表記を返す" {}
        describe "IntClosedRangeは指定した整数を含むか判定できる" {}
    }
}

テストコードはrstestを使って書いていきます。
speculateでも書けますが、パラメタライズドテストを書くときにrstestを使用するので、
最初からこちらを使って書くと割り切ることにします。
この状態で cargo test を実行すると、もちろんコンパイルで失敗します。

error[E0425]: cannot find value `range` in this scope
  --> tests/int_closed_range.rs:13:31
   |
13 |                 assert_eq!(3, range.lower());
   |                               ^^^^^ not found in this scope

コンパイルエラーに導かれながらここまで書いてみました↓

tests/int_closed_range_test.rs
#[cfg(test)]
extern crate speculate;
extern crate rstest;

use speculate::speculate;
use rstest::*;
use int_close_range::IntClosedRange;

speculate! {
    describe "IntClosedRangeは整数閉区間を表す" {
        describe "IntClosedRangeは下端点と上端点を持つ" {
            #[rstest]
            fn lower_and_upper() {
                let range = IntClosedRange::new(3);
                assert_eq!(3, range.lower());
            }
        }
        describe "IntClosedRangeは整数閉区間の文字列表記を返す" {}
        describe "IntClosedRangeは指定した整数を含むか判定できる" {}
    }
}
src/int_closed_range.rs
pub mod int_close_range {

    pub struct IntClosedRange {
        lower: i32,
    }

    impl IntClosedRange {
        pub fn new(lower: i32) -> Self {
            Self{lower}
        }
        pub fn lower(&self) -> i32 {
            self.lower
        }
    }
}
src/lib.rs
mod int_closed_range;

pub use int_closed_range::int_close_range::IntClosedRange;

説明が抜けていましたが、今回の例ではパッケージはライブラリで作成し、プロダクトコードと
テストコードは別ファイルで作成しています(別ファイルでテストを書くケースを想定して)
別ファイルに分けた場合のファイル構成などはTRPLのこのあたりに説明を任せることにします。

同様にupperのテストも書いていきます。

tests/int_closed_range_test.rs
fn lower_and_upper() {
	let range = IntClosedRange::new(3, 7);
	assert_eq!(3, range.lower());
	assert_eq!(7, range.upper());
}
src/int_closed_range.rs
pub struct IntClosedRange {
        lower: i32,
        upper: i32,
}

impl IntClosedRange {
	pub fn new(lower: i32, upper: i32) -> Self {
	    Self{lower, upper}
	}
	pub fn lower(&self) -> i32 {
	    self.lower
	}
	pub fn upper(&self) -> i32 {
	    self.upper
	}
}

assertionルーレットの排除

動画とは順序が変わりますが、ひとつのテストにassertionが複数あるのは避けたいので、
テストを分割しましょう。
あとから見てメンテナンスのモチベを落としそうな要素は早急に取り除きます
(もしかして、私のテストコード汚すぎ?って少しでも感じてしまうと触りたくなくなってしまいますから)

tests/int_closed_range_test.rs
describe "IntClosedRangeは下端点と上端点を持つ" {
    #[rstest]
    fn lower() {
	let range = IntClosedRange::new(3, 7);
	assert_eq!(3, range.lower());
    }
    #[rstest]
    fn upper() {
	let range = IntClosedRange::new(3, 7);
	assert_eq!(7, range.upper());
    }
}

fixtureを使って重複を排除

lowerとupperのテストで同じコードがあるため、fixtureを使って重複を取り除きます。

tests/int_closed_range_test.rs
describe "IntClosedRangeは下端点と上端点を持つ" {
    #[fixture]
    fn fixture() -> IntClosedRange {
	IntClosedRange::new(3,7)
    }

    #[rstest]
    fn lower(fixture: IntClosedRange) {
	assert_eq!(3, fixture.lower());
    }

    #[rstest]
    fn upper(fixture: IntClosedRange) {
	assert_eq!(7, fixture.upper());
    }
}

今回の場合はコードが短いため恩恵が小さいのですが、fixtureを使うと複数のテストで使い回すことが
できるので、重複が多い場合には効率よくテストを書けるようになります。

文字列表記のテスト

続けて、文字列表記のテストを書いてみます。
fixtureを使えばラクに書けそうですが、スコープが「IntClosedRangeは下端点と上端点を持つ」に
限定されてしまっているので位置を変更しましょう。

tests/int_closed_range_test.rs
describe "IntClosedRangeは整数閉区間を表す" {
#[fixture]
fn fixture() -> IntClosedRange {
    IntClosedRange::new(3,7)
}
describe "IntClosedRangeは下端点と上端点を持つ" {
    #[rstest]
    fn lower(fixture: IntClosedRange) {
	assert_eq!(3, fixture.lower());
    }

    #[rstest]
    fn upper(fixture: IntClosedRange) {
	assert_eq!(7, fixture.upper());
    }
}
describe "IntClosedRangeは整数閉区間の文字列表記を返す" {
    #[rstest]
    fn notation(fixture: IntClosedRange) {
	assert_eq!("[3, 7]", fixture.notation());
    }
}

動画では一度仮実装をして、別パターンのテストを作ることで正しい実装に直しているのに対し、
この記事では仮実装は飛ばして実装まで持っていってます。
他のパターンでも正しく動作するか心配なのでテストケースを追加して確認してみましょう。

fixtureとパラメタライズドテスト

下端点-2、上端点3でも正しく変換されるかテストします。
しかし、fixtureでは下端点3,上端点7が与えられているので、fixtureはそのままでは使えません。
[3, 7]のテストをしつつ、[-2, 3]のテストも実施したい、このようなケースではパラメタライズドテストにします。

まず、fixtureを修正する

fixtureの下端点と上端点の値が固定されているので異なる値も入れられるように修正します。

tests/int_closed_range_test.rs
#[fixture(lower=3, upper=7)]
fn fixture(lower: i32, upper: i32) -> IntClosedRange {
    IntClosedRange::new(lower, upper)
}

このように記載すると、デフォルトは3と7が入り、↓のようにテストを書くことで異なる値を
入れることもできるようになります。

#[rstest(fixture(-2, 3))]
fn test(fixture: IntClosedRange) {
}

パラメタライズドテスト

今回の場合は[3, 7]のテストもしたいので↑をそのまま使うことはできず、パラメタライズドテストと
組み合わせることになります。

tests/int_closed_range_test.rs
describe "IntClosedRangeは整数閉区間の文字列表記を返す" {
    #[rstest(range, expected,
	case(fixture(3, 7), "[3, 7]"),
	case(fixture(-2, 3), "[-2, 3]")
    )]
    fn notation(range: IntClosedRange, expected: String) {
	assert_eq!(expected, range.notation());
    }
}

↑の場合、rangeにfixtureで生成されたIntClosedRangeインスタンスが入り、
expectedに期待している文字列が入ります。
テストを実行すると

test speculate_0::IntClosedRangeは整数閉区間を表す::IntClosedRangeは整数閉区間の文字列表記を返す::notation::case_2 ... ok
test speculate_0::IntClosedRangeは整数閉区間を表す::IntClosedRangeは整数閉区間の文字列表記を返す::notation::case_1 ... ok

こんな感じで2パターンでテストが行われます。

指定した整数が含まれるかの判定

次は指定した値が整数閉区間に含まれるかどうかを判定するテストです。

tests/int_closed_range_test.rs
describe "IntClosedRangeは指定した整数を含むか判定できる" {
    #[rstest]
    fn includes(fixture: IntClosedRange) {
	assert!(fixture.includes(5));
    }
}

fixtureで用意された[3, 7]の整数閉区間において5が含まれるかどうかをテストします。
メソッドの実装はこんなかんじです。

src/int_closed_range.rs
pub fn includes(&self, arg: i32) -> bool {
    self.lower <= arg && arg <= self.upper
}

5だけ判定しても不十分なので、こちらもパラメタライズドテストにしてテストケースを追加しましょう。

tests/int_closed_range_test.rs
describe "IntClosedRangeは指定した整数を含むか判定できる" {
    #[rstest(arg, expected,
	case(1, false),
	case(3, true),
	case(5, true),
	case(7, true),
	case(9, false)
    )]
    fn includes(fixture: IntClosedRange, arg: i32, expected: bool) {
	assert_eq!(expected, fixture.includes(arg));
    }
}

これで一通り実装できました。これで終わりでも良いのですが・・・

running 9 tests
test speculate_0::IntClosedRangeは整数閉区間を表す::IntClosedRangeは下端点と上端点を持つ::lower ... ok
test speculate_0::IntClosedRangeは整数閉区間を表す::IntClosedRangeは指定した整数を含むか判定できる::includes::case_3 ... ok
test speculate_0::IntClosedRangeは整数閉区間を表す::IntClosedRangeは指定した整数を含むか判定できる::includes::case_2 ... ok
test speculate_0::IntClosedRangeは整数閉区間を表す::IntClosedRangeは指定した整数を含むか判定できる::includes::case_1 ... ok
test speculate_0::IntClosedRangeは整数閉区間を表す::IntClosedRangeは指定した整数を含むか判定できる::includes::case_5 ... ok
test speculate_0::IntClosedRangeは整数閉区間を表す::IntClosedRangeは指定した整数を含むか判定できる::includes::case_4 ... ok
test speculate_0::IntClosedRangeは整数閉区間を表す::IntClosedRangeは下端点と上端点を持つ::upper ... ok
test speculate_0::IntClosedRangeは整数閉区間を表す::IntClosedRangeは整数閉区間の文字列表記を返す::notation::case_1 ... ok
test speculate_0::IntClosedRangeは整数閉区間を表す::IntClosedRangeは整数閉区間の文字列表記を返す::notation::case_2 ... ok

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

テストを実行結果から、どんな値でテストしたのかが読み取れないのがイマイチです。

テストコード内での仕様の記述とテスト内容の表示

JunitのDisplayNameに相当するものはないのでprintlnで頑張ります。
また、各テスト名を仕様が分かるような記述に変更し、完成です。

tests/int_closed_range_test.rs
speculate! {
    describe "IntClosedRangeは整数閉区間を表す" {
        #[fixture(lower=3, upper=7)]
        fn fixture(lower: i32, upper: i32) -> IntClosedRange {
            IntClosedRange::new(lower, upper)
        }
        describe "IntClosedRangeは下端点と上端点を持つ" {
            #[rstest]
            fn lowerメソッドは整数閉区間の下端点を返す(fixture: IntClosedRange) {
                println!("整数閉区間が[3, 7]のとき、下端点は3である");
                assert_eq!(3, fixture.lower());
            }

            #[rstest]
            fn upperメソッドは整数閉区間の上端点を返す(fixture: IntClosedRange) {
                println!("整数閉区間が[3, 7]のとき、上端点は7である");
                assert_eq!(7, fixture.upper());
            }
        }
        describe "IntClosedRangeは整数閉区間の文字列表記を返す" {
            #[rstest(range, expected,
                case(fixture(3, 7), "[3, 7]"),
                case(fixture(-2, 3), "[-2, 3]")
            )]
            fn notationメソッドは整数閉区間の文字列表記を返す(range: IntClosedRange, expected: String) {
                println!("下端点が{}、上端点が{}のとき、文字列表記は{}である", range.lower(), range.upper(), expected);
                assert_eq!(expected, range.notation());
            }
        }
        describe "IntClosedRangeは指定した整数を含むか判定できる" {
            #[rstest(arg, expected,
                case(1, false),
                case(3, true),
                case(5, true),
                case(7, true),
                case(9, false)
            )]
            fn includesメソッドは指定した値が区間内に含まれるか判定しboolを返す(fixture: IntClosedRange, arg: i32, expected: bool) {
                println!("整数閉区間[3, 7]において、includes({}) == {}", arg, expected);
                assert_eq!(expected, fixture.includes(arg));
            }
        }
    }
}

cargo testコマンドでprintlnを表示するには、下記のようにオプションを付けて実行します。

$ cargo test -- --nocapture
   Compiling int_close_range v0.1.0 (/home/yo-kuma/program/rust/int_close_range)
    Finished test [unoptimized + debuginfo] target(s) in 2.91s
     Running unittests (target/debug/deps/int_close_range-c4d359c032313a96)

running 0 tests

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

     Running tests/int_closed_range.rs (target/debug/deps/int_closed_range-06d112a34fd54ae6)

running 9 tests
-------------- TEST START --------------
整数閉区間[3, 7]において、includes(3) == true
test speculate_0::IntClosedRangeは整数閉区間を表す::IntClosedRangeは指定した整数を含むか判定できる::includesメソッドは指定した値が区間内に含まれるか判定しboolを返す::case_2 ... ok
-------------- TEST START --------------
整数閉区間[3, 7]において、includes(1) == false
test speculate_0::IntClosedRangeは整数閉区間を表す::IntClosedRangeは指定した整数を含むか判定できる::includesメソッドは指定した値が区間内に含まれるか判定しboolを返す::case_1 ... ok
-------------- TEST START --------------
整数閉区間が[3, 7]のとき、上端点は7である
test speculate_0::IntClosedRangeは整数閉区間を表す::IntClosedRangeは下端点と上端点を持つ::upperメソッドは整数閉区間の上端点を返す ... ok
-------------- TEST START --------------
整数閉区間[3, 7]において、includes(5) == true
test speculate_0::IntClosedRangeは整数閉区間を表す::IntClosedRangeは指定した整数を含むか判定できる::includesメソッドは指定した値が区間内に含まれるか判定しboolを返す::case_3 ... ok
-------------- TEST START --------------
整数閉区間[3, 7]において、includes(7) == true
test speculate_0::IntClosedRangeは整数閉区間を表す::IntClosedRangeは指定した整数を含むか判定できる::includesメソッドは指定した値が区間内に含まれるか判定しboolを返す::case_4 ... ok
-------------- TEST START --------------
整数閉区間が[3, 7]のとき、下端点は3である
test speculate_0::IntClosedRangeは整数閉区間を表す::IntClosedRangeは下端点と上端点を持つ::lowerメソッドは整数閉区間の下端点を返す ... ok
-------------- TEST START --------------
整数閉区間[3, 7]において、includes(9) == false
test speculate_0::IntClosedRangeは整数閉区間を表す::IntClosedRangeは指定した整数を含むか判定できる::includesメソッドは指定した値が区間内に含まれるか判定しboolを返す::case_5 ... ok
-------------- TEST START --------------
下端点が3、上端点が7のとき、文字列表記は[3, 7]である
test speculate_0::IntClosedRangeは整数閉区間を表す::IntClosedRangeは整数閉区間の文字列表記を返す::notationメソッドは整数閉区間の文字列表記を返す::case_1 ... ok
-------------- TEST START --------------
下端点が-2、上端点が3のとき、文字列表記は[-2, 3]である
test speculate_0::IntClosedRangeは整数閉区間を表す::IntClosedRangeは整数閉区間の文字列表記を返す::notationメソッドは整数閉区間の文字列表記を返す::case_2 ... ok

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

まとめ

Rustの外部クレートを使ったテストコードの書き方に触れつつ、できるだけ重複のないように
テストコードを書いてみました。
重複が出たら早々に消すようにしたため、練習している最中は「コピペが増えて途中でダレる」
ということもなく書き進められたと思います(コードの規模が小さいからかもしれませんが)

今後

今回作成したテストコードは果たして本当にメンテしやすいのかを、
要件を追加して修正していくことで確認します。
どんな修正が発生するか、どのような流れでコードを書き換えていけばストレスなく進められるかを
記事にまとめていきます。