株式会社エス・スリー・フォー

Mockpp 導入ガイド

Mockppとは…

単体テストのフレームワーク CppUnit による自動テストはモジュールの品質を高め、安全/確実なソフトウェアの構築に大きな効果をもたらします。しかしながら CppUnitがうまく適用できないシチュエーションにも少なからず直面します。

ナビゲータ(class Navigator)の実装を考えてみましょう。ユーザはナビゲータに適当な複数のキーワードを与えます。ナビゲータはあらかじめ用意されたDatabaseにそのキーワードを食わせると、その検索結果としてURLが得られるものとします。さらにWebブラウザを制御するクラス:Browserも提供され、これにURLを与えるとブラウザに引き渡してくれるとしましょう。

つまり、Navigatorはその機能の実現にDatabaseとBrowserを利用することになります。DatabaseおよびBrowserは以下のようなヘッダが用意されています:

Database.h
#ifndef DATABASE_H__
#define DATABASE_H__

#include <string>
#include <vector>

class Database {
public:
  virtual ~Database() {}
  virtual std::string find(const std::vector<std::string>& keywords) =0;
};

#endif
Browser.h
#ifndef BROWSER_H__
#define BROWSER_H__

#include <string>

class Browser {
public:
  virtual ~Browser() {}
  virtual void show(const std::string& url) =0;
};

#endif

上記のクラスを利用したNavigatorのインタフェースは以下のようになります。

Navigator.h
#ifndef NAVIGATOR_H__
#define NAVIGATOR_H__

#include <string>

class Database;
class Browser;

class Navigator {
  Database* db_;
  Browser* br_;
public:
  Navigator(Database* db, Browser* br);
  bool navigate(const std::string& input);
};

#endif

コンストラクタでDatabaseとBrowserを与えておきます。ユーザからの入力0個以上のキーワードを空白で繋いだものをnavigateの引数に与えることにしましょう。

Navigator::navigate()で行なう処理はおおむね以下のとおりです:

  • inputを空白を区切り文字として複数の文字列に分割し、std::vector<std::string>keywords に格納する。
  • keywordsをDatabase::find()に与え、URLを得る。(ただしこのURLには先頭の"http://"が付いていない)
  • URLが空文字列であったらfalseを返す。そうでなければBrowser::show()を呼び出す。

