cocos2d-xプロジェクトにGoogleTestを導入してみた

Cocos2d-x

夢100事業部でエンジニアをしているmad_khakiです。

夢100は「Cocos2d-x」というゲームフレームワークを使って開発しています。

今回は「Cocos2d-x」を使ったアプリのプロジェクトにGoogleTestというテストフレームワークを導入し、単体テストを行えるようにする方法を紹介します。

■GoogleTestとは

正式名称は「Google C++ Testing Framework」で、Googleが提供しているC++用のテストフレームワークです。

iOS向けアプリケーションの開発には必需品であるXcodeの単体テスト機能と組み合わせて使用できる点から選択しました。

GitHubリポジトリ
GitHub – google/googletest

ドキュメント(日本語訳)
Google Test ドキュメント日本語訳

■環境

  • macOS 10.14.5
  • Xcode 11.1
  • Cocos2d-x 3.17
  • GoogleTest 1.8.1

Cocos2d-xのプロジェクトに関しては下記コマンドで新規作成したものをベースに書いていますが、既存プロジェクトに導入することも可能です。

cocos new gtest-sample -l cpp

■導入手順

▼ テスト用ターゲットの作成

ターゲットのテンプレート選択を開く

プロジェクトの設定画面の「+」ボタンからターゲットテンプレート選択ウィンドウを開くことができます。

「Unit Testing Bundle」を選択し、Next

右上にあるフィルタ欄にUnitといれると簡単に見つけることができます。

「Project」、「Target to be Tested」を確認
テスト用ターゲットの作成完了
言語設定をベースプロジェクトに合わせる
  • C Language Dialect
  • C++ Language Dialect
  • C++ Standard Library
テストを実行してみる

テスト実行(command + U)するとビルドが成功し、サンプルのテストが実行されます。

▼ GoogleTestを導入する

GitHubからGoogleTestをダウンロード

GitHubリポジトリ
GitHub – google/googletest

必要なファイルをプロジェクトにコピー
  • googletest/include以下
  • googletest/src以下
コピーしたファイル群をプロジェクトに追加

コピー先ディレクトリのコンテキストメニューから「Add Files to “XXX”」を選択します。

この際に「Add to targets」は全て解除しておきましょう。

追加するとこのようになります。

src/gtest-all.ccだけは、右ペインのTarget Membershipからテストターゲットを対象にしておきましょう。

Build Setting > Header Search Pathsからパスを通す
  • $(inherited)
  • $(SRCROOT)/Tests/googletest/include
  • $(SRCROOT)/Tests/googletest
Build Phases > Link Binary With Libraries を設定

テストターゲットからもCocos2d-xを利用できるようにするために、必要なライブラリを追加します。

※ Cocos2d-xのバージョンによって必要なライブラリは異なるので、ベースプロジェクトの設定を確認しながら設定しましょう。

▼ テストを簡単に行えるようにする

サンプルのテストの実行結果とソースコードを見ると、テストには

  • XCTestCaseを継承したクラスを実装する
  • textXXXとなるメソッドを定義する

が必要ということが分かります。

  • 1つのテストクラスに、複数のクラスのテストを書くのは避けたい
  • かといって、クラスごとにXCTest
  • C++がメインの開発言語なので、なるべくC++でテストも実装したい

といったことを満たすために、xcode-googletestを導入していきます。

#import <XCTest/XCTest.h>

@interface Tests : XCTestCase

@end

@implementation Tests

- (void)setUp {
    // Put setup code here. This method is called before the invocation of each test method in the class.
}

- (void)tearDown {
    // Put teardown code here. This method is called after the invocation of each test method in the class.
}

- (void)testExample {
    // This is an example of a functional test case.
    // Use XCTAssert and related functions to verify your tests produce the correct results.
}

- (void)testPerformanceExample {
    // This is an example of a performance test case.
    [self measureBlock:^{
        // Put the code you want to measure the time of here.
    }];
}

@end
GoogleTest.mmを入手して、プロジェクトに追加

GitHubリポジトリ
GitHub – mattstevens/xcode-googletest

GoogleTests.mmは実行時にターゲット内のテストメソッドを収集して実行してくれます。

例えば、SampleTest.cppを実装してテストを実行すると、これを動的に収集し、テストが行われます。

#import <gtest/gtest.h>

TEST(SampleTest, sample) {
    EXPECT_EQ(123, 123);
}

Tests.mmは不要になったので削除してしまいましょう。

テストの内容とパラメータを分けて実装できるようにする

googletestにはTestWithParamというテンプレートクラスが用意されているので、これを活用して、パラメータ化されたテストを行う準備をしましょう。

