[Rust]テストで設計を駆動する

25 min read読了の目安(約23000字

はじめに

前回の記事「Rustでメンテしやすいテストを書く」でテストを書きながらコードを書いてみました。
今回は、前回のお題から少し要件を追加して、テストコードおよびプロダクトコードを
リファクタリングしてみようと思います。
前回と同様、途中でコピペが増えてメンテナンスが面倒にならないようにするにはどのタイミングで
どのような対処をするべきかを考えます。

前回は言語は違うものの、お手本があったのでそれなりにキレイにコードを書けていますが、
今回はお手本がないため、あまり良くない書き方が含まれている可能性があります。
もし、「ここはこういう書き方のほうがいいよ!」という箇所がありましたら、ご指摘いただけますと幸いです。

要件の追加

前回は閉区間に限定していましたが、開区間にも対応してみましょう。
要件は以下のとおりです。

  • 整数閉区間と同様、下端点と上端点を指定して生成すること
  • 整数閉区間と同様、文字列表記を返す機能を持つこと
    (例:下端点3、上端点7の場合(3, 7))
  • 指定した整数が含まれるかどうかを判定する機能を持つこと

開区間のテストを書く?

では、整数開区間のテストを書いていきましょう、と言いたいところですが、一旦落ち着きましょう。
リリースまでに時間がないからと言って、すぐに書き始め、後で面倒なことになっては
これまでと何も変わりません。

これからどのようなテストを書くことになるか想像してみます。
要件で書かれていることをもう一度読んでみると、「閉区間と同様〜」という言葉が何度も出てきます。
つまりこれは、テストコードも同様の内容になりうるということです。
これはとても面倒なことです。ここでテストコードのコピペを許すと、
将来、半開区間などが出てきた時にさらにコピペが発生することになります。
そのあとにどれかのメソッドの仕様が変更になったとなると、影響範囲が広がり、
テストコードは放置されることになるでしょう。
それは由々しき事態です。なんとしても避けねばなりません。

コピペをせずに閉区間と開区間をそれぞれテストする方法を考えましょう。
これはプロダクトコードを書くのと同様、それなりに発想力が必要となります。
こうした姿勢が身につけばテストコードを書くのも楽しめるようになるかもしれません。
また、こうした考えがドメイン知識を深めることに繋がるかもしれません。
それこそ、まさに「テスト駆動」と呼べる取り組みなのではないかと、ふと思ったわけです。

どのような状態を持つか整理する

今回の要件追加で区間はどのような状態になりうるかを整理してみます。
区間は

  • 点には「上端」と「下端」がある
  • 点は「開いているか」「閉じているか」のどちらかの状態を持つ

つまり、上端と下端がそれぞれ点の開閉を表現してくれさえすれば、区間が閉区間であるか、
開区間であるかを気にする必要はない、ということです。
しかも、こうすることで後から半開区間などが出てきたとしても気にすることはなくなります。

面倒なことをやりたくない一心で必死に考えた結果、将来を見据えた新たなドメイン知識に
辿り着くという、これまでにない経験を得た瞬間です(おめでとう!!ありがとう!!)

上端点と下端点についてToDoを作る

では、上端点と下端点はどのように実装していけば良いか、ToDoにまとめてみます。

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

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

speculate! {
    describe "下端点Lowerは区間における最小値を表す" {
        describe "Lowerは自身が閉じているか開いているかの情報を持つ" {}
        describe "Lowerは自身の文字列表記を返す" {}
        describe "Lowerは指定した整数が自身より大きいかどうか判定できる" {}
    }
    describe "上端点Upperは区間における最大値を表す" {
        describe " Upperは自身が閉じているか開いているかの情報を持つ" {}
        describe "Upperは自身の文字列表記を返す" {}
        describe "Upperは指定した整数が自身より大きいかどうか判定できる" {}
    }
}

このようにまとめてみました。下端点と上端点でテスト内容が重複しそうですが、
それは書いている最中に修正できそうであれば直していきましょう。

下端点のテスト

点が閉じているか開いているかのテスト

tests/int_closed_range_test.rs
describe "下端点Lowerは区間における最小値を表す" {
    describe "Lowerは自身が閉じているか開いているかの情報を持つ" {
        #[rstest]
        fn Lowerの値の型がCloseのときpoint_typeメソッドはCloseを返す() {
	    let point = Point::Lower(PointType::Close(1));
            assert_eq!("Close", point.point_type());
        }
	#[rstest]
        fn Lowerの値の型がOpenのときpoint_typeメソッドはOpenを返す() {
	    let point = Point::Lower(PointType::Open(1));
            assert_eq!("Open", point.point_type());
        }
    }
    describe "Lowerは自身の文字列表記を返す" {}
    describe "Lowerは指定した整数が自身より大きいかどうか判定できる" {}
}

下端点、上端点およびOpen、Closeを型で表現してみました。
実装は下記のようになります。

src/int_closed_range.rs
pub enum PointType {
    Open(i32),
    Close(i32),
}

pub enum Point {
    Upper(PointType),
    Lower(PointType),
}

impl Point {
    pub fn point_type(&self) -> String {
        match &self {
	    Point::Lower(a) | Point::Upper(a) =>
	    match a {
	        PointType::Close(_b) => String::from("Close"),
	        PointType::Open(_b) => String::from("Open"),
	    }
        }
    }
}

よく似た性質のものを異なる型名を付けて区別したい場合はenumを使うのが良さそうです。
match式との相性も良く、ifを使うよりもすっきりと書けます
(ifをネストさせるな、という話は良く聞きますが、match式のネストはどうなんだろう)

文字列表記のテスト

続けて、文字列表記のテストを書き、実装します。

tests/int_closed_range_test.rs
describe "Lowerは自身の文字列表記を返す" {
    #[rstest]
    fn Lowerの値の型がCloseのとき文字列表記は大括弧に数値となる(close_lower: Point){
	println!("値がPointType::Close(1)のとき文字列表記は (1, となる");
	assert_eq!("[1,", close_lower.notate())
    }
    #[rstest]
    fn Lowerの値の型がOpenのとき文字列表記は小括弧に数値となる(open_lower: Point){
	println!("値がPointType::Open(1)のとき文字列表記は [1, となる");
	assert_eq!("(1,", open_lower.notate())
    }
}
src/int_closed_range.rs
pub fn notate(&self) -> String {
    match &self {
	Point::Lower(a)  => match a {
	    PointType::Close(b) => format!("[{},", b),
	    PointType::Open(b) => format!("({},", b),
	}
	Point::Upper(a) => match a {
	    PointType::Close(b) => format!(" {}]", b),
	    PointType::Open(b) => format!(" {})", b),
	}
    }
}

表記が少しカッコ悪いですが大目に見てください。
(それにしてもメソッド書くたびに同じmatch式を書いてるのがちょっとイヤですね・・・)

判定のテスト

判定テストはパラメタライズドテストにするのが良さそうなので、fixtureを用意しつつ、
これまでのテストをリファクタリングしました。

tests/int_closed_range_test.rs
describe "下端点Lowerは区間における最小値を表す" {
    #[fixture(value=1)]
    fn close_lower(value: i32) -> Point {
        Point::Lower(PointType::Close(value))
    }

    #[fixture(value=1)]
    fn open_lower(value: i32) -> Point {
        Point::Lower(PointType::Open(value))
    }

    describe "Lowerは自身が閉じているか開いているかの情報を持つ" {
        #[rstest]
        fn Lowerの値の型がCloseのときpoint_typeメソッドはCloseを返す(close_lower: Point) {
	    assert_eq!("Close", close_lower.point_type());
        }
        #[rstest]
        fn Lowerの値の型がOpenのときpoint_typeメソッドはOpenを返す(open_lower: Point) {
	    assert_eq!("Open", open_lower.point_type());
        }
    }

    describe "Lowerは自身の文字列表記を返す" {
        #[rstest(point, expected,
	    case(close_lower(1), "[1,"),
	    case(open_lower(1), "(1,")
        )]
        fn notateメソッドは文字列表記を返す(point: Point, expected: String){
	    println!("値がPointType::Close(1)のとき文字列表記は (1, となる");
	    assert_eq!(expected, point.notate())
        }
        #[rstest]
        fn Lowerの値の型がOpenのとき文字列表記は小括弧に数値となる(open_lower: Point){
	    println!("値がPointType::Open(1)のとき文字列表記は [1, となる");
	    assert_eq!("(1,", open_lower.notate())
        }
    }
    describe "Lowerは指定した整数が自身より大きいかどうか判定できる" {
        #[rstest(point, arg, expected,
	    case(close_lower(1), 0, false),
	    case(close_lower(1), 1, true),
	    case(close_lower(1), 2, true),
	    case(open_lower(1), 0, false),
	    case(open_lower(1), 1, false),
	    case(open_lower(1), 2, true)
        )]
        fn 指定した値がLowerよりも大きい場合はtrueを返す(point: Point, arg: i32, expected: bool) {
	    println!("{:?}の場合、compare({}) == {}", point, arg, expected);
	    assert_eq!(expected, point.compare(arg));
        }
    }
}

