「実践プロパティベーステスト」に登場する書籍貸出システムのプロパティベーステスト(PBT)をGoで書いてみたよ
「実践プロパティベーステスト」もうみなさんは読みましたか?プロパティベーステストについて日本語で書かれた貴重な書籍で、ラムダノートさんから出版されています。
本記事は書籍内に登場する書籍貸出システムを通してGoでどのようにプロパティベーステストを書けるのかを検証したことのまとめです。
書籍貸出システムの例は書籍の内容の中でも重要度の高いステートフルプロパティの話ですが書籍内のサンプルコードは全てErlangとElixirで書かれているため読み慣れていない方はなかなか理解するのに苦労すると思います。(わたしはだいぶ苦労しました。)Goで書くことで書籍の内容の理解に少しでもつながればいいなと思います。
本記事の成果物
「実践プロパティベーステスト」を読んでる時のメモ
対象読者
- Goの基本文法がわかる人
- 実践プロパティベーステストを読んだけど理解がいまいちな人
- 実践プロパティベーステストをこれから買おうか悩んでる人
- プロパティベーステストに興味がある人
- Goでプロパティベーステストを書きたい人
プロパティベーステストについて
わたしたちが普段書いているような実装の挙動を確認するようなテストは事例テスト(Example Based Testing: 以降EBTと呼ぶ)と呼ばれ、プロパティベーステスト(Propaty Based Testing: 以降PBTと呼ぶ)はコンピューターの力を使い非常に多くのパターンをテストするテスト手法です。
PBTを使うことであらゆるエッジケースを網羅することができ、事前にプログラムのバグを検知することができます。もとは関数型言語であるHaskellのQuickCheckが由来でいろいろな言語でPBTを書くためのフレームワークが作成されています。
なぜプロパティベーステストを学ぶのか
これはFindyさんのイベントでtwadaさんがおっしゃっていたことですが一般的に書かれるEBTによるテストはテストというよりは確認の意味合いが強いのでテストというには少し違う。PBTは何かを確認するというよりも開発者もわからない未知の不具合を探索するために書く。なのでEBTとPBTは互いに競合するものではなく補完しあうもの。
Test = Checking(確認) + Explorer(探索)
筆者は単体テストの質について考えた時に、テストがしやすい関数設計をすることが大事であり、そのことを踏まえた上でアプリケーション全体の設計をする必要があると思っています。テストがしやすい関数と言うと関数型プログラミングで設計されるような入力に対して出力が明確な関数を作りたくなります。そうすると、関数型プログラミングの世界で広く使われるPBTを書きたくなります。
そうして、筆者はPBTについて学びたいと思い立ったのですが、PBTはEBTに置き換わるもので可能な限りPBTを書く方がいいと思っていたのですが上述したようにそれは誤りなようです。
プログラムで何ができて何ができないのかをしっかりEBTでテストしたうえで、まだ見ぬ未知の不具合を探し出すのにPBTを書くことでプログラムの信頼性を大きく向上させることができます。
プロパティベーステストは難しい
PBTを書くことで信頼性の高いプログラムを書けることはわかったのでいざPBTを書こうとしてもおそらく多くの人がとまどうと思います。PBTはジェネレーターと呼ばれるさまざまな値を生成するものを使い、プログラムがどうあるべきかということをプロパティとして書きます。このプロパティの書き方が難しいと筆者は思っています。
例えば、Goのsliceの最大値を返すBiggest()
という関数について考えてみます。
func Biggest(list []int) (max int) {
for _, n := range list {
if n > max {
max = n
}
}
return max
}
このPBTを書くと以下のように書けます。(PBTを書くのにrapidと呼ばれるモジュールを使用していますがこれについては後述します。)
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)
}
})
}
ここで
sort.Ints(list)
expect := list[len(list)-1]
このロジックが思いつけばそんなに頭を悩ませる必要はないですが、これが思いつかないとどう検証していいのかわからなくなってしまう人が多いでしょう。これは書籍内でモデル化として紹介されているプロパティを書くためのテクニックです。
PBTに慣れていないでプロパティを書くとテスト対象と同じロジックで検証するようなプロパティを気づいたら書いてしまい、これはテストをする意味があるのか?となってしまいます。(わたしはなりました。)
上述したモデル化はテスト対象とは異なる別のロジックで実装をもう一つ作り、テスト対象の結果と検証するテクニックです。両方のロジックが誤っているということも考えられますがどちらかのロジックが誤っていればテストが失敗するので十分に信頼できるテストが書けます。
こういったテクニックを知らずにPBTを書くのは困難だと筆者は思うので、何かしらで一度PBTについて体系的に学んだ方が良いでしょう。
ちなみに、上記のBiggest()
のテストには不具合があり、テストを実行すると以下のようなエラーが発生します。
[rapid] panic after 0 tests: runtime error: index out of range [-1]
これはジェネレーターが空のsliceを作成すると
expect := list[len(list)-1]
ここでpanicするからです。この場合、いくつか対応は考えられますがジェネレーターで空のsliceを作成しないようにするなどが考えられます。
また、空のsliceを作成しないようにしてもテストはまだ失敗してしまいます。
[rapid] draw list: []int{-1}
これは[]int{-1}
というsliceがBiggest()
の引数に渡った時にzero値の0が関数の戻り値となってしまうためです。これは明確に実装バグなのでBiggest()
を修正すべきです。
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
}
このようにPBTはテストと修正のサイクルをテストが成功するまで何回も回すことになります。この例では関数のロジックがシンプルなのでちゃんとEBTのテストを書いていれば気づくことができたかもしれませんが、より複雑なプログラムをテストするときにPBTは強力なテスト手法となるということが少しは感じれたでしょうか??
Goでプロパティベーステストを書くには
GoでPBTを書くためには執筆時点で以下の2種類の選択肢があります。
違いとしてはrapidの方が後発でジェネリクスを使用して作られているため型安全かつシンプルなモジュールとなっています。一方、gopterはrapidよりも機能が豊富ですがintreface{}
が多く使われているためキャストする場面が多くなります。詳しくは両者を比較した以下のスクラップをご参照ください。
今回はシンプルに利用したかったためrapidを使用してPBTを書いていきます。
go get pgregory.net/rapid
書籍貸出システムの実装
書籍貸出システムはDBを用いて実装するため、今回はテスト時のDB環境にdockertest、テーブル作成のマイグレーションにgolang-migrate、SQLをGoコードとして扱うためにsqlcを採用しています。
-- 書籍データベース用のテーブルをセットアップする -- :setup_table_books
CREATE TABLE books (
isbn varchar(20) PRIMARY KEY,
title varchar(256) NOT NULL,
author varchar(256) NOT NULL,
owned smallint DEFAULT 0,
available smallint DEFAULT 0
);
-- 本を追加する
-- name: AddBook :exec
INSERT INTO books (isbn, title, author, owned, available)
VALUES (?, ?, ?, ?, ?);
-- 既存の本を1冊追加する
-- name: AddCopy :execresult
UPDATE books SET
owned = owned + 1,
available = available + 1
WHERE
isbn = ?;
-- 本を1冊借りる
-- name: BorrowCopy :execresult
UPDATE books SET available = available - 1 WHERE isbn = ? AND available > 0;
-- 本を返却する
-- name: ReturnCopy :execresult
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 :many
SELECT * FROM books WHERE title LIKE ?;
使うテーブルはbooks
というテーブルのみで、本の追加、貸出、返却といった操作用のクエリを定義しています。
このクエリをもとにGoコードを作成します。
sqlc generate
生成されるコードは以下のようなものです。
query.sql.go
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.24.0
// source: query.sql
package infrastructure
import (
"context"
"database/sql"
)
const addBook = `-- name: AddBook :exec
INSERT INTO books (isbn, title, author, owned, available)
VALUES (?, ?, ?, ?, ?)
`
type AddBookParams struct {
Isbn string
Title string
Author string
Owned sql.NullInt32
Available sql.NullInt32
}
// 本を追加する
func (q *Queries) AddBook(ctx context.Context, arg AddBookParams) error {
_, err := q.db.ExecContext(ctx, addBook,
arg.Isbn,
arg.Title,
arg.Author,
arg.Owned,
arg.Available,
)
return err
}
const addCopy = `-- name: AddCopy :execresult
UPDATE books SET
owned = owned + 1,
available = available + 1
WHERE
isbn = ?
`
// 既存の本を1冊追加する
func (q *Queries) AddCopy(ctx context.Context, isbn string) (sql.Result, error) {
return q.db.ExecContext(ctx, addCopy, isbn)
}
const borrowCopy = `-- name: BorrowCopy :execresult
UPDATE books SET available = available - 1 WHERE isbn = ? AND available > 0
`
// 本を1冊借りる
func (q *Queries) BorrowCopy(ctx context.Context, isbn string) (sql.Result, error) {
return q.db.ExecContext(ctx, borrowCopy, isbn)
}
const findByAuthor = `-- name: FindByAuthor :many
SELECT isbn, title, author, owned, available FROM books WHERE author LIKE ?
`
// 本を見つける
func (q *Queries) FindByAuthor(ctx context.Context, author string) ([]Book, error) {
rows, err := q.db.QueryContext(ctx, findByAuthor, author)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Book
for rows.Next() {
var i Book
if err := rows.Scan(
&i.Isbn,
&i.Title,
&i.Author,
&i.Owned,
&i.Available,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const findByIsbn = `-- name: FindByIsbn :one
SELECT isbn, title, author, owned, available FROM books WHERE isbn = ?
`
func (q *Queries) FindByIsbn(ctx context.Context, isbn string) (Book, error) {
row := q.db.QueryRowContext(ctx, findByIsbn, isbn)
var i Book
err := row.Scan(
&i.Isbn,
&i.Title,
&i.Author,
&i.Owned,
&i.Available,
)
return i, err
}
const findByTitle = `-- name: FindByTitle :many
SELECT isbn, title, author, owned, available FROM books WHERE title LIKE ?
`
func (q *Queries) FindByTitle(ctx context.Context, title string) ([]Book, error) {
rows, err := q.db.QueryContext(ctx, findByTitle, title)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Book
for rows.Next() {
var i Book
if err := rows.Scan(
&i.Isbn,
&i.Title,
&i.Author,
&i.Owned,
&i.Available,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const returnCopy = `-- name: ReturnCopy :execresult
UPDATE books SET available = available + 1 WHERE isbn = ?
`
// 本を返却する
func (q *Queries) ReturnCopy(ctx context.Context, isbn string) (sql.Result, error) {
return q.db.ExecContext(ctx, returnCopy, isbn)
}
生成したコードを用いて以下のようなRepositoryを作成します。
package book
import (
"context"
"database/sql"
"fmt"
"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 WithOwned(owned int32) addBookOptions {
return func(o *addBookOption) {
o.Owned = sql.NullInt32{Int32: owned, Valid: true}
}
}
func WithAvail(avail int32) addBookOptions {
return func(o *addBookOption) {
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 checkAffected(result sql.Result) error {
rows, err := result.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return fmt.Errorf("not affected")
}
return nil
}
func (br *bookRepository) AddCopy(ctx context.Context, isbn string) error {
result, err := br.q.AddCopy(ctx, isbn)
if err != nil {
return err
}
return checkAffected(result)
}
func (br *bookRepository) BorrowCopy(ctx context.Context, isbn string) error {
result, err := br.q.BorrowCopy(ctx, isbn)
if err != nil {
return err
}
return checkAffected(result)
}
func (br *bookRepository) ReturnCopy(ctx context.Context, isbn string) error {
result, err := br.q.ReturnCopy(ctx, isbn)
if err != nil {
return err
}
return checkAffected(result)
}
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+"%")
}
ステートフルプロパティテスト
いよいよ本題のステートフルプロパティを書いていきます。簡単に説明しておくとPBTにはステートレスプロパティと状態を管理するステートフルプロパティの2種類が存在します。
今回テストする書籍貸出システムはDBのレコードを更新・取得するため、テスト対象のRepositoryの関数を実行する度に状態が変化していくことになります。こういったプログラムのPBTを書く場合、ステートフルプロパティを書くことになります。
ステートフルプロパティはいわゆる副作用が発生するようなプログラムをテストしたい時に利用されるため必然的に結合テストなどで使われることが多いでしょう。
今回はrapidのステートフルプロパティを書くためのrapid.T.Repeat()
を使用してPBTを書いていきます。書き方の例として公式ドキュメントの例を以下に記載しておきます。
Example(Queue)
package main
import (
"testing"
"pgregory.net/rapid"
)
// Queue implements integer queue with a fixed maximum size.
type Queue struct {
buf []int
in int
out int
}
func NewQueue(n int) *Queue {
return &Queue{
buf: make([]int, n+1),
}
}
// Precondition: Size() > 0.
func (q *Queue) Get() int {
i := q.buf[q.out]
q.out = (q.out + 1) % len(q.buf)
return i
}
// Precondition: Size() < n.
func (q *Queue) Put(i int) {
q.buf[q.in] = i
q.in = (q.in + 1) % len(q.buf)
}
func (q *Queue) Size() int {
return (q.in - q.out) % len(q.buf)
}
func testQueue(t *rapid.T) {
n := rapid.IntRange(1, 1000).Draw(t, "n") // maximum queue size
q := NewQueue(n) // queue being tested
var state []int // model of the queue
t.Repeat(map[string]func(*rapid.T){
"get": func(t *rapid.T) {
if q.Size() == 0 {
t.Skip("queue empty")
}
i := q.Get()
if i != state[0] {
t.Fatalf("got invalid value: %v vs expected %v", i, state[0])
}
state = state[1:]
},
"put": func(t *rapid.T) {
if q.Size() == n {
t.Skip("queue full")
}
i := rapid.Int().Draw(t, "i")
q.Put(i)
state = append(state, i)
},
"": func(t *rapid.T) {
if q.Size() != len(state) {
t.Fatalf("queue size mismatch: %v vs expected %v", q.Size(), len(state))
}
},
})
}
// Rename to TestQueue(t *testing.T) to make an actual (failing) test.
func main() {
var t *testing.T
rapid.Check(t, testQueue)
}
ジェネレーター
まずはジェネレーターを作成していきます。ジェネレーターは書籍のISBN
、Title
、Author
の三種類が必要です。TitleとAuthorはrapidで用意されているデフォルトジェネレーターで問題なさそうです。ISBNの方はいくつかやり方が考えられそうですが正規表現を使用して生成してみます。
// ジェネレーター
// 仕様に合わせて生成する文字列は調整 今回は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.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]")
}
# ISBNジェネレーターで生成される値
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
ちなみに書籍で記載されているISBNジェネレーターのErlangの実装は以下のようになっています。
isbn() ->
?LET(ISBN,
[oneof(["978","979"]),
?LET(X, range(0,9999), integer_to_list(X)),
?LET(X, range(0,9999), integer_to_list(X)),
?LET(X, range(0,999), integer_to_list(X)),
frequency([{10, range($0,$9)}, {1, "X"}])],
iolist_to_binary(lists:join("-", ISBN))).
これを完全にrapidで再現するのは難しかったため少し挙動は異なります。もし、完全に再現したい場合rapidではなくgopterであれば再現できるかもしれません。
状態管理
テスト実行中に管理する状態を以下のように作成します。
// 書籍情報の状態
type _book struct {
isbn string
author string
title string
owned int32
avail int32
}
func NewBook(isbn, author, title string, owned, avail int32) *_book {
return &_book{isbn, author, title, owned, avail}
}
// 状態管理
type states = map[string]*_book
定義したstatesのkeyは書籍のISBNを想定していて、valueは書籍情報を表しています。テストで書籍が追加されればこのmapに書籍が追加されていき、貸出・返却といった更新操作が行われればmapに格納されている書籍情報も連動して更新されていきます。
ここで定義したstatesはDBのbooksテーブルと完全に連動するようにテストを作成していきます。
コマンドの定義
想定される操作を全て定義していきます。書籍内には書かれていませんがプロパティ内に定義する操作の一覧をコマンドと呼ぶこととします。ここで注意したいのが想定される全ての操作=テスト対象の全ての関数の実行
ではないことです。書籍管理システムの仕様として期待されている挙動は書籍内で以下のように書かれています。
- 「まだシステムに登録されていない本を追加する」に期待されるのは「成功」
- 「すでにシステムに登録されている本を追加する」に期待されるのは「失敗」
- 「すでにシステムに登録されている本の在庫を 1 冊追加する」に期待されるのは「成功(すぐに在庫が 1 冊増える)」
- 「まだシステムに登録されていない本の在庫を 1 冊追加する」に期待されるのは「エラー」
- 「システムに登録されていて利用可能な在庫がある本を貸出する」に期待されるのは「在庫が 1 冊減る」
- 「システムに登録されているが利用可能な在庫がない本を貸出する」に期待されるのは「貸出不能のエラー」
- 「システムに登録されていない本を貸出する」に期待されるのは「書籍がないというエラー」
- 「システムに登録されている本を返却する」に期待されるのは「在庫を戻す」
- 「システムに登録されてない本を返却する」に期待されるのは「在庫がないというエラー」
- 「システムに登録されていて利用可能な在庫が減っていない本を返却する」に期待されるのも「エラー」
- 「ISBN で本を検索する」に対し「その本がシステムに登録されている場合」に期待されるのは「成功」
- 「ISBN で本を検索する」に対し「その本がシステムに登録されていない場合」に期待されるのは「失敗」
- 「著者名で本を検索する」に対し「著者名の一部または全体と一致する本が少なくとも 1 つ登録されている」に期待されるのは「成功」
- 「書名で検索する」に対し「書名の一部または全部に一致する本が少なくとも 1 つ登録されている」に期待されるのは「成功」
- 「タイトルまたは著者名で検索する」に対し「一致するものがない」に期待されるのは「空の結果」
これが今回定義するプロパティの全てです。見るとわかる通り書籍を追加する
という操作の期待される振る舞いがDBの状態によって成功
するときと失敗
するときに分かれています。つまり、書籍を追加する
という実装と定義するコマンドは決して1対1ではないということです。
ではコマンドを書いてみましょう。とりあえず、statesに依存しない操作を既に作成済みのジェネレーターを使用して以下のように作成します。
// 状態に依存しないテスト
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)
}
},
}
ここで大事なのはテスト対象の実行前、実行後に事前条件もしくは事後条件があれば確認することと、テスト対象の実行後に状態の更新が必要であれば状態を更新することです。もし、状態の更新が漏れるとDBと状態の整合性がとれなくなりテストが失敗するでしょう。
コマンドはこれで全てではなく状態に依存する操作が存在します。例えば、既に書籍が存在する状態で書籍追加を行うといったような操作です。この操作を定義するには状態をもとに値を生成するジェネレーターが必要になりますので以下のようなジェネレーターを追加します。
func isbnGen(states states) string {
return elements(keys(states))
}
func authorGen(t *rapid.T, states states) string {
s := make([]string, 0, len(states))
for _, v := range states {
s = append(s, partial(t, v.author))
}
return elements(s)
}
func titleGen(t *rapid.T, states states) string {
s := make([]string, 0, len(states))
for _, v := range states {
s = append(s, partial(t, v.title))
}
return elements(s)
}
(独自で定義したユーティリティ関数を使用しています。ユーティリティー関数は以下に記載しておきます。)
ユーティリティ関数の詳細
// ユーティリティー / ヘルパー
func keys[K comparable, V any](m map[K]V) []K {
s := make([]K, 0, len(m))
for k := range m {
s = append(s, k)
}
return s
}
// func values[K comparable, V any](m map[K]V) []V {
// s := make([]V, 0, len(m))
// for _, v := range m {
// s = append(s, v)
// }
// return s
// }
func merge[K comparable, V any](m1 map[K]V, m2 map[K]V) map[K]V {
newMap := make(map[K]V, len(m1)+len(m2))
for k, v := range m1 {
newMap[k] = v
}
for k, v := range m2 {
newMap[k] = v
}
return newMap
}
// sliceの要素が空だとpanicする
func elements[T any](s []T) T {
switch len(s) {
case 0:
panic("slice is empty")
case 1:
return s[0]
}
return s[rand.Intn(len(s)-1)]
}
func partial(t *rapid.T, str string) string {
l := len([]rune(str))
start := rapid.IntRange(0, l-1).Draw(t, "start")
end := rapid.IntRange(start+1, l).Draw(t, "end")
return string([]rune(str)[start:end])
}
// func TestPartial(t *testing.T) {
// rapid.Check(t, func(t *rapid.T) {
// str := "d0"
// for i := 0; i < 10; i++ {
// fmt.Println(partial(t, str))
// }
// })
// }
func hasIsbn(states states, isbn string) bool {
keys := keys(states)
return slices.Contains(keys, isbn)
}
func likeAuthor(states states, author string) bool {
if author == "" {
return false
}
for _, v := range states {
if strings.Contains(strings.ToLower(v.author), strings.ToLower(author)) {
return true
}
}
return false
}
func likeTitle(states states, title string) bool {
if title == "" {
return false
}
for _, v := range states {
if strings.Contains(strings.ToLower(v.title), strings.ToLower(title)) {
return true
}
}
return false
}
作成したジェネレーターを使用し状態に依存したコマンドを以下のように作成します。
// 状態に依存するテスト
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から取得してきた本情報をソートして完全に一致しているか確認する
// 心折れたので手抜き
},
一部コマンドとアサーションを途中で心折れて省略してます😓
最終的なテストコードは以下のようになりました。
最終的なテストコード
package book_test
import (
"context"
"database/sql"
"errors"
"go-pbt/book"
"go-pbt/infrastructure"
container "go-pbt/internal"
"log"
"math/rand"
"os"
"slices"
"strings"
"testing"
"unicode"
"pgregory.net/rapid"
)
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)
}
// 書籍情報の状態
type _book struct {
isbn string
author string
title string
owned int32
avail int32
}
func NewBook(isbn, author, title string, owned, avail int32) *_book {
return &_book{isbn, author, title, owned, avail}
}
// 状態管理
type states = map[string]*_book
// ユーティリティー / ヘルパー
func keys[K comparable, V any](m map[K]V) []K {
s := make([]K, 0, len(m))
for k := range m {
s = append(s, k)
}
return s
}
// func values[K comparable, V any](m map[K]V) []V {
// s := make([]V, 0, len(m))
// for _, v := range m {
// s = append(s, v)
// }
// return s
// }
func merge[K comparable, V any](m1 map[K]V, m2 map[K]V) map[K]V {
newMap := make(map[K]V, len(m1)+len(m2))
for k, v := range m1 {
newMap[k] = v
}
for k, v := range m2 {
newMap[k] = v
}
return newMap
}
// sliceの要素が空だとpanicする
func elements[T any](s []T) T {
switch len(s) {
case 0:
panic("slice is empty")
case 1:
return s[0]
}
return s[rand.Intn(len(s)-1)]
}
func partial(t *rapid.T, str string) string {
l := len([]rune(str))
start := rapid.IntRange(0, l-1).Draw(t, "start")
end := rapid.IntRange(start+1, l).Draw(t, "end")
return string([]rune(str)[start:end])
}
// func TestPartial(t *testing.T) {
// rapid.Check(t, func(t *rapid.T) {
// str := "d0"
// for i := 0; i < 10; i++ {
// fmt.Println(partial(t, str))
// }
// })
// }
func hasIsbn(states states, isbn string) bool {
keys := keys(states)
return slices.Contains(keys, isbn)
}
func likeAuthor(states states, author string) bool {
if author == "" {
return false
}
for _, v := range states {
if strings.Contains(strings.ToLower(v.author), strings.ToLower(author)) {
return true
}
}
return false
}
func likeTitle(states states, title string) bool {
if title == "" {
return false
}
for _, v := range states {
if strings.Contains(strings.ToLower(v.title), strings.ToLower(title)) {
return true
}
}
return false
}
// ジェネレーター
// 仕様に合わせて生成する文字列は調整 今回は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 isbnGen(states states) string {
return elements(keys(states))
}
func authorGen(t *rapid.T, states states) string {
s := make([]string, 0, len(states))
for _, v := range states {
s = append(s, partial(t, v.author))
}
return elements(s)
}
func titleGen(t *rapid.T, states states) string {
s := make([]string, 0, len(states))
for _, v := range states {
s = append(s, partial(t, v.title))
}
return elements(s)
}
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))
})
}
func assertBook(t *rapid.T, state _book, record infrastructure.Book) {
t.Helper()
if state.isbn != record.Isbn {
t.Fatalf("different book.isbn state.isbn %s record.isbn %s", state.isbn, record.Isbn)
}
if state.title != record.Title {
t.Fatalf("different book.title state.title %s record.title %s", state.title, record.Title)
}
if state.author != record.Author {
t.Fatalf("different book.author state.author %s record.author %s", state.author, record.Author)
}
if state.owned != record.Owned.Int32 || !record.Owned.Valid {
t.Fatalf("different book.owned state.owned %d record.owned %d", state.owned, record.Owned.Int32)
}
if state.avail != record.Available.Int32 || !record.Available.Valid {
t.Fatalf("different book.avail state.avail %d record.avail %d", state.avail, record.Available.Int32)
}
}
これでGoでステートフルプロパティを使用したPBTが書けました!!
おわりに
本記事では詳細なPBTについての説明、rapidやgopterの書き方の説明は行いませんでしたがPBTの魅力や雰囲気は伝わったでしょうか?
わたし自身PBTを学んで日が浅いですし、実際のプロダクトで利用まではできていませんがPBTを利用する価値は十分にあると感じています。
ステートフルプロパティは複雑になりがちで難易度は高いですがステートレスプロパティであれば割と気軽に導入して使用していってもいいんじゃないかなと思います。わたしも使えそうであれば積極的に使っていきたいなと思っています。
わたしはラムダノートさんの回し者でも何でもないですがもし本記事でPBTに興味を持っていただけたならば「実践プロパティベーステスト」を購入することをお勧めいたします。難易度は高いですが非常に貴重な技術書だと思います!
今回は以上です🐼
Discussion