template <typename T>
class TestWithParam : public Test, public WithParamInterface<T> {
};

実装した基盤クラス(ParamTest)は以下のようになっています。

メソッドは

  • SetUp : テストメソッドの前処理
  • TearDown : テストメソッドの後処理

メンバ変数は

  • params : テストのパラメータ
  • expected : 期待される出力
  • message : テスト成功時のメッセージ

TestValueクラスは型を抽象的に扱うためのラッパークラスで、詳細は次の項で解説します。

#import <gtest/gtest.h>
#include "TestValue.h"

class ParamTest : public testing::TestWithParam<TestValue> {
protected:
    TestValue testData;

    TestValue params;
    TestValue expected;
    std::string message;

    virtual void SetUp();
    virtual void TearDown();
};
void ParamTest::SetUp() {
    testData = GetParam();

    params = testData["params"];
    expected = testData["expected"];
    message = testData["message"].asString();
}

void ParamTest::TearDown() {
}
型を抽象的に扱う

Cocos2d-xには組み込み型やstd::vector, std::mapといったコンテナのwrapperとしてValueクラスが提供されています。

cocos2d-x/CCValue.h at v3 · cocos2d/cocos2d-x · GitHub

Valueクラスを継承したTestValueクラスを実装することで、

  • 型を抽象的に扱う
  • テスト用の拡張を既存のプロジェクトとは分離する

という2点を実現しましょう。

まず、Valueクラスは継承を想定しておらず、デストラクタにvirtual修飾子がないので追加しておきましょう。

/** Destructor. */
virtual ~Value();

TestValueクラスには各種型に対応したコンストラクタやコンビニエンスコンストラクタを実装します。一部を抜粋したものを掲載しておきます。

class TestValue : public cocos2d::Value {
public:
    static const TestValue Null;

    TestValue();

    TestValue(unsigned char v);
    TestValue(int v);
    TestValue(float v);
    TestValue(double v);
    TestValue(long v);
    TestValue(int64_t v);
    TestValue(bool v);
    TestValue(const char* v);
    TestValue(const std::string& v);

    TestValue(const TestValueVector& v);
    TestValue(TestValueVector&& v);
    TestValue(const TestValueMap& v);
    TestValue(TestValueMap&& v);
    TestValue(const TestValueMapIntKey& v);
    TestValue(TestValueMapIntKey&& v);

    TestValue(const cocos2d::Value* other);
    TestValue(const cocos2d::Value& other);
    TestValue(cocos2d::Value&& other);

    TestValue(const TestValue& other);
    TestValue(TestValue&& other);
  
  // 略
  
    template <typename E,
    typename std::enable_if<std::is_enum<E>::value>::type* = nullptr,
    typename std::enable_if<std::is_same<typename std::underlying_type<E>::type, int>::value>::type* = nullptr>
    TestValue(E v) {
        *this = static_cast<int>(v);
    }

    template <typename Container,
    typename std::iterator_traits<typename Container::iterator>::value_type* = nullptr>
    TestValue(Container container) : TestValue(TestValueVector(container.begin(), container.end())) {}

    template <typename T>
    TestValue(std::initializer_list<T> list) : TestValue(TestValueVector(list.begin(), list.end())) {}

    TestValue(std::initializer_list<TestValuePair> list);
    TestValue(std::initializer_list<TestValuePairIntKey> list);

    TestValue(std::initializer_list<std::initializer_list<TestValuePair>> list);
    TestValue(std::initializer_list<std::initializer_list<TestValuePairIntKey>> list);
  
  // 略
  
  	template <typename Container, typename Predicate,
		typename ValueType = typename Container::iterator::value_type,
		typename ArgumentType = typename function_argument<Predicate>::type,
		typename std::enable_if<std::is_convertible<ValueType, ArgumentType>::value>::type* = nullptr>
	static TestValue create(const Container& container, Predicate predicate) {
		TestValueVector vector;
		std::transform(container.begin(), container.end(), std::back_inserter(vector), predicate);
		return TestValue(vector);
	}

	template <typename Container, typename Predicate,
		typename ValueType = typename Container::iterator::value_type,
		typename ArgumentType = typename function_argument<Predicate>::type,
		typename std::enable_if<std::is_convertible<typename ValueType::first_type, std::string>::value>::type* = nullptr,
		typename std::enable_if<std::is_convertible<typename ValueType::second_type, ArgumentType>::value>::type* = nullptr>
	static TestValue create(const Container& container, Predicate predicate) {
		TestValueMap map;
		for (const auto& v : container) {
			map[v.first] = predicate(v.second);
		}
		return TestValue(map);
	}
  
