Closed11

実践プロパティベーステストをGoと共に読む

ぱんだぱんだ

1章 プロパティベーステストの基礎

これはイベントでtwadaさんが言ってたことだけど既存のテストである事例ベーステスト(EBT)、プロパティベーステスト(PBT)の関係はお互いを補完するような関係。

EBTは期待する結果が正しいかを確認するテスト。テストというよりも確認(Checking)の方が意味合いが近い。TDDにこのようなことが書いてあった気がする。

PBTは知らない、気づいていない不具合を探索するテスト手法。探索という意味合いが強い。

EBTをPBTで置き換えるとかそういう話ではなくてEBTでまずはしっかりと想定できる結果を確認しつつ、まだ見ぬ不具合がないか探索するためにPBTを書く。

PBTはgeneratorという仕組みを使い、私たち開発者がテストしきれない無数のパターンの値を生成しテストすることができる。ある入力を与えた時の出力がちゃんと予測可能な純粋関数になっている関数型プログラミングで好んで使われるのはPBTの仕組みと相性がいいから。

PBTにおいてテストを書くという感覚をまず捨てることが必要。プロパティを作るという感覚を持つことが大事。

また、より複雑なステートフルなプロパティテストを書くこともできる。これはあとあとたぶん出てくるはず。

あとは縮小(Shrink)という用語がある。これはプロパティテストで失敗ケースが検出されたとき、同じように失敗する最小のケースを代わりに教えてくれる仕組みのこと。PBTはフレームワークの力を使って書くが、この縮小もフレームワークの方で機能として用意してくれている。縮小を使う意義は単純に大きな値よりも小さな値を扱った方がデバッグが楽だから。

GoでPBTをやるには以下のライブラリがある。

gopterの情報が多く出てくるがrapidの方が後発でシンプルでより複雑なプロパティテストをサポートしているらしいので今回はrapidを使ってみる。

package main_test

import (
	"sort"
	"testing"

	"pgregory.net/rapid"
)

func TestSortStrings(t *testing.T) {
	rapid.Check(t, func(t *rapid.T) {
		s := rapid.SliceOf(rapid.String()).Draw(t, "s")
		sort.Strings(s)
		if !sort.StringsAreSorted(s) {
			t.Fatalf("unsorted after sort: %v", s)
		}
	})
}
ぱんだぱんだ

第2章 プロパティを書く

PBTの肝は常に真になるようなプログラムのルールをみつけ出すことである。これがちゃんとできないといわゆる自作自演でテストしてるようで何もテストしてないことになる。

どのような入力値を与えるかはgeneratorという仕組みが存在する。これは代表的なStringやint型の値を生成するようなジェネレーターもあれば、より複雑な入力値を生成するカスタムジェネレーターを生成することも可能。

プロパティにはいくつか種類があり主な種類は以下の2つ。

  • ステートレスプロパティ
  • ステートフルプロパティ

ステートレスプロパティは独立していて副作用を持たない関数のテストに適している。より複雑で副作用があるようなテストにはステートフルプロパティが適している。

generatorはフレームワークで基本的な型の値を生成するデフォルトジェネレーターが用意されているはず。rapidでは以下のような基本型のジェネレーターが用意されている。

func main() {
	for i := 0; i < 5; i++ {
		fmt.Println(rapid.Int().Example(i))
	}
}
-3
-186981
4
-2
43

書いてみる

sliceの最大値を返す関数のプロパティを考えてみる。

func Biggest(list []int) (max int) {
	for _, n := range list {
		if n > max {
			max = n
		}
	}
	return max
}
func TestBiggest(t *testing.T) {
	rapid.Check(t, func(t *rapid.T) {
		list := rapid.SliceOf(rapid.Int()).Draw(t, "list")
		act := biggest.Biggest(list)
		sort.Ints(list)
		expect := list[len(list)-1]

		if act != expect {
			t.Errorf("biggest value is wrong act: %d expect: %d\n", act, expect)
		}
	})
}

プロパティベーステストを書く時に難しいのが関数を実行して常に真となるようなルールを見つけることだ。今回の例で言うと渡されたslice内の最大の数値を返すというルールをプロパティとして書かなければならない。

しかし、このプロパティを書こうとすると関数の中身が簡単すぎてプロパティルールが関数の実装そのままとなってしまう。これが自作自演というやつだ。何もテストできていない。

ではどうすればいいかというと関数の実装をもう一つ作りそれを一つ目の実装のサニティチェックとして使う。上記の例で言うとsliceの中身を順番に確認し最大値を見つける実装とソートして最後の要素を最大値とする実装を用意する。二つとも正しければおそらく正しい。片方が間違っていれば実装がおかしい。もしかしたら、両方とも間違ってるかもしれない。なので、片方の実装は明らかに正しいものを用意する。

テストを実行するとpanicしてしまう。

[rapid] panic after 0 tests: runtime error: index out of range [-1]

どうやら、空のlistを生成したときにOutOfIndexでpanicしてるよう。

以下のように修正する。

func Biggest(list []int) (max int, err error) {
	if len(list) == 0 {
		return 0, errors.New("empty list")
	}

	for _, n := range list {
		if n > max {
			max = n
		}
	}
	return max, nil
}

空のsliceが引数だったときにゼロ値で0が返るのはややこしい気もするのでerrorを返すように修正。テストもあわせて修正。

func TestBiggest(t *testing.T) {
	rapid.Check(t, func(t *rapid.T) {
		list := rapid.SliceOf(rapid.Int()).Draw(t, "list")
		act, err := biggest.Biggest(list)
		sort.Ints(list)
		var expect int
		if len(list) > 0 {
			expect = list[len(list)-1]
		}

		// sliceが空ならerrorを返す
		if len(list) == 0 && err == nil {
			t.Errorf("must return error, if slice is empty")
		}

		// actとexpectの値は一致してる必要がある
		if act != expect {
			t.Errorf("biggest value is wrong act: %d expect: %d\n", act, expect)
		}
	})
}

