プロパティベーステストをGolangでやってみた
はじめに
こんにちは、クラウドエース Backend Division 所属の前山です。
「最近プロパティベーステストの情報をよく見かけるけど、...」というコメントを貰い、ちょっと調べてみるか、という気持ちになったので調べ、せっかくなので記事としてまとめてみました。
プロパティベーステストって何?という方にとって参考になれば幸いです。
前提
本記事では、プロパティベーステストを検証するにあたり、Goとrapidを使用しています。
プロパティベーステストとは
プロパティベーステスト(Property based testing)は、テスト対象のコードが満たすべき特定の特性や条件(プロパティ)が、さまざまな入力値に対して一貫して成り立つかどうかを確認する方法です。
プロパティベーステストと通常のユニットテストの違いを確認するために「正の整数を渡したときにはtrue、負の整数を渡したらfalseを返す」というテストを実装してみましょう。
まず、通常のユニットテストでは、具体的な代表値(例えば 5, -3, 0)に対する関数の出力をチェックします。以下はその例です。
package main
import (
"testing"
)
func isPositive(n int) bool {
return n > 0
}
// ユニットテストの例
func TestIsPositive(t *testing.T) {
// 整数5を渡したとき
if !isPositive(5) {
t.Errorf("isPositive(5) should be true")
}
// 整数-3を渡したとき
if isPositive(-3) {
t.Errorf("isPositive(-3) should be false")
}
// 整数0を渡したとき
if isPositive(0) {
t.Errorf("isPositive(0) should be false")
}
}
一方、プロパティベーステストでは、同じ関数に対して多様な入力を自動生成し、プロパティが保持されるかを確認します。
package main
import (
"testing"
"pgregory.net/rapid"
)
func isPositive(n int) bool {
return n > 0
}
// プロパティベーステストの例(rapidを使用)
func TestIsPositiveProperty(t *testing.T) {
rapid.Check(t, func(t *rapid.T) {
n := rapid.Int().Draw(t, "n") // 任意の整数を生成
if n > 0 {
if !isPositive(n) {
t.Error("Expected true for positive number")
}
} else {
if isPositive(n) {
t.Error("Expected false for non-positive number")
}
}
})
}
次が実行例です(※わかりやすさの向上のため、上記サンプルコードに生成値を表示する処理を追加し、実行しています)。
# プロパティベーステストを10回実行するという意味のコマンドです。
# これにより10回ランダムで生成した値を入力値としたテストを実行できます。
$ go test -rapid.checks=10 -v
=== RUN TestIsPositiveProperty
入力値: -231
入力値: -7
入力値: 93
入力値: 3
入力値: 310
入力値: 1
入力値: -484539535064925
入力値: 58094
入力値: -2
入力値: -31840
positive_test.go:16: [rapid] OK, passed 10 tests (75.042µs)
--- PASS: TestIsPositiveProperty (0.00s)
PASS
ok lab-rapid 0.137s
このように、プロパティベーステストは通常のユニットテストよりも入力の範囲を広げ、コードの振る舞いをより広範囲にわたって検証することができます。
これにより、通常のユニットテストでは見過ごされがちなエッジケースや隠れたバグを発見する可能性をあげることができます。
なぜプロパティベーステストが必要なのか
プロパティベーステストを使用することで、通常のユニットテストでは達成が難しいレベルでの品質向上が期待できます。
どういうことなのかを理解するために、まず通常のユニットテストにおける品質向上における問題点を見てみましょう。
通常のユニットテストにおける問題点
その主な問題点は、開発者がテストケースを考える必要があるという点にあります。このアプローチには以下のような限界があります。
限られた視点
開発者が考えるテストケースは、開発者の経験や予測に基づいています。これにより、見落とされがちなシナリオやエッジケースが生じる可能性があります。
入力値やシナリオの偏り
開発者によっては意図せずに特定の入力値やシナリオを選ぶ可能性があり、これによりテストの網羅性が損なわれることがあります。
時間と労力
良質なテストケースを考えることは時間も手間もかかります。これは特に複雑なシステムでは顕著になります。
更新の遅れ
ソフトウェアの機能が進化するにつれて、テストケースも更新される必要があります。しかし、テストケースの作成コストにより、これが迅速に行われないことがあります。
プロパティベーステストがもたらす解決策
これに対して、プロパティベーステストは以下のような利点を提供します。
自動化されたテストケース生成
ランダムな入力値を用いることで、開発者が考えるよりも広範囲なシナリオをカバーできます。
エッジケースの探索
ランダムな入力は予期せぬエッジケースを発見するのに役立ち、ソフトウェアの堅牢性を高めます。
効率的なテストプロセス
テストケースの自動生成により、時間と労力を節約できます。
継続的なテスト更新
ソフトウェアの変化に応じて、プロパティベーステストは適応し続けることができます。
プロパティベーステストをやってみる
では、fizzbuzz の仕様をベースにした関数に対して、プロパティベーステストを実装してみます。
今回実装する関数の仕様は以下の通りです。
- 0 以下の数字を入力したときにはエラーと空文字を返す
- 3 の倍数の数字を入力したときには Fizz という文字を返す
- 5 の倍数の数字を入力したときには Buzz という文字を返す
- 3 の倍数かつ5の倍数の数字を入力したときには FizzBuzz という文字を返す
関数本体の実装
package main
import (
"errors"
"fmt"
)
func FizzBuzz(n int) (string, error) {
if n <= 0 {
return "", errors.New("input must be greater than 0")
}
if n%15 == 0 {
return "FizzBuzz", nil
}
if n%3 == 0 {
return "Fizz", nil
}
if n%5 == 0 {
return "Buzz", nil
}
return fmt.Sprint(n), nil
}
テストの実装
package main
import (
"testing"
"fmt"
"pgregory.net/rapid"
)
func TestFizzBuzzRapid(t *testing.T) {
rapid.Check(t, func(t *rapid.T) {
// 整数を生成するプロパティ
n := rapid.Int().Draw(t, "n")
result, err := FizzBuzz(n)
// 0以下の入力の場合、エラーと空文字列が返されることを確認
if n <= 0 {
if err == nil {
t.Errorf("Expected error for n <= 0, got none")
}
if result != "" {
t.Errorf("Expected empty string for n <= 0, got %s", result)
}
return
}
// 正の入力でエラーが発生した場合、テストを失敗とする
if err != nil {
t.Errorf("Unexpected error for n > 0: %v", err)
return
}
// FizzBuzzの各ルールに従った結果が返されることを確認
switch {
case n%3 == 0 && n%5 == 0:
if result != "FizzBuzz" {
t.Errorf("Incorrect result for %d: %s", n, result)
}
case n%3 == 0:
if result != "Fizz" {
t.Errorf("Incorrect result for %d: %s", n, result)
}
case n%5 == 0:
if result != "Buzz" {
t.Errorf("Incorrect result for %d: %s", n, result)
}
default:
if result != fmt.Sprint(n) {
t.Errorf("Incorrect result for %d: %s", n, result)
}
}
})
}
実行します
go test ./... -v
=== RUN TestFizzBuzzRapid
fizzbuzz_test.go:11: [rapid] OK, passed 100 tests (1.581792ms)
--- PASS: TestFizzBuzzRapid (0.00s)
PASS
ok lab-rapid 0.345s
動きました。100ケースの入力値をテストを実行してくれたようです。
ちなみに失敗すると次のような形式で結果を表示してくれます。rapidは親切ですね。
go test ./... -v
=== RUN TestFizzBuzzRapid
fizzbuzz_test.go:11: [rapid] failed after 46 tests: Incorrect result for 15: FizzBuzz
To reproduce, specify -run="TestFizzBuzzRapid" -rapid.failfile="testdata/rapid/TestFizzBuzzRapid/TestFizzBuzzRapid-20231119212453-40114.fail" (or -rapid.seed=15356944119882137616)
Failed test output:
fizzbuzz_test.go:12: [rapid] draw n: 15
fizzbuzz_test.go:34: Incorrect result for 15: FizzBuzz
--- FAIL: TestFizzBuzzRapid (0.00s)
FAIL
FAIL lab-rapid 0.332s
FAIL
プロパティベーステストに対する考察
どういった場面で活用できそうか
少なくともユニットテストで実現し得る全ての範囲で導入は可能だと考えています。
プロパティベーステスト用のフレームワークには、ステートレスな処理に対するテストだけではなく、ステートフルな処理に対する値を生成する機能が搭載されているものもあり、それを考えると相当応用範囲は広いと感じています。
特に品質が必要な処理に対して、優先的に導入する方針が合っているような気がします。
導入に対する懸念点
プロパティベーステストには、利点だけではなく不利な点もあります。
具体的には次の内容です。
学習コスト
プロパティベーステストは、設定する値に対するテストではなくプロパティに対するテストを実施する手法です。そのため開発者には、有用なテストを実施するための有用なプロパティを設計、実装するスキルが求められます。
今回はプロパティ設計の難しさには触れられませんでしたが、筆者が色々調べた限りにおいては決して簡単ではない印象です。
またプロパティベーステストが品質向上において有効に働かないケースもあり得ます。要件に対してどのテスト手法が良いのかを判断できるようになるための学習も必要です。
少なくとも、無邪気に「いますぐ全プロジェクトに導入しよう」とは言えないレベルの難易度の高さだと感じています。
実行時間
入力値を一定数生成するという手法であることから、通常の値を設定するユニットテストと比べて、実行時間がかかる傾向にあります。そのため不用意に色々な箇所に導入してしまうと開発におけるイテレーションの時間を長くしてしまう可能性があります。
イテレーションの時間が長い = 開発効率の低下となるため、どの部分にどの範囲で導入するかは慎重に考える必要があります。
まとめ
色々な懸念はあるものの、プロパティベーステストによってこれまで見落としがちだったテストケースが洗い出されてくれる効果は開発において大きなプラスになると思います。
実践導入のためには、もう少し色々試してみる必要がありそうなので、隙をみて色々試してみたいと思います。
Discussion