テスト・ファースト・デザインの流儀に則れば、ここでテスト対象であるNavigatorのテストを書き、テストを繰り返しながらテスト項目をすべてパスするように実装を進めます。しかし、ここでDatabaseおよびBrowserが未実装であったとき、テストできません。こんなとき、Database/Browserの動作を真似する'偽物(Mock)'を用意することで解決できます。Mockpp(MockObject for C++:http://mockpp.sf.net/)は、この'偽物(Mock)'を作るためのライブラリです。

Mockの作り方

Mockppを使って、Databaseのニセモノ:MockDatabaseを作ってみましょう。

MockDatabase.h
#ifndef MOCKDATABASE_H__
#define MOCKDATABASE_H__

#include "Database.h"
#include <mockpp/MockObject.h>
#include <mockpp/ExpectationList.h>
#include <mockpp/ReturnObjectList.h>

class MockDatabase : public Database, public mockpp::MockObject { // [1]

public:

  virtual std::string find(const std::vector<std::string>& keywords) {
    for ( std::vector<std::string>::size_type i = 0; 
          i < keywords.size(); ++i ) {
      args.addActual(keywords[i]); // [4]
    }
    return rets.nextReturnObject(); // [5]
  }

  MockDatabase():mockpp::MockObject("Database"), args("arguments",this), rets("returns",this) { // [3]
  }

  mockpp::ExpectationList<std::string>  args; // [2]
  mockpp::ReturnObjectList<std::string> rets; // [2]
};

#endif
  1. MockDatabaseは本物のインタフェース Database とmockpp::MockObjectから導出します。
  2. メソッドfindに飛び込む引数の正当性を検証する ExpectedList、そして findの戻り値を格納しておく ReturnObjectList とをメンバ変数に用意します。
  3. コンストラクタでは[2]のそれぞれに名前を付けておきます。
  4. ExpectedList,ReturnObjectListのコンストラクタにはそれらを包含するMockObjectのポインタを(第二引数に)与えます。
  5. そしてExpectedListをMockに登録する。
  6. メソッドfindでは与えられた引数をひとつづつ取り出し、ExpectedList::addActualに与えます。このとき期待した値と異なれば、例外をthrowすることによってテストが失敗します。
  7. あらかじめ用意しておいた値を返します。

ニセモノBrowser: MockBrowser も同様です。メソッド show の戻り値は void なのでReturnObjectList は必要ありません。

MockBrowser.h
#ifndef MOCKBROWSER_H__
#define MOCKBROWSER_H__

#include "Browser.h"
#include <mockpp/MockObject.h>
#include <mockpp/ExpectationList.h>

class MockBrowser : public Browser, public mockpp::MockObject {
public:
  virtual void show(const std::string& url) {
    args.addActual(url);
  }

  MockBrowser() : mockpp::MockObject("Browser"), args("arguments",this) {
  }


  mockpp::ExpectationList<std::string> args;
};

#endif

CppUnitによるテストコード

NavigatorTest.cpp
//CUPPA:include=+
#include "Navigator.h"
#include "MockDatabase.h"
#include "MockBrowser.h"
//CUPPA:include=-
#include <cppunit/extensions/HelperMacros.h>
#include <cppunit/TestAssert.h>

//CUPPA:namespace=+
//CUPPA:namespace=-

class NavigatorTest : public CppUnit::TestFixture {
private:

  Navigator*     navigator;
  MockDatabase*  database;  MockBrowser*   browser;

public:

  virtual void setUp() { 
    database  = new MockDatabase();
    browser   = new MockBrowser();
    navigator = new Navigator(database,browser);
  }
  virtual void tearDown() { 
    delete database;
    delete browser;
    delete navigator;
  }

//CUPPA:decl=+
  void testOK() {
    database->args.addExpected("大阪"); // [1]
    database->args.addExpected("software"); // [1]
    database->rets.addObjectToReturn("www.s34.co.jp"); // [2]
    browser->args.addExpected("http://www.s34.co.jp"); // [3]
    CPPUNIT_ASSERT(  navigator->navigate("大阪 software") ); // [4]
    database->verify(); // [5]
    browser->verify(); // [6]
  }
  void testNG() {
    database->args.addExpected("東京");
    database->args.addExpected("hardware");
    database->rets.addObjectToReturn("");
    CPPUNIT_ASSERT( !navigator->navigate("東京 hardware") );
    database->verify();
    browser->verify();
  }
//CUPPA:decl=-
  CPPUNIT_TEST_SUITE(NavigatorTest);
//CUPPA:suite=+
  CPPUNIT_TEST(testOK);
  CPPUNIT_TEST(testNG);
//CUPPA:suite=-
  CPPUNIT_TEST_SUITE_END();
};

//CUPPA:impl=+
//CUPPA:impl=-

CPPUNIT_TEST_SUITE_REGISTRATION(NavigatorTest);

testOKではDatabase::findに"大阪"と"software"が与えられることを期待し(1)、そのとき"www.s34.co.jp"を返すこと(2)。
引き続いてNavigator::showに"http://www.s34.co.jp"が与えられることを期待しています(3)。
次にNavigator::navigateを呼んでみて、trueが返ってくればテストをパスしたことになります。

testNGも同様です。期待する引数、そのときの戻り値を設定し、テスト対象メソッドを呼び出します。こちらはnavigateの結果がfalseであるはずです。

テストと実装

これでようやくNavigator の実装に着手できます。
まずは最小限コンパイル/リンク/実行できるだけの'いいかげん'なコードからはじめましょう。

Navigator.cpp
#include "Navigator.h"
#include "Database.h"
#include "Browser.h"

Navigator::Navigator(Database* db, Browser* br) : db_(db), br_(br) {
}

bool Navigator::navigate(const std::string& input) {
  std::vector<std::string> keywords;
  keywords.push_back(input);
  std::string url = db_->find(keywords);
  br_->show(url);
  return true;
}

コンパイル/リンクし、実行すると次のような結果が得られます:

!!!FAILURES!!!
Test Results:
Run:  2   Failures: 0   Errors: 2


1) test: NavigatorTest::testOK (E)
uncaught exception of type mockpp::AssertionFailedError
- arguments added item does not match. 大阪 != 大阪 software.


2) test: NavigatorTest::testNG (E)
uncaught exception of type mockpp::AssertionFailedError
- arguments added item does not match. 東京 != 東京 hardware.

この結果は、Database::findへの引数が"大阪"であることを期待していたのに、実際は"大阪]software"であったことを報告しています。入力文字列を空白で分割していないので当然です。

では'空白を区切りとした分割'をNavigator.cppに追加しましょう。

Navigator.cpp
#include "Navigator.h"
#include "Database.h"
#include "Browser.h"

Navigator::Navigator(Database* db, Browser* br) : db_(db), br_(br) {
}

bool Navigator::navigate(const std::string& input) {
  std::vector<std::string> keywords;
  std::string::size_type beginPos = 0;
  std::string::size_type endPos = 0;
  while ( (beginPos = input.find_first_not_of(" ", endPos)) != std::string::npos ) {
    endPos = input.find_first_of(" ", beginPos);
    keywords.push_back(input.substr(beginPos, endPos-beginPos));
  }
  std::string url = db_->find(keywords);
  br_->show(url);
  return true;
}

実行結果は以下のようになります:

!!!FAILURES!!!
Test Results:
Run:  2   Failures: 1   Errors: 1


1) test: NavigatorTest::testOK (E)
uncaught exception of type mockpp::AssertionFailedError
- arguments added item does not match. http://www.s34.co.jp != www.s34.co.jp.


2) test: NavigatorTest::testNG (F) line: 46 NavigatorTest.cpp
assertion failed
- Expression: !navigator->navigate("東京 hardware")

…Browser::showの引数(URL)に"http://"をくっつけるのを忘れているようです…

このように、Mockppを使えば、'本物'なしでテスト・ファースト・デザインに則ったテスト/実装が可能になります。