ジェネレーターに空のsliceを生成させない方法もあるけど、空のsliceの場合の挙動も考えたかったので実装の方を修正した。また、テストの方は空のsliceの時にerrorが返るはずなのでその条件を追記。実行してみる。

[rapid] draw list: []int{-1}

sliceの中身が-1の時に失敗してる。Biggest関数のmaxの初期値が0だからだ。sliceの最初の要素を初期値にするように修正してみる。

func Biggest(list []int) (int, error) {
	if len(list) == 0 {
		return 0, errors.New("empty list")
	}

	max := list[0]
	for _, n := range list {
		if n > max {
			max = n
		}
	}
	return max, nil
}

これで実行してみるとテストがパスした。

[rapid] OK, passed 100 tests (855.166µs)

このようにステートレスプロパティの場合、反復作業を繰り返すことになる。テストが失敗したらプロパティが間違っている実装が間違っているかのどちらかなのでどちらが誤っているかを見極める。PBTにおけるプロパティの書き方の基本はテスト対象の実装の異なる実装をプロパティとしてテストが通るまで修正を繰り返すことである。

ぱんだぱんだ

第3章 プロパティで考える

前章でプロパティの書き方を学んだ。しかし、プロパティを書けるようになるには一朝一夕にはならず訓練するしかない。この章ではプロパティを書くためのテクニック的なものを学んでいく。

モデル化

モデル化とは非常に単純な実装をモデルとして書き、それを本物の実装と競争させる手法。前章のBiggest関数のテストで書いたプロパティがまさにモデル化を使用している。

モデルは通常アルゴリズム的に非効率だが、明らかに正しい実装となることが多い。競争させるモデルの実装が誤っているとテストにならないためだ。Biggest関数では標準パッケージであるsortとsliceのアクセスを使用した。基本的に明らかに正しい実装とするために信頼できる標準パッケージを組み合わせることは有効であろう。

プロパティを書く時にテスト対象の関数が単純な場合、プロパティを書くのは容易ではない。単純すぎるため関数のルールをプロパティに落とし込もうとするとテスト対象の実装のコピーとなってしまうため。

このような単純な関数のプロパティを書く時にモデル化は有効。モデル化のポイントはテスト対象の実装がモデルと同程度に信頼できるということが保証できる点だ。

モデル化の一種として神託(oracle)と呼ばれるものがある。これは別のプログラミング言語やライブラリの実装をモデルとして使うことで自分で比較実装を用意しないで、既に実績のある実装を比較として使うことができる、ということだと思う。

最後に、モデルの実装はパフォーマンスを考慮していないことがほとんどのためテストパフォーマンスが悪くなることがあるということを覚えておく必要がある。ただ、それでもモデル化でプロパティを書くことのメリットは大きい。

事例テストを汎化する

モデル化がうまくいくのは同じプログラムを何種類も書け、そのうちの一つが自明に正しいほど単純な実装のとき。そのため、モデル化が常に有効とは限らない。

こういった場合に、最初に事例テストを何種類か書き共通手順を取り出しジェネレーターに置き換えるといった手法が有効なときがある。

プロパティベーステストは信頼できるプロパティを書く必要があるが、プロパティの処理としてどこまでの処理を信頼するかはテスト実装者に委ねられる。

不変条件

システムにおいて常に真になるはずという条件や事実を不変条件という。不変条件の例としては

  • お店で在庫以上の数の商品を売れない。
  • sort関数が最小値から最大値に向かって値が大きくなる

など。単一の不変条件だけではシステムの動作が期待通りに動くことを示すには不十分。複数の不変条件を見つけ、それが常に真であり続けるなら十分にシステムが期待通りに動作するということが示せる。

PBTにおいてそれぞれが1つの不変条件を維持するプロパティをいくつも書くことができる。つまり、単一の不変条件に基づくプロパティだけではなく十分に信頼できるまで不変条件に基づくプロパティを書くことができる。

ここでsort関数について不変条件を考えてみる。

Goの標準パッケージのsortパッケージを使うと破壊的変更になってしまうし、そもそも標準パッケージで信頼性があるので以下のように新しいsliceを返すSort関数を定義する。

func Sort(list []int) []int {
	sorted := make([]int, len(list))
	copy(sorted, list)
	sort.Ints(sorted)
	return sorted
}

この関数の不変条件はソートされていることであるがそれでは関数の処理そのままで、そのようなプロパティを作成したところで同じ処理をプロパティとして定義することになり何もテストできていない。

そこでslice全体に対して考えるのではなく一つ一つの要素に対する不変条件を考えると前の要素が次の要素よりも小さいことである。これをプロパティとして書くと以下のように書ける。

	// sort関数の不変条件の一つとしソート済みのsliceは後の要素より前の要素は常に小さい
	t.Run("ordered", func(t *testing.T) {
		rapid.Check(t, func(t *rapid.T) {
			s := rapid.SliceOf(rapid.Int()).Draw(t, "s")
			sorted := biggest.Sort(s)
			for i := 0; i < len(sorted)-1; i++ {
				if !(sorted[i] <= sorted[i+1]) {
					t.Errorf("not sorted prev: %d next: %d", sorted[i], sorted[i+1])
				}
			}
		})
	})

不変条件は一つだけでは不十分。他に考えられる不変条件には以下のようなものがある。

  • ソート済みのsliceとソート前のsliceの長さは同じ
  • ソート済みのsliceの全要素には、対応するソート前のsliceの要素がある。(要素が追加されない)
  • ソート前のsliceの全要素には、対応するソート済みのsliceの要素がある。(要素は削除されない)

