「テスト駆動開発入門」の xUnit 実装を C++ でしてみる
ケント・ベック「テスト駆動開発入門」の第 2 部をもとに C++ 版のユニットテストフレームワークを実装する一連の流れをまとめてみた。
xUnitへの第1歩 (第18章)
以下がテストフレームワークをつくるためのTODOリストだそうである。
- テストメソッドを呼び出す
- 最初にsetupを呼び出す
- 最後にteardownを呼び出す
- テストメソッドが失敗してもteardownを呼び出す
- 複数のテストを実行する
- 収集した結果を報告する
そして「テストメソッドが呼ばれたことを記録し確認できるようにする」が最初の戦略と書かれている。
フラグを含むテストケースを作成する。テストするメソッドが呼ばれる前にフラグを false に設定し、メソッド実行後に true にする。
TestCase クラスはメソッドが実行されたかを報告するテストケースのため WasRun とし、フラグは wasRun とする。
フラグのチェックで test.wasRun *1とできる。
#include <iostream> int main() { WasRun test; std::cout << test.wasRun << std::endl; test.testMethod(); std::cout << test.wasRun << std::endl; return 0; }
コンパイルしようとするも WasRun がないため失敗する。
c:\projects\my_test\testtest\main.cc(20) : error C2065: 'WasRun' : 定義されていない識別子です。 (略)
構造体を導入しよう。
struct WasRun {};
メンバーが足りないので、まだコンパイルは通らない。
c:\projects\my_test\testtest\main.cc(22) : error C2228: '.wasRun' の左側はクラス、構造体、共用体でなければなりません
ざっと必要になる実装をいれる。
struct WasRun { bool wasRun; WasRun() : wasRun(false) {} void testMethod() {} };
これでコンパイル・リンクは通り、実行可能となるが結果は 0 0 (いずれも false) のままだ。
0 0
0 1 という結果がほしい。そこで testMethod 内でフラグを変更する。
struct WasRun { bool wasRun; WasRun() : wasRun(false) {} void testMethod() { wasRun = true; } };
期待した結果を得られるようになった。(「やった、グリーンバーだ。」)次にテストメソッドを直接呼ぶのでなく、実際のインターフェース run を呼ぶように変更する。
int main() {
WasRun test;
std::cout << test.wasRun << std::endl;
test.run();
std::cout << test.wasRun << std::endl;
今の実装では run で直接 testMethod を呼ぶように書くことができる。
struct WasRun { bool wasRun; WasRun() : wasRun(false) {} void testMethod() { wasRun = true; } void run() { testMethod(); } };
さて、書籍では関数の名前を渡すことでテストメソッドを変更可能にしているが C/C++ ではこの方法は使えない。代わり関数ポインターを渡すことにする。
struct WasRun { bool wasRun; void (WasRun::*method)(); WasRun(void (WasRun::*method)()) : wasRun(false), method(method) {} void testMethod() { wasRun = true; } void run() { testMethod(); } }; int main() { WasRun test(&WasRun::testMethod); std::cout << test.wasRun << std::endl; test.run(); std::cout << test.wasRun << std::endl;
そしてテスト実行メソッドを変更。
void run() { (this->*method)(); }
メソッドが呼び出されたことの記録、そしてメソッドの動的な呼び出し、ふたつの仕事をしている WasRun を分割する。そのためにテストケーススーパークラスを追加する。
struct TestCase { virtual ~TestCase() {} };
WasRun を TestCase の導出クラスに。
struct WasRun : TestCase {
書籍では name (テストメソッドの名前を記憶する) 変数を TestCase に移動しているが関数ポインターを使用したため、簡単には移動できない。
そこで「型」の情報を持ち運べるようテンプレート引数を TestCase に追加。
template <typename Testee> struct TestCase { virtual ~TestCase() {} }; struct WasRun : TestCase<WasRun> {
ようやく method 変数を TestCase に移動できる。 method 変数の型を何度もタイプする手間を省くため型宣言も追加した。
template <typename Testee> struct TestCase { typedef void (Testee::*method_t)(); method_t method; TestCase(method_t method) : method(method) {} virtual ~TestCase() {} }; struct WasRun : TestCase<WasRun> { bool wasRun; WasRun(method_t method) : wasRun(false), TestCase(method) {} void testMethod() { wasRun = true; } void run() { (this->*method)(); } };
そして WasRun::run を TestCase::run に移動。 (ダウンキャストがみっともない)
void run() { (dynamic_cast<Testee*>(this)->*method)(); }
0 1 が表示されることに注意を払わなくて済むよう、今構築したしくみを利用して TestCaseTest を導入する。
#include <assert.h> template <typename Testee> struct TestCase { typedef void (Testee::*method_t)(); method_t method; TestCase(method_t method) : method(method) {} virtual ~TestCase() {} void run() { (dynamic_cast<Testee*>(this)->*method)(); } }; struct WasRun : TestCase<WasRun> { bool wasRun; WasRun(method_t method) : wasRun(false), TestCase(method) {} void testMethod() { wasRun = true; } }; struct TestCaseTest : TestCase<TestCaseTest> { TestCaseTest(method_t method) : TestCase(method) {} void testRunning() { WasRun test(&WasRun::testMethod); assert(!test.wasRun); test.run(); assert( test.wasRun); } }; int main() { TestCaseTest(&TestCaseTest::testRunning).run();
ここまでの手順でテストメソッドを呼び出すしくみができあがった。
テストメソッドを呼び出す- 最初にsetupを呼び出す
- 最後にteardownを呼び出す
- テストメソッドが失敗してもteardownを呼び出す
- 複数のテストを実行する
- 収集した結果を報告する
テーブルの設定 (第19章)
複数のテストを独立して実行できるようにしたい。そのために setup/teardown の仕組みを導入する。
セットアップのテストを TestCaseTest に追加。
void testSetup() {
WasRun test(&WasRun::testMethod);
test.run();
assert(test.wasSetup);
}
不足しているフラグを WasRun に追加する。
struct WasRun : TestCase<WasRun> { bool wasRun; bool wasSetup; WasRun(method_t method) : wasRun(false), TestCase(method) {} void testMethod() { wasRun = true; } void setup() { wasSetup = true; } };
TestCase が setup を呼び出すよう変更する。
virtual void setup() {} void run() { setup(); (dynamic_cast<Testee*>(this)->*method)(); }
main への testSetup の呼び出しを追加を忘れずに。
TestCaseTest(&TestCaseTest::testSetup).run();
WasRun の wasRun を setup で初期化するようにし、 TestCaseTest の testRunning で事前チェックをなくす。
WasRun(method_t method) : wasSetup(false), TestCase(method) {} void testMethod() { wasRun = true; } void setup() { wasRun = false; wasSetup = true; } }; struct TestCaseTest : TestCase<TestCaseTest> { TestCaseTest(method_t method) : TestCase(method) {} void testRunning() { WasRun test(&WasRun::testMethod); test.run(); assert( test.wasRun); }
setup を使って TestCaseTest の共通部分をフィクスチャーとして切り出す。
オブジェクトの動的廃棄のために auto_ptr を導入し、関連する箇所を . から -> に変更する。
#include <memory> struct TestCaseTest : TestCase<TestCaseTest> { TestCaseTest(method_t method) : TestCase(method) {} std::auto_ptr<WasRun> test; void setup() { test.reset(new WasRun(&WasRun::testMethod)); } void testRunning() { test->run(); assert(test->wasRun); } void testSetup() { test->run(); assert(test->wasSetup); } };
フレームワークが setup を呼び出すようになり、またフィクスチャーを利用して共通の基盤を利用するようになった。
テストメソッドを呼び出す最初にsetupを呼び出す- 最後にteardownを呼び出す
- テストメソッドが失敗してもteardownを呼び出す
- 複数のテストを実行する
- 収集した結果を報告する
完了後の掃除 (第20章)
メソッドの呼び出し順序も記録できるようにするためテストの戦略を変更する。
テストメソッドを呼び出す最初にsetupを呼び出す- 最後にteardownを呼び出す
- テストメソッドが失敗してもteardownを呼び出す
- 複数のテストを実行する
- 収集した結果を報告する
- WasRunでログ文字列を使用する
ログをとるために sstream を利用する。
#include <sstream> struct WasRun : TestCase<WasRun> { bool wasRun; bool wasSetup; std::ostringstream log; WasRun(method_t method) : TestCase(method) {} void testMethod() { wasRun = true; } void setup() { wasRun = false; wasSetup = true; log << "setup "; }
ログを見るようテストを変更する。
void TestCaseTest::testSetup() { test->run(); assert("setup " == test->log.str()); }
wasSetup フラグを消去し、 testMethod でその呼び出しを記録するよう変更する。
struct WasRun : TestCase<WasRun> { bool wasRun; std::ostringstream log; WasRun(method_t method) : TestCase(method) {} void testMethod() { wasRun = true; log << "testMethod "; } void setup() { wasRun = false; log << "setup "; }
この変更でテストが失敗する。というのはログが "setup testMethod " に変わっているからである。ログ確認を修正する。
void TestCaseTest::testSetup() { test->run(); assert("setup testMethod " == test->log.str()); }
testSetup は testRunning のテストも兼ねているので testRunning を消去し testSetup とあわせて testTemplateMethod とする。
struct TestCaseTest : TestCase<TestCaseTest> { TestCaseTest(method_t method) : TestCase(method) {} std::auto_ptr<WasRun> test; void setup() { test.reset(new WasRun(&WasRun::testMethod)); } void testTemplateMethod() { test->run(); assert("setup testMethod " == test->log.str()); } };
呼び出すテストもあわせて変更する。
int main()
{
TestCaseTest(&TestCaseTest::testTemplateMethod).run();
testSetup がなくなったことで TestCaseTest::setup の存在意義がなくなったため消す。
struct TestCaseTest : TestCase<TestCaseTest> { TestCaseTest(method_t method) : TestCase(method) {} std::auto_ptr<WasRun> test; void testTemplateMethod() { std::auto_ptr<WasRun> test(new WasRun(&WasRun::testMethod)); test->run(); assert("setup testMethod " == test->log.str()); } };
WasRun にログ文字列を実装し終え、ようやく準備がととのった。
テストメソッドを呼び出す最初にsetupを呼び出す- 最後にteardownを呼び出す
- テストメソッドが失敗してもteardownを呼び出す
- 複数のテストを実行する
- 収集した結果を報告する
WasRunでログ文字列を使用する
teardown の呼び出しテストを準備する。
void TestCaseTest::testTemplateMethod() { std::auto_ptr<WasRun> test(new WasRun(&WasRun::testMethod)); test->run(); assert("setup testMethod teardown " == test->log.str()); }
teardown の呼び出しを実装する。
template <typename Testee> struct TestCase { typedef void (Testee::*method_t)(); method_t method; TestCase(method_t method) : method(method) {} virtual ~TestCase() {} virtual void setup() {} virtual void teardown() {} void run() { setup(); (dynamic_cast<Testee*>(this)->*method)(); teardown(); } }; struct WasRun : TestCase<WasRun> { bool wasRun; std::ostringstream log; WasRun(method_t method) : TestCase(method) {} void testMethod() { wasRun = true; log << "testMethod "; } void setup() { wasRun = false; log << "setup "; } void teardown() { log << "teardown "; } };
フレームワークを使いながら、ここまで実装した。
テストメソッドを呼び出す最初にsetupを呼び出す最後にteardownを呼び出す- テストメソッドが失敗してもteardownを呼び出す
- 複数のテストを実行する
- 収集した結果を報告する
WasRunでログ文字列を使用する
カウント (第21章)
複数のテストを実行してその結果を返せるようにする、その下準備のために、まずはひとつのテストに対してひとつの結果が返るようにする。
TestCase::run が実行結果を記録する TestResult を返すようにする。
struct TestcaseTest : Testcase<TestcaseTest> { ... void testResult() { WasRun test(&WasRun::testMethod); TestResult result = test.run(); assert("1 run, 0 failed" == result.summary()); } int main() { ... TestcaseTest(&TestcaseTest::testResult).run();
明白な仮実装を入れる。
struct TestResult { std::string summary() const { return "1 run, 0 failed"; } }; template <typename Testee> struct Testcase { ... TestResult run() { setup(); (dynamic_cast<Testee*>(this)->*method)(); teardown(); return TestResult(); }
テストが成功している状態で、少しずつ改良する。
struct TestResult { size_t nrOfRun; TestResult() : nrOfRun(1) {} std::string summary() const { std::ostringstream buf; buf << nrOfRun << " run, 0 failed"; return buf.str(); } };
nrOfRun をテスト実行のたび testStarted を呼ぶことでインクリメントするように変える。
struct TestResult { size_t nrOfRun; TestResult() : nrOfRun(0) {} void testStarted() { ++nrOfRun; } std::string summary() const { std::ostringstream buf; buf << nrOfRun << " run, 0 failed"; return buf.str(); } };
testStarted を呼び出すよう変更する。
template <typename Testee> struct Testcase { ... TestResult run() { TestResult result; result.testStarted(); setup(); (dynamic_cast<Testee*>(this)->*method)(); teardown(); return result; } };
失敗したテストの数も更新されるようにしたい。そこでこの要求にしたがってテストをつくる。
struct TestcaseTest : Testcase<TestcaseTest> { ... void testFailedResult() { WasRun test(&WasRun::testBrokenMethod); TestResult result = test.run(); assert("1 run, 1 failed" == result.summary()); } }; struct WasRun : Testcase<WasRun> { ... void testBrokenMethod() { throw std::runtime_error(__FUNCTION__); }
仮実装から本実装に移す方法を使ってテスト結果を報告できるようにした。
テストメソッドを呼び出す最初にsetupを呼び出す最後にteardownを呼び出す- テストメソッドが失敗してもteardownを呼び出す
- 複数のテストを実行する
収集した結果を報告するWasRunでログ文字列を使用する- 失敗したテストを報告する
なお先のテストはしばらく棚上げにする。
//TestcaseTest(&TestcaseTest::testFailedResult).run();
失敗の扱い (第22章)
テストが失敗した場合の書式を確認する小さなテストを書く。
struct TestcaseTest : Testcase<TestcaseTest> { ... void testFailedResultFormatting() { TestResult result = TestResult(); result.testStarted(); result.testFailed(); assert("1 run, 1 failed" == result.summary()); }
あわせてテストの呼び出しを追加する。
TestCaseTest(&TestCaseTest::testFailedResultFormatting).run();
次が失敗をカウントする実装である。
struct TestResult { size_t nrOfRun; size_t nrOfFailure; TestResult() : nrOfRun(0), nrOfFailure(0) {} void testStarted() { ++nrOfRun; } void testFailed() { ++nrOfFailure; } ...
そしてカウントした結果を出力する。
std::string summary() const { std::ostringstream buf; buf << nrOfRun << " run, " << nrOfFailure << " failed"; return buf.str(); }
テストメソッドの例外を捕らえて失敗を記録する。
TestResult run() { TestResult result; result.testStarted(); setup(); try { (dynamic_cast<Testee*>(this)->*method)(); } catch (const std::exception&) { result.testFailed(); } teardown(); return result; }
これで先のテストが通るようになった。
TestcaseTest(&TestcaseTest::testFailedResult).run();
テストの失敗を補足し、報告できるようになった。
テストメソッドを呼び出す最初にsetupを呼び出す最後にteardownを呼び出す- テストメソッドが失敗してもteardownを呼び出す
- 複数のテストを実行する
収集した結果を報告するWasRunでログ文字列を使用する失敗したテストを報告する- setupのエラーをとらえて報告する
スイートにまとめる方法 (第23章)
現時点で main にはテストを呼び出すコードが乱雑に並んでいる。
TestCaseTest(&TestCaseTest::testResult).run(); TestCaseTest(&TestCaseTest::testTemplateMethod).run(); TestCaseTest(&TestCaseTest::testFailedResult).run(); TestCaseTest(&TestCaseTest::testFailedResultFormatting).run();
テストをひとまとめに扱えるようにしたい。
struct TestCaseTest : TestCase<TestCaseTest> { ... void testSuite() { TestSuite suite; suite.add(new WasRun(&WasRun::testMethod)); suite.add(new WasRun(&WasRun::testBrokenMethod)); TestResult result = suite.run(); assert("2 run, 1 failed" == result.summary()); }
ひとまとめにするためにリスト(C++ では STL のコンテナクラスのいずれか)が使えるが、 TestCase がそれぞれ異なる基底型をもつため STL のリストで管理できない。
そこで書籍からいったん離れて Testbase という基底型を導入する。
struct Testbase { virtual ~Testbase() {} virtual TestResult run() = 0; };
これを Testcase の親クラスに設定する。
template <typename Testee> struct TestCase : Testbase {
add メソッドを追加する。 C++ でポリモーフィックなオブジェクトを扱うためにはポインターを受け取らねばならないため add する対象は new で確保したオブジェクトとすることにし、デストラクターで開放するように仕込んだ。
#include <list> // for std::list #include <algorithm> // for std::for_each #include <numeric> // for std::accumulate struct TestSuite { std::list<Testbase*> list; ~TestSuite() { std::for_each(list.begin(), list.end(), delete_); } static void delete_(Testbase *v) { delete v; } void add(Testbase *test) { list.push_back(test); }
さらに run メソッドを追加する。書籍ではすべてのテストでひとつの result を共有したいという動機からコレクティング・パラメーターが紹介されていた。
しかし TestResult は関数の戻り値として受け取るのが自然とおもうため、結果を集計して返すという仕組みで実装した。
TestResult TestSuite::run() { return std::accumulate(list.begin(), list.end(), TestResult(), sum); } static TestResult TestSuite::sum(const TestResult &s, Testbase *v) { TestResult tmp = v->run(); tmp.nrOfRun += s.nrOfRun; tmp.nrOfFailure += s.nrOfFailure; return tmp; }
テストの実行はつぎのようにまとめられる。
int main()
{
TestSuite suite;
suite.add(new TestCaseTest(&TestCaseTest::testTemplateMethod));
suite.add(new TestCaseTest(&TestCaseTest::testResult));
suite.add(new TestCaseTest(&TestCaseTest::testFailedResult));
suite.add(new TestCaseTest(&TestCaseTest::testFailedResultFormatting));
suite.add(new TestCaseTest(&TestCaseTest::testSuite));
std::cout << suite.run().summary() << std::endl;
これでTestSuiteとしてテストをまとめられるようになった。多くの重複が存在するが、 TestCase から自動的に TesuSuite を作成できれば解消できる(と書かれている)。
テストメソッドを呼び出す最初にsetupを呼び出す最後にteardownを呼び出す- テストメソッドが失敗してもteardownを呼び出す
複数のテストを実行する収集した結果を報告するWasRunでログ文字列を使用する失敗したテストを報告する- setupのエラーをとらえて報告する
- TestCaseクラスからTestSuiteを作成する
ここまでで書籍の解説は終了し、残りは読者への課題として残されている。なお assert を使っているが、 NDEBUG でも実行されるよう別の実装を導入すべきであろう。
落穂ひろい
では残りの課題を順に解消しよう。
まずテストメソッドが失敗しても teardown が呼び出されるようにすることを調べる。このテストは WasRun と testBrokenMethod を流用して以下のように書ける:
void TestCaseTest::testTeardownCalledIfSetupFailed() { WasRun test(&WasRun::testBrokenMethod); test.run(); assert("setup teardown " == test.log.str()); }
そしてテストを main に追加する。
suite.add(new TestCaseTest(&TestCaseTest::testTeardownCalledIfSetupFailed));
対応する実装は TestCase で setup で発生する例外を補足することである。
TestResult TestCase::run() { TestResult result; result.testStarted(); try { setup(); (dynamic_cast<Testee*>(this)->*method)(); } catch (const std::exception&) { result.testFailed(); } teardown(); return result; }
setup で生じる例外にも対応できた。 std::exception 以外の例外も補足すべきだろうか?
テストメソッドを呼び出す最初にsetupを呼び出す最後にteardownを呼び出すテストメソッドが失敗してもteardownを呼び出す複数のテストを実行する収集した結果を報告するWasRunでログ文字列を使用する失敗したテストを報告する- setupのエラーをとらえて報告する
- TestCaseクラスからTestSuiteを作成する
setup の失敗を捕らえて報告できるようにする。 setup が失敗するテストケースをWasRunを派生して作成する。
struct FailedSetup : WasRun { FailedSetup() : WasRun(&WasRun::testMethod) {} void setup() { testBrokenMethod(); } };
そして TestCaseTest に次のテストを追加し、
void TestCaseTest::testReportCatchSetupFailure() { assert("1 run, 1 failed" == FailedSetup().run().summary()); }
main にテストの呼び出しを追加する。
suite.add(new TestCaseTest(&TestCaseTest::testReportCatchSetupFailure));
実のところ実装は先のテストで済んでいるため追加したテストは成功し、ToDoは残すところただひとつとなる。
テストメソッドを呼び出す最初にsetupを呼び出す最後にteardownを呼び出すテストメソッドが失敗してもteardownを呼び出す複数のテストを実行する収集した結果を報告するWasRunでログ文字列を使用する失敗したテストを報告するsetupのエラーをとらえて報告する- TestCaseクラスからTestSuiteを作成する
しかし TestCase から自動的に TestSuite を作成できると重複が減らせる…のか?
TestSuite も TestCase と同じインターフェースを備えているため TestSuite に TestSuite を add でき、同様に run して TestResult を得られるようにする…という考え方ならわかるのだが。(しかし重複は解消されない)
TestCase から TestSuite を作成できるようにすることで、こうすれば重複を減らせる!というアイデアをお持ちの方はぜひコメントをお寄せください。
今後の課題
個人的に気になっていて解消したい点:
- assertを__FILE__と__LINE__を使った整形テキスト出力に
- テスト呼び出しにおける重複: new <クラス名>(&<クラス名>::method) で <クラス名> は一回にしたい
- setup/teardownの削除: テストフレームワークとしての名前づけという気持ちはわからなくもないけれど、 RAII の原則に従って、これはコンストラクターとデストラクターに置き換えたい
これとは別に、テストが「クラス(/構造体)」になっていることの意義、つまりフィクスチャーがグローバル名前空間を汚染しないというメリットについても文書にしたい。
(再掲) TestCase から TestSuite を作成できるようにすることで、こうすれば重複を減らせる!というアイデアをお持ちの方はぜひコメントをお寄せください。
*1:テストは実行されたと読める