ちなみに、printlnで表示できるよう、PointTypeとPointには#[derive(Debug)]を追加しています。

src/int_closed_range.rs
pub fn compare(&self, arg: i32) -> bool {
    match &self {
	Point::Lower(a)  => match a {
	    PointType::Close(b) => {*b <= arg},
	    PointType::Open(b) => {*b < arg},
	}
	Point::Upper(a) => match a {
	    PointType::Close(b) => {*b >= arg},
	    PointType::Open(b) => {*b > arg},
	}
    }
}

下端点の場合は指定した値が自身より大きいこと(または以上であること)、上端点の場合は指定した値が
自身より小さいこと(または以下であること)を判定するようにします。

上端点のテスト

下端点だけで結構な量を書いて、さらに同じ量のテストを書く(またはコピペする)のは
ただただ辛いだけなので、下端点のテストをリファクタリングし、上端点もテストするようにします。
ついでにテスト名が不自然な書き方になっていたり、printlnの表記の修正漏れも直します
(とっても大事)

tests/int_closed_range_test.rs
describe "下端点および上端点は区間における最小値と最大値を表す" {
    #[fixture(value=1)]
    fn close_lower(value: i32) -> Point {
        Point::Lower(PointType::Close(value))
    }

    #[fixture(value=1)]
    fn open_lower(value: i32) -> Point {
        Point::Lower(PointType::Open(value))
    }

    #[fixture(value=5)]
    fn close_upper(value: i32) -> Point {
        Point::Upper(PointType::Close(value))
    }

    #[fixture(value=5)]
    fn open_upper(value: i32) -> Point {
        Point::Upper(PointType::Open(value))
    }

    describe "下端点と上端点は自身が閉じているか開いているかの情報を持つ" {
        #[rstest(point, expected,
	    case(close_lower(1), "Close"),
	    case(open_lower(1), "Open"),
	    case(close_upper(5), "Close"),
	    case(open_upper(5), "Open")
        )]
        fn point_type(point: Point, expected: String) {
	    println!("{:?}の場合、点は{}である", point, expected);
	    assert_eq!(expected, point.point_type());
        }
    }

    describe "下端点と上端点は自身の文字列表記を返す" {
        #[rstest(point, expected,
	    case(close_lower(1), "[1,"),
	    case(open_lower(1), "(1,"),
	    case(close_upper(5), " 5]"),
	    case(open_upper(5), " 5)")
        )]
        fn notate(point: Point, expected: String){
	    println!("値が{:?}のとき文字列表記は{}となる", point, expected);
	    assert_eq!(expected, point.notate())
        }
    }
    describe "下端点と上端点は指定した値と自身の大小関係を判定できる" {
        #[rstest(point, arg, expected,
	    case(close_lower(1), 0, false),
	    case(close_lower(1), 1, true),
	    case(close_lower(1), 2, true),
	    case(open_lower(1), 0, false),
	    case(open_lower(1), 1, false),
	    case(open_lower(1), 2, true),
	    case(close_upper(5), 6, false),
	    case(close_upper(5), 5, true),
	    case(close_upper(5), 4, true),
	    case(open_upper(5), 6, false),
	    case(open_upper(5), 5, false),
	    case(open_upper(5), 4, true),
        )]
        fn compare(point: Point, arg: i32, expected: bool) {
	    println!("{:?}の場合、compare({}) == {}", point, arg, expected);
	    assert_eq!(expected, point.compare(arg));
        }
    }
}

