ソフトウェアテスト手法の一つである property based testing を、今関わっている C++ のプロジェクトでも使ってみたいと思って、調べて見た。
Wikipedia の QuickCheck のリファレンス欄には、C++ 向けに 3 つのフレームワークが載っている。そのうちの一つの RapidCheck が Google Test に対応していると書いてあるので、これを試してみる。(Google Test は C++ 向けのユニットテストフレームワークで、現プロジェクトでも使っているので)
インストール
property based testing とは何の関係もないが、最初に乗り越えなくてはいけない山はインストールだ。今のプロジェクトではビルドツールに Bazel を使っているが、RapidCheck は cmake だ。
Bazel は Google 社内で使われている Blaze のオープンソース版で、「表現できることを強く制限することで、ビルドの再現性とスケーラビリティ・速度を向上させる」という点に特徴があると思う。例えば、automake を使っているプロジェクトをインストールする際のよくある手順は「$ ./configure
が通るまで、エラーメッセージを解読し、必要なライブラリの適切なバージョンをインストールする」ということだが、(よくできた)Bazel プロジェクトでは $ bazel build [target]
するだけで依存関係が(だいたい)解決される。これは Bazel では、(原則として)全ての依存関係をビルドファイルに明示しることが要求されるためで、最初にビルドファイルを書く人はえらい大変だが、その後ビルドする人はとても楽である。
プロジェクト間の依存関係を記述する場合、依存しているプロジェクトが全て Bazel で管理されていれば大した苦労はないのだが、現実世界はとてもそうはなってない。この「Bazel で管理されていないプロジェクトを Bazel に認識させる」というのが一仕事である。(余談だが、同じく Bazel を使っている OSS の Envoy が外部プロジェクトをビルドする際にどうしたかという苦労話がここに書いてある。)
まず WORKSPACE ファイルで rapidcheck の git リポジトリを定義する。
new_git_repository(
name = "rapidcheck",
remote = "https://github.com/emil-e/rapidcheck.git",
commit = "86b4e1aeab2ac8187750fbb03a5db99a617d3d57",
build_file_content = RAPIDCHECK_BUILD,
)
この際、参照する commit が tag を必ず定義する。こうしないと人によって外部プロジェクトの違うバージョンに依存してしまうからだ。また、この外部プロジェクトをどうビルドするかを Bazel がわかるように定義する。それが build_file_content だ。
cc_library(
name = "rapidcheck",
hdrs = glob([
"include/**/*.h",
"include/**/*.hpp",
"extras/gtest/include/**/*.h",
]),
srcs = glob([
"src/**/*.cpp",
"src/**/*.h",
"src/**/*.hpp",
]),
includes = [
"include",
"extras/gtest/include",
],
visibility = ["//visibility:public"],
)
このビルドルールをどのように作ったかというと、・・・手作業である。RapidCheck の CMake ルール を読んで、こんな感じで動くかなと試行錯誤しつつ翻訳していく。とても面倒な作業なうえ、このビルドルールが正しい保証はどこにもない。(というか正しくない。boost や gmock 拡張は今回不要だったのでビルドしていない)。Bazel 開発コミュニティにはぜひ(不完全でいいので) CMake -> Bazel 翻訳ツールを作って欲しい。
property based testing のための準備
ようやく RapidCheck そのものをテストしてみる。
まずはテストしたいコードを書く。適当にバグってるコードとして、このような関数を用意した。
bool is_odd(int n) {
return (n / 2) * 2 < n;
}
「整数 n
が奇数なら true
を返す」関数のつもりだが、n
が負数の時に正しく動かない。まず普通にユニットテストを書く。
# include <gtest/gtest.h>
# include "numbers.h"
TEST(NumbersTest, Odd)
{
ASSERT_TRUE(is_odd(3));
ASSERT_FALSE(is_odd(2));
}
このテストはパスする。
$ bazel test //test:unit
INFO: Analysed target //test:unit (0 packages loaded).
INFO: Found 1 test target...
Target //test:unit up-to-date:
bazel-bin/test/unit
INFO: Elapsed time: 1.626s, Critical Path: 1.49s
INFO: 3 processes, darwin-sandbox.
INFO: Build completed successfully, 4 total actions
//test:unit PASSED in 0.1s
Executed 1 out of 1 test: 1 test passes.
念のため、実装が期待通りにバグっていることを確認するために、ASSERT_TRUE(is_odd(-3));
にすると・・・。
$ bazel test //test:unit
INFO: Analysed target //test:unit (0 packages loaded).
INFO: Found 1 test target...
FAIL: //test:unit (see /private/var/tmp/_bazel_ken.kawamoto/e7e417362da6cc84f68273ad2f733bfb/execroot/rapidcheck_bazel/bazel-out/darwin-fastbuild/testlogs/test/unit/test.log)
INFO: From Testing //test:unit:
==================== Test output for //test:unit:
Running main() from gmock_main.cc
[==========] Running 1 test from 1 test case.
[----------] Global test environment set-up.
[----------] 1 test from NumbersTest
[ RUN ] NumbersTest.Odd
test/unit.cpp:6: Failure
Value of: is_odd(-3)
Actual: false
Expected: true
[ FAILED ] NumbersTest.Odd (0 ms)
[----------] 1 test from NumbersTest (0 ms total)
[----------] Global test environment tear-down
[==========] 1 test from 1 test case ran. (0 ms total)
[ PASSED ] 0 tests.
[ FAILED ] 1 test, listed below:
[ FAILED ] NumbersTest.Odd
1 FAILED TEST
================================================================================
Target //test:unit up-to-date:
bazel-bin/test/unit
INFO: Elapsed time: 1.516s, Critical Path: 1.37s
INFO: 3 processes, darwin-sandbox.
INFO: Build completed, 1 test FAILED, 4 total actions
//test:unit FAILED in 0.1s
/private/var/tmp/_bazel_ken.kawamoto/e7e417362da6cc84f68273ad2f733bfb/execroot/rapidcheck_bazel/bazel-out/darwin-fastbuild/testlogs/test/unit/test.log
Executed 1 out of 1 test: 1 fails locally.
property based test を書く
property based testing の利点は、上のようにテストケースを人間が書かなくて良いことだ。人間は関数が満たす性質を記述し、フレームワークが適切なテストケースを生成してくれる。
この場合、「id_odd(n)
が true
となるような n
は 2 で割り切れない」という性質を記述する。(だいぶ馬鹿馬鹿しいけど、良いサンプルが思いつかなかったので・・・。)
# include <gtest/gtest.h>
# include <rapidcheck/gtest.h>
# include "numbers.h"
RC_GTEST_PROP(NumbersTest, Odd, (int n))
{
RC_ASSERT(is_odd(n) == (n % 2 != 0));
}
上に載せた普通のユニットテストを比べてマクロの名前が変わっている。TEST
-> RC_GTEST_PROP
, ASSERT_XXX
-> RC_ASSERT
という感じだ。RC_GTEST_PROP
の最後の引数は、このテストケースがの入力のタプルで、今回は int
が一つだけとなる。
さて、このテストを走らせると・・・、
$ bazel test //test:rapidcheck
INFO: Analysed target //test:rapidcheck (0 packages loaded).
INFO: Found 1 test target...
FAIL: //test:rapidcheck (see /private/var/tmp/_bazel_ken.kawamoto/e7e417362da6cc84f68273ad2f733bfb/execroot/rapidcheck_bazel/bazel-out/darwin-fastbuild/testlogs/test/rapidcheck/test.log)
INFO: From Testing //test:rapidcheck:
==================== Test output for //test:rapidcheck:
Running main() from gmock_main.cc
[==========] Running 1 test from 1 test case.
[----------] Global test environment set-up.
[----------] 1 test from NumbersTest
[ RUN ] NumbersTest.Odd
Using configuration: seed=9637502639840697227
external/rapidcheck/extras/gtest/include/rapidcheck/gtest.h:29: Failure
Failed
Falsifiable after 3 tests
std::tuple<int>:
(-1)
test/rapidcheck.cpp:7:
RC_ASSERT(is_odd(n) == (n % 2 != 0))
Expands to:
false == true
[ FAILED ] NumbersTest.Odd (1 ms)
[----------] 1 test from NumbersTest (1 ms total)
[----------] Global test environment tear-down
[==========] 1 test from 1 test case ran. (1 ms total)
[ PASSED ] 0 tests.
[ FAILED ] 1 test, listed below:
[ FAILED ] NumbersTest.Odd
1 FAILED TEST
Some of your RapidCheck properties had failures. To reproduce these, run with:
RC_PARAMS="reproduce=B8gT11mYlJ3cUV2c09yTkR2ibgZ2Wm0vFu4GYmtlJ9bhLuBmZbZS_W4ibgZ2Wm0vFSAADIAAAAAA"
================================================================================
Target //test:rapidcheck up-to-date:
bazel-bin/test/rapidcheck
INFO: Elapsed time: 0.263s, Critical Path: 0.12s
INFO: 1 process, darwin-sandbox.
INFO: Build completed, 1 test FAILED, 2 total actions
//test:rapidcheck FAILED in 0.1s
/private/var/tmp/_bazel_ken.kawamoto/e7e417362da6cc84f68273ad2f733bfb/execroot/rapidcheck_bazel/bazel-out/darwin-fastbuild/testlogs/test/rapidcheck/test.log
Executed 1 out of 1 test: 1 fails locally.
と失敗する。この部分
Falsifiable after 3 tests
std::tuple<int>:
(-1)
から「3 つのテストケースがパスしたのち、-1 を与えたら失敗した」とわかる。
RC_PARAMS
という環境変数をセットすると、失敗するケースを再現できる。
$ bazel test //test:rapidcheck --verbose_failures --test_env RC_PARAMS="reproduce=B8gT11mYlJ3cUV2c09yTkR2ibgZ2Wm0vFu4GYmtlJ9bhLuBmZbZS_W4ibgZ2Wm0vFSAADIAAAAAA"INFO: Analysed target //test:rapidcheck (0 packages loaded).
INFO: Found 1 test target...
FAIL: //test:rapidcheck (see /private/var/tmp/_bazel_ken.kawamoto/e7e417362da6cc84f68273ad2f733bfb/execroot/rapidcheck_bazel/bazel-out/darwin-fastbuild/testlogs/test/rapidcheck/test.log)
INFO: From Testing //test:rapidcheck:
==================== Test output for //test:rapidcheck:
Running main() from gmock_main.cc
[==========] Running 1 test from 1 test case.
[----------] Global test environment set-up.
[----------] 1 test from NumbersTest
[ RUN ] NumbersTest.Odd
Using configuration: reproduce=B8gT11mYlJ3cUV2c09yTkR2ibgZ2Wm0vFu4GYmtlJ9bhLuBmZbZS_W4ibgZ2Wm0vFSAADIAAAAAA seed=12472948832513890380
external/rapidcheck/extras/gtest/include/rapidcheck/gtest.h:29: Failure
Failed
Falsifiable after 1 tests
std::tuple<int>:
(-1)
...
というように、RapidCheck を Bazel & Google Test と組み合わせて動かせたので、次はもっと現実的なケースを考えてみたい。