これらの不変条件をプロパティとして実装すると以下のようになる。

	//  ソート済みのsliceとソート前のsliceは同じ長さであるべき
	t.Run("sameSize", func(t *testing.T) {
		rapid.Check(t, func(t *rapid.T) {
			s := rapid.SliceOf(rapid.Int()).Draw(t, "s")
			sorted := biggest.Sort(s)
			if len(s) != len(sorted) {
				t.Errorf("not same size originalLen: %d sortedLen: %d", len(s), len(sorted))
			}
		})
	})

	// ソート済みsliceの全要素には、対応するソート前の要素がある
	t.Run("noAdded", func(t *testing.T) {
		rapid.Check(t, func(t *rapid.T) {
			s := rapid.SliceOf(rapid.Int()).Draw(t, "s")
			sorted := biggest.Sort(s)

			for _, elm := range sorted {
				if !contains(s, elm) {
					t.Errorf("added item, but must not added added: %d", elm)
				}
			}
		})
	})

	//  ソート前のリストの全要素には、対応するソート済みリストの要素がある
	t.Run("noRemoved", func(t *testing.T) {
		rapid.Check(t, func(t *rapid.T) {
			s := rapid.SliceOf(rapid.Int()).Draw(t, "s")
			sorted := biggest.Sort(s)

			for _, elm := range s {
				if !contains(sorted, elm) {
					t.Errorf("slice item removed item: %d", elm)
				}
			}
		})
	})

これでだいぶSort関数への信頼度は上がりました。しかし、これらのテストのためにcontains()という関数を実装していることに注目したい。これがまだプログラミング言語側で用意された関数ならば十分信頼できるとしていいかもしれないが、Goにはsliceに対してのそのような操作は標準では用意されていないた自作することになる。つまり、このcontainsという関数の動作が信頼できないテストのために作った関数のテストが必要になるということである。

どこまで処理を信頼するかは開発者本人が決めることになるが少なくとも今回のような場合はcontainsという関数は十分に信頼できるとは言い難いため、containsのテストを書く。

func contains[U ~[]T, T cmp.Ordered](list U, elm T) bool {
	sort.Slice(list, func(i, j int) bool { return list[i] < list[j] })
	_, found := slices.BinarySearch(list, elm)
	return found
}

func TestContains(t *testing.T) {
	tests := map[string]struct {
		slice  []int
		target int
		want   bool
	}{
		"want contains": {
			slice:  []int{1, 2, 3},
			target: 2,
			want:   true,
		},
		"negative slice": {
			slice:  []int{-1, -2, -3},
			target: -2,
			want:   true,
		},
		"zero": {
			slice:  []int{0, 1, 2},
			target: 0,
			want:   true,
		},
		"random": {
			slice:  []int{3, 1, 0, 9, -1},
			target: 0,
			want:   true,
		},
		"not contains": {
			slice:  []int{1, 2, 3},
			target: 4,
			want:   false,
		},
	}

	for name, tt := range tests {
		name, tt := name, tt
		t.Run(name, func(t *testing.T) {
			act := contains(tt.slice, tt.target)
			if act != tt.want {
				t.Errorf("want %v, but %v", tt.want, act)
			}
		})
	}
}

contains関数のテストはPBTでも書けなくはないのだと思うのだけどここでプロパティ書き出すとキリが無くなりそうな気がしなくもないので事例テスト(EBT)を書いた。これである程度contains関数を信頼することができたためSort関数のPBTもそれなりに信頼できるようになった。

このように不変条件によるプロパティの作成は関数の処理をどこまで信頼するかということを考え、信頼できるまでテストを書く必要がある

対称プロパティ

これは例えばEncode()Decode()のように対になるような処理がある場合、encodeしてdecodeして元に戻せるみたいなプロパティが書ける。

Goであれば以下のように書ける。

type JsonStruct struct {
	Id int `json:"id"`
}

func Decode[T any](str string) T {
	var s T
	b := bytes.NewBufferString(str)
	if err := json.NewDecoder(b).Decode(&s); err != nil {
		panic(err)
	}
	return s
}

func Encode[T any](s T) string {
	b := new(bytes.Buffer)
	if err := json.NewEncoder(b).Encode(s); err != nil {
		panic(err)
	}
	return b.String()
}
func TestJson(t *testing.T) {
	rapid.Check(t, func(t *rapid.T) {
		val := rapid.Int().Draw(t, "val")
		s := biggest.JsonStruct{
			Id: val,
		}

		encoded := biggest.Encode(s)
		encoded = strings.Trim(encoded, "\n")
		decoded := biggest.Decode[biggest.JsonStruct](encoded)

		if encoded != fmt.Sprintf("{\"id\":%d}", val) {
			t.Errorf("failed to encode encoded: %s expect: %s", encoded, fmt.Sprintf("{\"id\":%d}", val))
		}

		if !reflect.DeepEqual(s, decoded) {
			t.Errorf("failed to decode decoded: %v", decoded)
		}
	})
}

PBTのコツとして一度に全てのプロパティを書こうとせずに小さいプロパティを複数書くこと。

最後に練習問題。
文字列内の単語数を数える関数のプロパティを考える。

package count

import "strings"

// 単語数をカウントする。区切り文字は空白
func Count(str string) int {
	words := strings.Split(str, " ")
	return len(words)
}

ジェネレーターは正規表現を使用し1つ以上の単語を含むような文字列を生成する。プロパティの実装にはモデル化を使用する。もう一つの考えられる実装として空白区切りで単語数を数えるため空白数+1が単語数となるはず。それをプロパティとしてテストを書くと以下のようになる。

func TestCount(t *testing.T) {
	rapid.Check(t, func(t *rapid.T) {
		s := rapid.StringMatching(`^(\w+ )+\w$`).Draw(t, "s")
		act := count.Count(s)
		expect := strings.Count(s, " ") + 1
		if act != expect {
			t.Errorf("act %d expect %d", act, expect)
		}
	})
}

たぶんこんな感じで大丈夫なはず

ぱんだぱんだ

第4章 カスタムジェネレーター

  • 統計情報を収集するcollect関数に相当するものはrapidにはなさそう。gopterにもたぶんない。
  • ジェネレーターのメトリクスを取りたい場合は自力でやる必要がある
  • Example()のような生成する値を確認する関数は用意されてるのでそれで頑張る

リサイズジェネレーター

ジェネレーターが生成する値の大きさを縮小したり、拡大したりと生成する値の幅を拡張するようなジェネレーターのこと。ErlangやElixirであればresizeのような関数が用意されているようだが、rapidにはない。