  // 略
テストコードを実装し、実行する

テスト対象としてCalculatorクラスを用意しました。

2つの整数型の引数a, bを加算して返すplus関数を持っています。

class Calculator
{
public:
    Calculator() = default;
    virtual ~Calculator() = default;

    int plus(int a, int b) const { return a + b; }

private:

};

これに対して、テストコードはこのようになります。

これまでの下準備によって、

  • plus関数を実行し、期待値と比較する
  • plus関数に渡す2つの整数と、期待される計算結果を定義する

を分離し、1つのテストの中の複数のケースとして実現できています。

using CalculatorTest = ParamTest;

// テストコード
TEST_P(CalculatorTest, plus) {
    Calculator c;

    TestValue result = {
        {"result", c.plus(params["a"].asInt(), params["b"].asInt())},
    };

    EXPECT_EQ(expected, result) << message;
}

// テストケース
INSTANTIATE_TEST_CASE_P(Test, CalculatorTest, ([] {
    std::vector<TestValue> testDataList = {
        { // ケース1
            {"params", {
                {"a", 1},
                {"b", 2},
            }},
            {"expected", {
                {"result", 3}, // 1 + 2 = 3が期待結果
            }},
            {"message", "正の数 + 正の数"},
        },
        { // ケース2
            {"params", {
                {"a", 0},
                {"b", 6},
            }},
            {"expected", {
                {"result", 6}, // 0 + 6 = 6が期待結果
            }},
            {"message", "0 + 正の数"},
        },
    };
    return testing::ValuesIn(testDataList);
}()));

テストを実行すると、各テストケースが成立していることが確認できます。

例えば、2つめのケースの期待値(result)を7にするとCalculatorTestの2つめがテストを通過しなくなります。

このとき、コンソールには失敗したテストに関する情報が出力されるほか

-[Test_CalculatorTest plus/1] : Expected equality of these values:
  expected
    Which is: 
{
    result: 7
}
  result
    Which is: 
{
    result: 6
}
0 + 正の数

Xcodeのテストレポートからも確認することができます。

▼ Ex. 失敗時の差分を分かりやすくする

Diff Template Library (DTL) を導入して失敗時の差分を分かりやすくしてみましょう。

参考資料
QtでGitHubのような差分を出力する – Qiita

GitHubリポジトリ
GitHub – cubicdaiya/dtl

リポジトリからダウンロードし、dtlディレクトリ以下をプロジェクトに追加します。

gtest.ccに下記の変更を加えます。

#include "dtl.hpp" // 変更点

// 中略

AssertionResult EqFailure(const char* lhs_expression,
                          const char* rhs_expression,
                          const std::string& lhs_value,
                          const std::string& rhs_value,
                          bool ignoring_case) {


  // ---- 変更点ここから ----
  msg << "\n";
  msg << "\n";
  msg << "--- Expected\n";
  msg << "+++ Actual\n";
  std::string line;
  std::vector<std::string> expected_line_list;
  std::stringstream expected_value_stream(lhs_value);
  while (std::getline(expected_value_stream, line)) {
    expected_line_list.push_back(line);
  }
  std::vector<std::string> actual_line_list;
  std::stringstream actual_value_stream(rhs_value);
  while (std::getline(actual_value_stream, line)) {
    actual_line_list.push_back(line);
  }
  dtl::Diff<std::string> diff(expected_line_list, actual_line_list);
  diff.compose();
  diff.composeUnifiedHunks();
  diff.printUnifiedFormat(msg);
  // ---- 変更点ここまで ----

  return AssertionFailure() << msg;

テストを実行すると、先程の失敗時の出力がGitのdiffのようになります。

■テスト駆動開発について

テスト機能の導入について紹介してきましたが、必ずしも「テスト駆動開発 (テストファーストなアジャイル型の開発)」にする必要は無いと考えています。

プロジェクト開始当初からテスト前提で開発していなければ、そのコードはテストがしやすい作りになっていないことが多く、メンバーもテストに不慣れです。

特にゲームのようなGUIを含むコードの場合、ロジック (Model) と ビジュアル (View)を分離した設計を心がけていないと、更に難度が上がります。

そのような状況でいきなり Red → Green → Refactor といったテスト駆動開発を遵守しようとしてもうまくいきません。テストもテスト駆動開発もあくまで手段なので、使えそうな部分を取り入れていけば良いと思います。

エンジニアの自己満足にならず、『より良いプロダクトをユーザに提供する』という目的をブラさずに試行錯誤していきましょう。