整数閉区間を修正する

fixtureの修正

Rust
    describe "IntClosedRangeは整数閉区間を表す" {
-       #[fixture(lower=3, upper=7)]
-       fn fixture(lower: i32, upper: i32) -> IntClosedRange {
+       #[fixture(lower=close_lower(3), upper=close_upper(7))]
+       fn fixture(lower: Point, upper: Point) -> IntClosedRange {
            IntClosedRange::new(lower, upper)
        }

まずは、これまで作ったfixtureを使って、整数閉区間テスト用のfixtureを修正します。
新しく作ったfixtureはこちらでも使用できるように位置を変更しています。

speculate! {
    #[fixture(value=1)]
    fn close_lower(value: i32) -> Point {
        Point::Lower(PointType::Close(value))
    }

    #[fixture(value=1)]
    fn open_lower(value: i32) -> Point {
        Point::Lower(PointType::Open(value))
    }

    #[fixture(value=5)]
    fn close_upper(value: i32) -> Point {
        Point::Upper(PointType::Close(value))
    }

    #[fixture(value=5)]
    fn open_upper(value: i32) -> Point {
        Point::Upper(PointType::Open(value))
    }

    describe "下端点および上端点は区間における最小値と最大値を表す" {
    
    // 以下略

構造体の型を修正

fixtureの引数の型を変えたことで、IntClosedRangeの生成でmismatched typesの
エラーが出ていると思います。なので、構造体の型を修正します。

src/int_closed_range.rs
pub struct IntClosedRange {
    lower: Point,
    upper: Point,
}

メソッドの修正

ここを直すとメソッドにもたくさんエラーが出るので、そこも修正していきましょう。

src/int_closed_range.rs
impl IntClosedRange {
    pub fn new(lower: Point, upper: Point) -> Self {
        Self{lower, upper}
    }
    pub fn lower(&self) -> &Point {
        &self.lower
    }
    pub fn upper(&self) -> &Point {
        &self.upper
    }
    pub fn notation(&self) -> String {
        format!("{}{}", self.lower.notate(), self.upper.notate())
    }
    pub fn includes(&self, arg: i32) -> bool {
        self.lower.compare(arg) && self.upper.compare(arg)
    }
}

とりあえずエラーを消すために型を合わせてみました。
ついでに、notationとincludesについては実装を見なおしています。
notationはlowerとupperそれぞれでnotateメソッドを実行すれば良く、
includesはlowerとupperのcompareメソッドの結果のアンドを取れば良いので、
記述が簡潔になりました。
エラーを解消できたら実行して問題ないか確認しましょう。

テストの修正

またまた、テストでエラーが大量発生しますが、挫けずにエラーに導かれるまま直していきます。

tests/int_closed_range_test.rs
describe "IntClosedRangeは整数閉区間を表す" {
    #[fixture(lower=close_lower(3), upper=close_upper(7))]
    fn fixture(lower: Point, upper: Point) -> 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(close_lower(3), close_upper(7)), "[3, 7]"),
	    case(fixture(close_lower(-2), close_upper(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));
        }
    }
}

可能な限り直しましたが、下記は簡単に修正できなそうです。

tests/int_closed_range_test.rs
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());
    }
}