prop_resize() ->
  ?FORALL(Bin, resize(150, binary()), % <= ここをリサイズした
    collect(to_range(10, byte_size(Bin)), is_binary(Bin))).

ただ、このような生成するバイナリのサイズを制限するようなものであれば、ByteMaxByteMinといったジェネレーター関数が用意されているので代わりに使うことができる。

変換ジェネレーター

PBTを書いているとデフォルトのジェネレーターでは不十分なときがあり、デフォルトのジェネレーターで値を生成したあとにプロパティの中でデータ変換をするときがある。これは値の生成がジェネレーターとプロパティで分離されてしまっているよくない抽象化。

rapidでいえばCustom()を使用することで独自のジェネレーターを定義することができるのでここら辺を使えば変換ジェネレーターとして使えそう。

生成する値を制限する

これは生成する値から反例を取り除きたい時。rapidであればFilter()があるのでこれで実現できそう。ただ、取り除きたい値が多い場合、Filterによる生成する値の制限はパフォーマンス的に良くないかもしれないのでその場合は、Customジェネレーターでやったほうがいいかもしれない。

確率を変更する

PropErのfrequency()のような関数があれば生成するジェネレーターの確率まで指定できるがrapidにはそこまではない。たぶんgopterにもない。OneOf()のような関数は用意されている。もし確率まで操作したかったらカスタムジェネレーターで頑張る必要がある。

再帰ジェネレーター

再帰関数が書けない。。一応途中まで考えたけど上手く書けない。


type Direction int

const (
	_ Direction = iota
	right
	left
	up
	down
)

func GenDirection(current Direction) *rapid.Generator[Direction] {
	return rapid.Custom(func(t *rapid.T) Direction {
		for {
			d := rapid.OneOf(
				rapid.Just(right),
				rapid.Just(left),
				rapid.Just(up),
				rapid.Just(down),
			).Draw(t, "d")

			if (d == right && current != left) ||
				(d == left && current != right) ||
				(d == up && current != down) ||
				(d == down && current != up) {
				return d
			}
		}
	})
}

func GenPath(t *rapid.T, min, max int) *rapid.Generator[[]Direction] {
	return rapid.SliceOfN(
    // ここに再帰ジェネレーター入れて、Defferredで遅延読み込むできるよってしたいけどできない
		rapid.Deferred(func() *rapid.Generator[Direction] { return GenDirection(0) }), min, max,
	)
}

再帰ジェネレーターの役割としてはFilterやCustomでは実現できないようなときに再起的にジェネレータを作成することでうまくいくときがある。しかし、ジェネレーター関数は呼び出し時に再帰関数も展開されてしまい、メモリ枯渇につながるのでそのようなときは遅延呼び出しが利用できる。rapidではDefeered()が用意されているためこれで再帰ジェネレーターが利用できる。

ただ、そもそもうまく再帰ジェネレーターが作れない。。

シンボリックコール

テストのために生成した値が大量のバイト列だった場合、どれだけ縮小したとしてもデバッグは困難。このような問題を解決するためにシンボリックコールから作られたジェネレーターがある。

rapidでは用意されていないがCustom関数を利用して作ることはできるかもしれない。

ぱんだぱんだ

第5章 プロパティ駆動開発

商品の価格計算するロジックのPBTを書いてみる。サンプルのErlangやElixirは関数型言語なのでジェネレーターの合成をして、カスタムジェネレーターを作成していたが、Goでやると愚直にジェネレーターを実装する感じになっちゃう。

package shop_test

import (
	"fmt"
	"go-pbt/shop"
	"math/rand"
	"testing"
	"time"

	"pgregory.net/rapid"
)

type itemPrices struct {
	items         []string
	expectedPrice int
	prices        map[string]int
}

func itemPriceList(size int) *rapid.Generator[itemPrices] {
	return rapid.Custom(func(t *rapid.T) itemPrices {
		prices := priceList().Draw(t, "prices")
		items, expectedPrice := itemList(prices, size)
		return itemPrices{
			items:         items,
			expectedPrice: expectedPrice,
			prices:        prices,
		}
	})
}

func keys[K comparable, V any](m map[K]V) []K {
	keys := make([]K, 0, len(m))
	for k := range m {
		keys = append(keys, k)
	}
	return keys
}

func random[K comparable, V any](m map[K]V, keys []K) (k K, v V) {
	if len(keys) == 1 {
		return keys[0], m[keys[0]]
	}
	r := rand.New(rand.NewSource(time.Now().UnixNano()))
	i := r.Intn(len(keys) - 1)
	key := keys[i]
	return key, m[key]
}

func itemList(prices map[string]int, size int) (items []string, expectedPrice int) {
	items = make([]string, size)
	itemNames := keys(prices)
	for i := 0; i < size; i++ {
		item, price := random(prices, itemNames)
		items[i] = item
		expectedPrice += price
	}
	return items, expectedPrice
}

// key: itemName value: price
func priceList() *rapid.Generator[map[string]int] {
	return rapid.MapOfN(
		rapid.String().Filter(func(v string) bool { return v != "" }),
		rapid.Int(),
		1,
		100,
	)
}

func TestNoSpecial(t *testing.T) {
	rapid.Check(t, func(t *rapid.T) {
		size := rapid.IntRange(0, 30).Draw(t, "size")
		ip := itemPriceList(size).Draw(t, "ip")
		act, err := shop.Total(ip.items, ip.prices, []any{})
		if err != nil {
			t.Error(err)
		}

		if act != ip.expectedPrice {
			t.Errorf("expect total %d, but %d", ip.expectedPrice, act)
		}
	})
}

func TestPrices(t *testing.T) {
	for i := 0; i < 10; i++ {
		fmt.Println(priceList().Example(i))
	}
}

ぱんだぱんだ

第7章 収縮

収縮とはプロパティテストが失敗したときに値を扱いやすいように変換してくれる処理のことで通常フレームワーク側の機能になる。収縮はデータが空になるように変化していく傾向があり、数値であれば0、配列などであれば空要素といった方向に変化していく。

rapidでは収縮関係の関数はない。gopterにはあった気がする。

ぱんだぱんだ