このテストはもともと、fixture.upper()がi32型を返していたので成立していたのですが、
型が変わったことによって整数と比較ができなくなってしまっています。
テストが通るように実装を見直していきます。

実装の見直し

いまのlowerメソッドとupperメソッドはi32型を返さないので、返すように変更します。

src/int_closed_range.rs
pub fn lower(&self) -> i32 {
    self.lower.value()
}
pub fn upper(&self) -> i32 {
    self.upper.value()
}

lowerとupperにvalueメソッドを用意して、i32型の値を返してもらうようにします。
今のPoint型にはvalueメソッドはないので追加しましょう。

src/int_closed_range.rs
pub fn value(&self) -> i32 {
    match &self {
	Point::Lower(a) | Point::Upper(a) => 
	match a {
	    PointType::Close(b) | PointType::Open(b) => *b
	}
    }
}

impl Pointに上記メソッドを作成しました。
エラーがなくなったので、テストが成功するか確認します
(説明が抜けていなければ成功するはずですが、この書き方もうちょいなんとかならんものか・・・)

テストの追加と名前の修正

実装の修正ができてテストも成功したので、足りていないテストパターンを追加しましょう。
また、開区間に対応したのでIntClosedRangeという名前も変えたいですね。

  • Pint構造体で追加したvalueメソッドのテスト
  • 開区間、左開右閉区間、左閉右開区間のテスト
  • IntClosedRange構造体の名前の修正
  • モジュール名の修正
  • ファイル名の修正
  • cargo.tomlのパッケージ名修正(必要であれば)
    こんなかんじでしょうか。

valueメソッドのテスト

tests/int_closed_range_test.rs
describe "下端点と上端点は自身の値を返す" {
    #[rstest(point, expected,
	case(close_lower(1), 1),
	case(open_lower(1), 1),
	case(close_upper(5), 5),
	case(open_upper(5), 5)
    )]
    fn point_value(point: Point, expected: i32) {
	println!("{:?}の場合、値は{}である", point, expected);
	assert_eq!(expected, point.value());
    }
}

すでに実装に問題ないことが確認できていますが、こういうメソッドを持っているということを
テストから読み取れるようにしておきます。

開区間などなどのテストと名前の修正

これはパラメタライズドテストのケースを増やすだけですね。
なので、途中経過は省略して(だいぶ長くなってきたので)、諸々の名前を修正して完成です。

最終形

int_interval_test.rs
#[cfg(test)]
extern crate speculate;
extern crate rstest;

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

speculate! {
    #[fixture(value=1)]
    fn close_lower(value: i32) -> Point {
        Point::Lower(PointType::Close(value))
    }

    #[fixture(value=1)]
    fn open_lower(value: i32) -> Point {
        Point::Lower(PointType::Open(value))
    }

    #[fixture(value=5)]
    fn close_upper(value: i32) -> Point {
        Point::Upper(PointType::Close(value))
    }

    #[fixture(value=5)]
    fn open_upper(value: i32) -> Point {
        Point::Upper(PointType::Open(value))
    }

    describe "下端点および上端点は区間における最小値と最大値を表す" {
        describe "下端点と上端点は自身が閉じているか開いているかの情報を持つ" {
            #[rstest(point, expected,
                case(close_lower(1), "Close"),
                case(open_lower(1), "Open"),
                case(close_upper(5), "Close"),
                case(open_upper(5), "Open")
            )]
            fn point_type(point: Point, expected: String) {
                println!("{:?}の場合、点は{}である", point, expected);
                assert_eq!(expected, point.point_type());
            }
        }

        describe "下端点と上端点は自身の値を返す" {
            #[rstest(point, expected,
                case(close_lower(1), 1),
                case(open_lower(1), 1),
                case(close_upper(5), 5),
                case(open_upper(5), 5)
            )]
            fn point_value(point: Point, expected: i32) {
                println!("{:?}の場合、値は{}である", point, expected);
                assert_eq!(expected, point.value());
            }
        }

        describe "下端点と上端点は自身の文字列表記を返す" {
            #[rstest(point, expected,
                case(close_lower(1), "[1,"),
                case(open_lower(1), "(1,"),
                case(close_upper(5), " 5]"),
                case(open_upper(5), " 5)")
            )]
            fn notate(point: Point, expected: String){
                println!("値が{:?}のとき文字列表記は{}となる", point, expected);
                assert_eq!(expected, point.notate())
            }
        }
        describe "下端点と上端点は指定した値と自身の大小関係を判定できる" {
            #[rstest(point, arg, expected,
                case(close_lower(1), 0, false),
                case(close_lower(1), 1, true),
                case(close_lower(1), 2, true),
                case(open_lower(1), 0, false),
                case(open_lower(1), 1, false),
                case(open_lower(1), 2, true),
                case(close_upper(5), 6, false),
                case(close_upper(5), 5, true),
                case(close_upper(5), 4, true),
                case(open_upper(5), 6, false),
                case(open_upper(5), 5, false),
                case(open_upper(5), 4, true),
            )]
            fn compare(point: Point, arg: i32, expected: bool) {
                println!("{:?}の場合、compare({}) == {}", point, arg, expected);
                assert_eq!(expected, point.compare(arg));
            }
        }
    }
    
    describe "IntIntervalは整数区間を表す" {
        #[fixture(lower=close_lower(3), upper=close_upper(7))]
        fn fixture(lower: Point, upper: Point) -> IntInterval {
            IntInterval::new(lower, upper)
        }
        describe "IntIntervalは下端点と上端点を持つ" {
            #[rstest(range,
                case(fixture(close_lower(3), close_upper(7))),
                case(fixture(close_lower(3), open_upper(7))),
                case(fixture(open_lower(3), open_upper(7))),
                case(fixture(close_lower(3), close_upper(7))),
            )]
            fn lowerメソッドは整数区間の下端点を返す(range: IntInterval) {
                println!("整数区間が{}のとき、下端点は3である",range.notation());
                assert_eq!(3, range.lower());
            }

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

    #[derive(Debug)]
    pub enum PointType {
        Open(i32),
        Close(i32),
    }

    #[derive(Debug)]
    pub enum Point {
        Upper(PointType),
        Lower(PointType),
    }

    impl Point {
        pub fn point_type(&self) -> String {
            match &self {
                Point::Lower(a) | Point::Upper(a) => 
                match a {
                    PointType::Close(_b) => String::from("Close"),
                    PointType::Open(_b) => String::from("Open"),
                }
            }
        }
        pub fn value(&self) -> i32 {
            match &self {
                Point::Lower(a) | Point::Upper(a) => 
                match a {
                    PointType::Close(b) | PointType::Open(b) => *b
                }
            }
        }
        pub fn notate(&self) -> String {
            match &self {
                Point::Lower(a)  => match a {
                    PointType::Close(b) => format!("[{},", b),
                    PointType::Open(b) => format!("({},", b),
                }
                Point::Upper(a) => match a {
                    PointType::Close(b) => format!(" {}]", b),
                    PointType::Open(b) => format!(" {})", b),
                }
            }
        }
        pub fn compare(&self, arg: i32) -> bool {
            match &self {
                Point::Lower(a)  => match a {
                    PointType::Close(b) => {*b <= arg},
                    PointType::Open(b) => {*b < arg},
                }
                Point::Upper(a) => match a {
                    PointType::Close(b) => {*b >= arg},
                    PointType::Open(b) => {*b > arg},
                }
            }
        }
    }

    #[derive(Debug)]
    pub struct IntInterval {
        lower: Point,
        upper: Point,
    }

    impl IntInterval {
        pub fn new(lower: Point, upper: Point) -> Self {
            Self{lower, upper}
        }
        pub fn lower(&self) -> i32 {
            self.lower.value()
        }
        pub fn upper(&self) -> i32 {
            self.upper.value()
        }
        pub fn notation(&self) -> String {
            format!("{}{}", self.lower.notate(), self.upper.notate())
        }
        pub fn includes(&self, arg: i32) -> bool {
            self.lower.compare(arg) && self.upper.compare(arg)
        }
    }
}

まとめ

このネタを思いついたころはそんなに大変ではないだろうと思っていたのですが、
そこそこ大掛かりな修正になってしまいました。
最後に、テストを書くのが面倒にならないためにどうするか、自分が今回得た気付きをまとめます。

  • 面倒に思うことは決して悪いことではない
    • 面倒だという気持ちを押し殺して無理に進むと辛くなって歩みが止まってしまう
    • 面倒で辛い道を歩くくらいなら、少しでも楽しくなりそうな道を探す
    • それが設計を改善するための手がかりになるかもしれない
  • 早め早めに重複を潰す
    • 複数のテストで同じ処理を使い回すならfixture
    • 全く同じテストで値だけ変えたいときはパラメタライズドテスト

編集後記

  • 下端点や上端点は最初、Traitとかを使う予定だったのですが、うまく書けそうになく、enumを使いました。どっちの方がRustらしく、かつ、きれいなコードになるのかは、まだ勉強を始めたばかりなので判断がつきません(どなたか同じお題で別解作ってくれないかなぁ〜チラッ)
  • コードを全て同じファイル、同じモジュール内に書いてしまったけど、分割したほうがいいのかなぁとかそのあたりの設計のプラクティスについても知識不足