第8章 標的型プロパティ

ErlangのPropErの比較的新しい機能。他のプログラミング言語で作成されたPBTライブラリではないのがほとんどだろう。もちろんrapidやgopterにはない。

内容もよくわかんなかった。既存のジェネレーターとプロパティが密結合でもっと汎用的なジェネレーターは書けないのか?みたいな書き出しから始まっているのでより高度なジェネレーターもしくはプロパティの書き方なんだと思うけど、よくわからんかった。とりあえず、焼きなましといった競プロで見かけるアルゴリズムの話が出ていた。

ぱんだぱんだ

ステートフルプロパティ

実際のアプリケーション開発ではDBやファイルシステムなど副作用が発生する処理がほとんど。それをなるべくドメインロジックから切り離して考えようねというのでDDDやクリーンアーキテクチャや関数型の話になることが多く、そのような設計をしているとそういった副作用を発生させる処理は一箇所にまとまることが多い。クリーンやDDDのレイヤードアーキテクチャでいうとUsecase層やアプリケーションサービス層と呼ばれるところがその役割を担う。

ステートフルプロパティは状態を持つ関数のPBTであり、状態を持つということは副作用を伴う。そのため、ステートフルプロパティを書くときは結合テストやE2Eテストであることが多いだろう。

9章ではキャッシュの実装をテストする。10章では書籍の貸出システムを実装してテストを書く。主にDB処理を伴う時にPBTをどう書くかという話になりそう。Goで書いてみる。

11章では有限状態機械プロパティというものを学ぶがだいぶ発展的な内容な気がしたのでなんとなく読んで終わりにした。

ぱんだぱんだ

書籍の貸出システム

とりあえず、実装。書籍のサンプルでは以下のようなbooksテーブルのクエリを実装している。今回はGoとsqlcを使用して実装した。

-- 本を追加する
-- name: AddBook :exec
INSERT INTO books (isbn, title, author, owned, available)
VALUES (?, ?, ?, ?, ?);

-- 既存の本を1冊追加する 
-- name: AddCopy :exec
UPDATE books SET
  owned = owned + 1,
  available = available + 1 
WHERE 
  isbn = ?;

-- 本を1冊借りる
-- name: BorrowCopy :exec
UPDATE books SET available = available - 1 WHERE isbn = ? AND available > 0;

-- 本を返却する
-- name: ReturnCopy :exec
UPDATE books SET available = available + 1 WHERE isbn = ?;

-- 本を見つける
-- name: FindByAuthor :many
SELECT * FROM books WHERE author LIKE ?;

-- name: FindByIsbn :one
SELECT * FROM books WHERE isbn = ?;

-- name: FindByTitle :one
SELECT * FROM books WHERE title LIKE ?; 
repository.go
package book

import (
	"context"
	"database/sql"
	"go-pbt/infrastructure"
)

type BookRepository interface {
	AddBook(ctx context.Context, isbn, title, author string, options ...addBookOptions) error
	AddCopy(ctx context.Context, isbn string) error
	BorrowCopy(ctx context.Context, isbn string) error
	ReturnCopy(ctx context.Context, isbn string) error
	FindBookByAuthor(ctx context.Context, author string) ([]infrastructure.Book, error)
	FindBookByIsbn(ctx context.Context, isbn string) (infrastructure.Book, error)
	FindBookByTitle(ctx context.Context, title string) (infrastructure.Book, error)
}

type bookRepository struct {
	q *infrastructure.Queries
}

func NewRepository(db *sql.DB) BookRepository {
	return &bookRepository{q: infrastructure.New(db)}
}

type addBookOption struct {
	Owned sql.NullInt32
	Avail sql.NullInt32
}

type addBookOptions func(*addBookOption)

func (o *addBookOption) WithOwned(owned int32) {
	o.Owned = sql.NullInt32{Int32: owned, Valid: true}
}

func (o *addBookOption) WithAvail(avail int32) {
	o.Avail = sql.NullInt32{Int32: avail, Valid: true}
}

func (br *bookRepository) AddBook(ctx context.Context, isbn, title, author string, options ...addBookOptions) error {
	var op addBookOption
	for _, option := range options {
		option(&op)
	}

	params := infrastructure.AddBookParams{
		Isbn:      isbn,
		Title:     title,
		Author:    author,
		Owned:     op.Owned,
		Available: op.Avail,
	}

	return br.q.AddBook(ctx, params)
}

func (br *bookRepository) AddCopy(ctx context.Context, isbn string) error {
	return br.q.AddCopy(ctx, isbn)
}

func (br *bookRepository) BorrowCopy(ctx context.Context, isbn string) error {
	return br.q.BorrowCopy(ctx, isbn)
}

func (br *bookRepository) ReturnCopy(ctx context.Context, isbn string) error {
	return br.q.ReturnCopy(ctx, isbn)
}

func (br *bookRepository) FindBookByAuthor(ctx context.Context, author string) ([]infrastructure.Book, error) {
	return br.q.FindByAuthor(ctx, author)
}

func (br *bookRepository) FindBookByIsbn(ctx context.Context, isbn string) (infrastructure.Book, error) {
	return br.q.FindByIsbn(ctx, isbn)
}

func (br *bookRepository) FindBookByTitle(ctx context.Context, title string) (infrastructure.Book, error) {
	return br.q.FindByTitle(ctx, title)
}

ジェネレーターを書く

まず、titleとauthorのジェネレーター。書籍ではこのようになっている。

title() ->
  ?LET(S, string(), elements([S, unicode:characters_to_binary(S)])).

stringとバイナリのどちらかを生成して返すジェネレーターになっている。これをGoで再現しようとするとかなり手間なので普通に文字列を生成して返すように実装。

// ジェネレーター
func title() *rapid.Generator[string] {
	return rapid.String()
}

func author() *rapid.Generator[string] {
	return rapid.String()
}

ISBNは以下のように頑張って実装。

func isbn() *rapid.Generator[string] {
	return rapid.Custom(func(t *rapid.T) string {
		a := rapid.OneOf(rapid.Just("978"), rapid.Just("979")).Draw(t, "isbn-a")
		b := strconv.Itoa(rapid.IntRange(0, 9999).Draw(t, "isbn-b"))
		c := strconv.Itoa(rapid.IntRange(0, 9999).Draw(t, "isbn-c"))
		d := strconv.Itoa(rapid.IntRange(0, 999).Draw(t, "isbn-d"))
		e := rapid.StringOfN(
			rapid.RuneFrom([]rune{'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'X'}),
			1, 1, 1,
		).Draw(t, "isbn-e")

		return strings.Join([]string{a, b, c, d, e}, "-")
	})
}

このジェネレーターで以下のような文字列が生成できる。

979-7653-6-129-3
978-49-7449-62-X
979-1-47-4-3
978-2099-1545-129-6
978-1-1-4-7
979-493-1-125-4
979-1361-122-1-0
978-3-1-191-7
979-7-298-3-4
978-3-2800-0-4

上記のジェネレーターは正規表現で以下のようにも書ける。

func isbn() *rapid.Generator[string] {
	return rapid.StringMatching("(978|979)-(([0-9]|[1-9][0-9]|[1-9]{2}[0-9]|[1-9]{3}[0-9])-){2}([0-9]|[1-9][0-9]|[1-9]{2}[0-9])-[0-9X]")
}

もっと簡潔になるかと思ったけどそんなに簡潔にならなかった。

広範囲のステートフルテスト

とりあえず、全ての関数が動作することだけをテストする。rapidでステートフルテストを書く場合、rapid.T.Repeat()を使用することで実現できる。

rapid.T.Repeat()は引数にmap[string]func(*rapid.T)を取り、このmapに実行したい処理を紐づけていく。そして、実行した処理の後の状態は関数の外側で保持しておき、いい感じに処理を書いていく。

通常ステートフルテストは実行するcommand群とそれらを実行した後の状態と処理を実行する前後検証などを組み合わせて作成する。

DBはdockertestと初期テーブルの作成にgolang-migrateを使用した。

全ての関数を動作することだけを確認するテスト
package book_test

import (
	"context"
	"database/sql"
	"fmt"
	"go-pbt/book"
	container "go-pbt/internal"
	"log"
	"os"
	"slices"
	"testing"
	"unicode"

	"pgregory.net/rapid"
)

// ジェネレーター
// func notEmpty(g *rapid.Generator[string]) *rapid.Generator[string] {
// 	return g.Filter(func(v string) bool { return len(v) != 0 })
// }

// 仕様に合わせて生成する文字列は調整 今回はASCII文字列と数字から1-100文字の範囲で生成
func title() *rapid.Generator[string] {
	return rapid.StringOfN(rapid.RuneFrom(nil, unicode.ASCII_Hex_Digit), 1, 100, -1)
}

// 仕様に合わせて生成する文字列は調整 今回はASCII文字列と数字から1-100文字の範囲で生成
func author() *rapid.Generator[string] {
	return rapid.StringOfN(rapid.RuneFrom(nil, unicode.ASCII_Hex_Digit), 1, 100, -1)
}

func isbn() *rapid.Generator[string] {
	// return rapid.Custom(func(t *rapid.T) string {
	// 	a := rapid.OneOf(rapid.Just("978"), rapid.Just("979")).Draw(t, "isbn-a")
	// 	b := strconv.Itoa(rapid.IntRange(0, 9999).Draw(t, "isbn-b"))
	// 	c := strconv.Itoa(rapid.IntRange(0, 9999).Draw(t, "isbn-c"))
	// 	d := strconv.Itoa(rapid.IntRange(0, 999).Draw(t, "isbn-d"))
	// 	e := rapid.StringOfN(
	// 		rapid.RuneFrom([]rune{'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'X'}),
	// 		1, 1, 1,
	// 	).Draw(t, "isbn-e")

	// 	return strings.Join([]string{a, b, c, d, e}, "-")
	// })
	return rapid.StringMatching("(978|979)-(([0-9]|[1-9][0-9]|[1-9]{2}[0-9]|[1-9]{3}[0-9])-){2}([0-9]|[1-9][0-9]|[1-9]{2}[0-9])-[0-9X]")
}

func TestExample(t *testing.T) {
	for i := 0; i < 20; i++ {
		fmt.Println(isbn().Example(i))
	}
}

const migrationPath = "../db/migrations"

var db *sql.DB

func TestMain(m *testing.M) {
	// container起動
	container, err := container.RunMySQLContainer()
	if err != nil {
		log.Fatal(err)
	}

	// マイグレーション
	if err = container.Migrate(migrationPath); err != nil {
		container.Close()
		log.Fatal(err)
	}

	db = container.DB

	code := m.Run()

	container.Close()
	os.Exit(code)
}

func TestProperty(t *testing.T) {
	type state struct {
		isbn   string
		author string
		title  string
	}

	ctx := context.Background()
	br := book.NewRepository(db)
	states := make([]state, 0, 100)
	isbns := make([]string, 0, 100)

	rapid.Check(t, func(t *rapid.T) {
		t.Repeat(map[string]func(*rapid.T){
			"AddBook": func(t *rapid.T) {
				isbn := isbn().Filter(func(v string) bool {
					return !slices.Contains(isbns, v)
				}).Draw(t, "isbn")
				author := author().Draw(t, "author")
				title := title().Draw(t, "title")
				if err := br.AddBook(ctx, isbn, title, author); err != nil {
					t.Fatal(err)
				}
				states = append(states, state{
					isbn:   isbn,
					author: author,
					title:  title,
				})
				isbns = append(isbns, isbn)
			},
			"AddCopy": func(t *rapid.T) {
				if len(states) == 0 {
					t.Skip("no books")
				}

				isbn := states[len(states)-1].isbn
				if err := br.AddCopy(ctx, isbn); err != nil {
					t.Fatalf("failed to AddCopy isbn: %s err: %s", isbn, err.Error())
				}
			},
			"BorrowCopy": func(t *rapid.T) {
				if len(states) == 0 {
					t.Skip("no books")
				}

				isbn := states[len(states)-1].isbn
				if err := br.BorrowCopy(ctx, isbn); err != nil {
					t.Fatalf("failed to BorrowCopy isbn: %s err: %s", isbn, err.Error())
				}
			},
			"ReturnCopy": func(t *rapid.T) {
				if len(states) == 0 {
					t.Skip("no books")
				}

				isbn := states[len(states)-1].isbn
				if err := br.ReturnCopy(ctx, isbn); err != nil {
					t.Fatalf("failed to ReturnCopy isbn: %s err: %s", isbn, err.Error())
				}
			},
			"FindBookByAuthor": func(t *rapid.T) {
				if len(states) == 0 {
					t.Skip("no books")
				}

				state := states[len(states)-1]
				_, err := br.FindBookByAuthor(ctx, state.author)
				if err != nil {
					t.Fatalf("failed to FindBookByAuthor isbn: %s err: %s", state.isbn, err.Error())
				}
			},
			"FindBookByTitle": func(t *rapid.T) {
				if len(states) == 0 {
					t.Skip("no books")
				}

				state := states[len(states)-1]
				_, err := br.FindBookByTitle(ctx, state.title)
				if err != nil {
					t.Fatalf("failed to FindBookByTitle isbn: %s err: %s", state.isbn, err.Error())
				}
			},
			"FindBookByIsbn": func(t *rapid.T) {
				if len(states) == 0 {
					t.Skip("no books")
				}

				isbn := states[len(states)-1].isbn
				_, err := br.FindBookByIsbn(ctx, isbn)
				if err != nil {
					t.Fatalf("failed to FindBookByIsbn isbn: %s err: %s", isbn, err.Error())
				}
			},
		})
	})
}

bookの追加処理を実行した場合、追加したbookの情報を外部で保持しておく。他の更新・検索系の関数は保持している本の最後に追加されたものを対称に関数を実行している。もし、本が一冊も登録されていなかった場合には更新・検索系の処理はスキップしている。

これを実行するにあたってtitleとauthorの値に空文字が指定されるとDB検索でヒットしないためジェネレーターの空文字を生成しないように修正した。

書籍では文字列の生成をバイナリ含ませて生成しているのでもっと修正箇所多かった。あと、集計と確率ジェネレーターみたいなの欲しい。

状態の正確なモデル化

上記のテストは動かしただけ。正確に本情報がどう変わっているかをシュミレートして検証するようにテストを書く。長くなったのが以下のように書けた。

最終的なテスト
func TestProperty2(t *testing.T) {
	ctx := context.Background()
	br := book.NewRepository(db)
	states := make(states)

	rapid.Check(t, func(t *rapid.T) {
		// 状態に依存しないテスト
		alwaysPossible := map[string]func(*rapid.T){
			"AddBookNew": func(t *rapid.T) {
				isbn := isbn().Draw(t, "isbn")
				author := author().Draw(t, "author")
				title := title().Draw(t, "title")

				// 事前条件
				if hasIsbn(states, isbn) {
					t.Skip("already exist book")
				}

				if err := br.AddBook(ctx, isbn, title, author, book.WithOwned(1), book.WithAvail(1)); err != nil {
					t.Fatalf("failed to AddBookNew isbn: %s err: %s", isbn, err.Error())
				}

				// 状態更新
				states[isbn] = NewBook(isbn, author, title, 1, 1)
			},
			"AddCopyNew": func(t *rapid.T) {
				isbn := isbn().Draw(t, "isbn")

				// 事前条件
				if hasIsbn(states, isbn) {
					t.Skip("already exist book")
				}

				if err := br.AddCopy(ctx, isbn); err == nil {
					t.Fatal("expected error, but not error")
				}
			},
			"BorrowCopyUnkown": func(t *rapid.T) {
				isbn := isbn().Draw(t, "isbn")

				// 事前条件
				if hasIsbn(states, isbn) {
					t.Skip("already exist book")
				}

				if err := br.BorrowCopy(ctx, isbn); err == nil {
					t.Fatal("expected error, but not error")
				}
			},
			"ReturnCopyUnkown": func(t *rapid.T) {
				isbn := isbn().Draw(t, "isbn")

				// 事前条件
				if hasIsbn(states, isbn) {
					t.Skip("already exist book")
				}

				if err := br.ReturnCopy(ctx, isbn); err == nil {
					t.Fatal("expected error, but not error")
				}
			},
			"FindBookByIsbnUnkown": func(t *rapid.T) {
				isbn := isbn().Draw(t, "isbn")

				// 事前条件
				if hasIsbn(states, isbn) {
					t.Skip("already exist book")
				}

				var err error
				if _, err = br.FindBookByIsbn(ctx, isbn); err == nil {
					t.Fatal("failed to FindBookByIsbnUnkown. expect error, but not error")
				}

				if !errors.Is(err, sql.ErrNoRows) {
					t.Fatalf("expect sql.ErrNoRows, but %v", err)
				}
			},
			"FindBookByAuthorUnkown": func(t *rapid.T) {
				author := author().Draw(t, "author")

				// 事前条件
				if likeAuthor(states, author) {
					t.Skip("already exist book")
				}

				result, err := br.FindBookByAuthor(ctx, author)
				if err != nil {
					t.Fatalf("failed to FindBookByAuthorUnkown author: %s err: %s", author, err.Error())
				}

				if len(result) != 0 {
					t.Fatalf("failed to FindBookByAuthorUnkown. expect record not found, but found result: %v", result)
				}
			},
			"FindBookByTitleUnkown": func(t *rapid.T) {
				title := title().Draw(t, "title")

				// 事前条件
				if likeTitle(states, title) {
					t.Skip("already exist book")
				}

				result, err := br.FindBookByTitle(ctx, title)
				if err != nil {
					t.Fatalf("failed to FindBookByTitlteUnkown title: %s err: %s", title, err.Error())
				}

				if len(result) != 0 {
					t.Fatalf("failed to FindBookByAuthorUnkown. expect record not found, but found result: %v", result)
				}
			},
		}

		// 状態に依存するテスト
		reliesOnState := map[string]func(*rapid.T){
			"AddBookExisting": func(t *rapid.T) {
				// まだstateがない
				if len(states) == 0 {
					t.Skip("states is empty")
				}

				isbn := isbnGen(states)
				title := title().Draw(t, "title")
				author := author().Draw(t, "author")

				// 事前条件
				if !hasIsbn(states, isbn) {
					t.Fatalf("states not include generate ISBN %s", isbn)
				}

				// duplicate keyでエラーを期待
				if err := br.AddBook(ctx, isbn, title, author); err == nil {
					t.Fatal("expect error, but not error")
				}
			},
			"AddCopyExisting": func(t *rapid.T) {
				// まだstateがない
				if len(states) == 0 {
					t.Skip("states is empty")
				}

				isbn := isbnGen(states)

				// 事前条件
				if !hasIsbn(states, isbn) {
					t.Fatalf("states not include generate ISBN %s", isbn)
				}

				if err := br.AddCopy(ctx, isbn); err != nil {
					t.Fatalf("failed to AddCopyExisting isbn: %s err: %s", isbn, err.Error())
				}

				// 状態更新
				states[isbn].avail += 1
				states[isbn].owned += 1
			},
			"BorrowCopyAvail": func(t *rapid.T) {
				// まだstateがない
				if len(states) == 0 {
					t.Skip("states is empty")
				}

				isbn := isbnGen(states)

				// 事前条件
				if !hasIsbn(states, isbn) {
					t.Fatalf("states not include generate ISBN %s", isbn)
				}

				if states[isbn].avail == 0 {
					t.Skip("no books to borrow")
				}

				if err := br.BorrowCopy(ctx, isbn); err != nil {
					t.Fatalf("failed to BorrowCopyAvail isbn: %s err: %s", isbn, err.Error())
				}

				// 状態更新
				states[isbn].avail -= 1
			},
			"BorrowCopyUnavail": func(t *rapid.T) {
				// まだstateがない
				if len(states) == 0 {
					t.Skip("states is empty")
				}

				isbn := isbnGen(states)

				// 事前条件
				if !hasIsbn(states, isbn) {
					t.Fatalf("states not include generate ISBN %s", isbn)
				}

				if states[isbn].avail != 0 {
					t.Skip("can borrow book yet")
				}

				if err := br.BorrowCopy(ctx, isbn); err == nil {
					t.Fatal("expected error, but not error")
				}
			},
			"ReturnCopyExisting": func(t *rapid.T) {
				// まだstateがない
				if len(states) == 0 {
					t.Skip("states is empty")
				}

				isbn := isbnGen(states)

				// 事前条件
				if !hasIsbn(states, isbn) {
					t.Fatalf("states not include generate ISBN %s", isbn)
				}

				if states[isbn].avail == states[isbn].owned {
					t.Skip("book is full")
				}

				if err := br.ReturnCopy(ctx, isbn); err != nil {
					t.Fatalf("failed to ReturnCopyExisting isbn: %s err: %s", isbn, err.Error())
				}

				// 状態更新
				states[isbn].avail += 1
			},
			// "ReturnCopyFull": func(t *rapid.T) {
			// 	// まだstateがない
			// 	if len(states) == 0 {
			// 		t.Skip("states is empty")
			// 	}

			// 	isbn := isbnGen(states)

			// 	// 事前条件
			// 	if !hasIsbn(states, isbn) {
			// 		t.Fatalf("states not include generate ISBN %s", isbn)
			// 	}

			// 	if states[isbn].avail != states[isbn].owned {
			// 		t.Skip("book is not full")
			// 	}

			// 	// 本当は貸出がない状態で返却をしようとするとエラーにしたいがそれをするには事前にDB問い合わせが必要
			// 	// やってもいいんだけど今回は手抜きでこのテストは飛ばす
			// 	if err := br.ReturnCopy(ctx, isbn); err != nil {
			// 		t.Fatalf("failed to ReturnCopyFull isbn: %s err: %s", isbn, err.Error())
			// 	}
			// },
			"FindBookByIsbnExists": func(t *rapid.T) {
				// まだstateがない
				if len(states) == 0 {
					t.Skip("states is empty")
				}

				isbn := isbnGen(states)

				// 事前条件
				if !hasIsbn(states, isbn) {
					t.Fatalf("states not include generate ISBN %s", isbn)
				}

				book, err := br.FindBookByIsbn(ctx, isbn)
				if err != nil {
					t.Fatalf("failed to FindBookByIsbnExists isbn: %s err: %s", isbn, err.Error())
				}

				assertBook(t, *states[isbn], book)
			},
			"FindBookByAuthorMatching": func(t *rapid.T) {
				// まだstateがない
				if len(states) == 0 {
					t.Skip("states is empty")
				}

				author := authorGen(t, states)

				// 事前条件
				if !likeAuthor(states, author) {
					t.Fatalf("states not include generate author %s", author)
				}

				_, err := br.FindBookByAuthor(ctx, author)
				if err != nil {
					t.Fatalf("failed to FindBookByAuthorMatching isbn: %s err: %s", author, err.Error())
				}

				// アサーション
				// statesからauthorが部分一致する本情報とDBから取得してきた本情報をソートして完全に一致しているか確認する
				// 心折れたので手抜き
			},
			"FindBookByTitleMatching": func(t *rapid.T) {
				// まだstateがない
				if len(states) == 0 {
					t.Skip("states is empty")
				}

				title := titleGen(t, states)

				// 事前条件
				if !likeTitle(states, title) {
					t.Fatalf("states not include generate title %s", title)
				}

				_, err := br.FindBookByTitle(ctx, title)
				if err != nil {
					t.Fatalf("failed to FindBookByTitleMatching title: %s err: %s", title, err.Error())
				}

				// アサーション
				// statesからtitleが部分一致する本情報とDBから取得してきた本情報をソートして完全に一致しているか確認する
				// 心折れたので手抜き
			},
		}

		t.Repeat(merge(alwaysPossible, reliesOnState))
	})
}
このスクラップは2024/01/07にクローズされました