Google Test ことはじめ

こんにちは。技術部の小池です。

これは Tech KAYAC Advent Calendar 2017 17日目の記事です。

今回はGoogle製のC++テストフレームワークの Google Test を案件に導入した話をします。

経緯

私の所属している新規ゲーム案件ではサーバサイドをPerlとC++で開発しており、Perlは社内に知見がありメソッド単位でテストをがっつり書いており品質が保たれていましたがC++はメンバーの知見がなくテストも書かれていない状態が続いていました。

C++はバトルのパケットを中継するリレーサーバ的な役割を担っており、バトル部分が複雑化するに伴いC++サーバに徐々にバグが潜んでいる感じで大変困っておりました。その状況を打破するためにテストをちゃんとやって品質を上げていくぞ!ということで導入したのがGoogle Testです。

テストの書き方はJUnitライクな感じなのでxUnit系のテストフレームワークを使ったことある方ならとっつきやすいかな〜と思います。

Google Testのビルド

Google TestはLinux、Mac、Windowsどの環境でも使用できるテストフレームワークです。それぞれの環境でビルドできるよう各環境用にビルドファイルが用意されているので案件ごとの環境に適したやり方でビルドできるかと思います。

私の案件では導入時点で最新バージョンのv.1.8.0をCentOS 7上で CMake を使ってビルドしました。以下のコマンドでビルドできます。

$ curl -OL https://github.com/google/googletest/archive/release-1.8.0.tar.gz
$ tar xzf release-1.8.0.tar.gz
$ cd googletest-release-1.8.0
$ cmake .
$ make

ビルドが成功すると googlemock/gtest 以下に libgtest.alibgtest_main.a の2つのライブラリファイルが生成されます。ヘッダファイル群は googletest/include にあります。ライブラリとヘッダの場所が分かれていて分かりにくい感じはありますね〜。

テストを書いてみる

早速テストを書いてみます。ディレクトリ構成は以下のようにしました。

├── googletest
│   ├── include
│   │   └── gtest (中身はヘッダファイル群)
│   ├── libgtest.a
│   └── libgtest_main.a
├── src
│   └── example.cpp
└── test
    └── example_test.cpp

2つの引数の合計を返すプログラムとそのテストを書いてみます。

src/example.cpp

int sum(int x, int y) {
    return x + y;
}

test/example_test.cpp

#include <gtest/gtest.h>

#include "src/example.cpp"

TEST(example_test, func_sum) {
    ASSERT_EQ(3, sum(1, 2));
}

テストケースごとにTESTマクロを呼び出します。第一引数はテストケースの名前、第二引数は個々のテストの名前です。第一引数はフィクスチャクラスの名前を兼ねており、複数のテストで共通で使用するオブジェクトの生成などの処理がある場合にTESTマクロではなくTEST_Fマクロを呼ぶことでフィクスチャクラスに共通処理をまとめることができます。

テストはアサーションで行います。ここではsum関数の実行結果が期待した値になっていることをテストしています。アサーションの一覧は こちら を見ると良いでしょう。Google Test v.1.6のものですが、日本語版のページ もあります。

実行してみます。

$ g++ -std=c++11 test/example_test.cpp -I. -Isrc -Igoogletest/include -Lgoogletest -lgtest -lgtest_main -lpthread
$ ./a.out
Running main() from gtest_main.cc
[==========] Running 1 test from 1 test case.
[----------] Global test environment set-up.
[----------] 1 test from example_test
[ RUN      ] example_test.func_sum
[       OK ] example_test.func_sum (0 ms)
[----------] 1 test from example_test (1 ms total)

[----------] Global test environment tear-down
[==========] 1 test from 1 test case ran. (2 ms total)
[  PASSED  ] 1 test.

無事にテストが通りました。

テストファイル内に自前でmain関数を書くこともできますが gtest_main にmain関数の実装があるので通常こちらを使う形で良いでしょう。私の案件ではビルド用のシェルスクリプトを書いてテスト時は gtest_main をリンクするようにしています。

また、Google Testはスレッドを使っているため pthread をリンクする必要があります。

Google Mockも使ってみる

通信や状態を含む処理や時間が関連する処理をテストする際は処理をモック化してテストをすることもあると思いますが、Google MockというGoogle Testに付属しているモックライブラリを使用することでモックオブジェクトを作ることができます。Google Testがビルドできていれば googlemock 以下に libgmock.alibgmock_main.a があるはずなのでこちらを使用します。ヘッダファイル群は googlemock/include 以下にあります。

先ほどの構成にGoogle Mockを追加して以下のようにしました。

├── googletest
│   ├── include
│   │   ├── gmock (中身はヘッダファイル群)
│   │   └── gtest (中身はヘッダファイル群)
│   ├── libgmock.a
│   ├── libgmock_main.a
│   ├── libgtest.a
│   └── libgtest_main.a
├── src
│   └── example.cpp
└── test
    └── example_test.cpp

Google Mockを使う際はまずモック化したいオブジェクトのインターフェースを作る必要があります。ここではゲームのルームにいるプレイヤー全員にハートビートを送るサンプルを考えてみます。この際playerのsession_idが0のときはplayerがルームから離脱している状態なので送信しないものとします。

#include <memory>
#include <vector>
#include <stdint.h>
#include <iostream>

struct Player {
    uint32_t player_id;
    uint32_t session_id;
};

class MyNetworkClientInterface {
public:
    virtual void heartbeat(uint32_t session_id) = 0;
};

class MyNetworkClient : public MyNetworkClientInterface {
public:
    void heartbeat(uint32_t session_id);
};

class Room {
public:
    std::vector<Player> players;
    MyNetworkClientInterface *client;

    void update() {
        for (auto &player : players) {
            if (player.session_id != 0) {
                client->heartbeat(player.session_id);
            }
        }
    }
};

MyNetworkClientInterface がアプリケーションクラスとモッククラスの共通のインターフェースで、実際に処理を行インターフェースを継承したアプリケーションのクラスが MyNetworkClient ですね。このハートビートを送るRoomクラスのupdateメソッドをテストする際は以下のようになります。

#include <gtest/gtest.h>
#include <gmock/gmock.h>

#include <memory>
#include <stdint.h>

#include "src/example.cpp"

class MockMyNetworkClient : public MyNetworkClientInterface {
public:
    MOCK_METHOD1(heartbeat, void(uint32_t session_id));
};

TEST(example_test, func_update) {
    MockMyNetworkClient client;

    auto room = std::unique_ptr<Room>(new Room());
    room->players.push_back(Player{1, 1234});
    room->players.push_back(Player{2, 0});
    room->client = &client;

    EXPECT_CALL(client, heartbeat(1234)).Times(1);
    EXPECT_CALL(client, heartbeat(0)).Times(0); // session_idが 0 なので呼ばれない

    room->update();
}

モック用の MockNetworkClient クラスを定義して使っています。このモッククラスはジェネレータで自動生成できます。モックのテストでは呼び出しの回数の判定や振る舞いの定義など一通りのことが可能で、ここでは呼び出し回数のテストをしています。

gmock と gmock_main を更にリンクさせテストを実行すると無事にテストが通ることが確認できます。

$ g++ -std=c++11 test/example_test.cpp -I. -Isrc -Igoogletest/include -Lgoogletest -lgtest -lgtest_main -lgmock -lgmock_main -lpthread
$ ./a.out
Running main() from gtest_main.cc
[==========] Running 2 tests from 1 test case.
[----------] Global test environment set-up.
[----------] 2 tests from example_test
[ RUN      ] example_test.func_sum
[       OK ] example_test.func_sum (0 ms)
[ RUN      ] example_test.func_update
[       OK ] example_test.func_update (1 ms)
[----------] 2 tests from example_test (1 ms total)

[----------] Global test environment tear-down
[==========] 2 tests from 1 test case ran. (1 ms total)
[  PASSED  ] 2 tests.

導入してどうだったか

すべてのクラスに対するテストが書かれているわけではないのですが、主要なクラスにはテストが書かれている状態となっています。C++サーバはゲームのルーム管理やパケット中継などを行うため状態管理が複雑でしたが、それぞれのテストケースごとにテストを書くことで品質は安定してきました。我々エンジニアの精神の均衡も保たれるようになり改めてテストは大事だな〜と思いました。

しかし通信処理の本体は静的ライブラリになっており、ライブラリから呼び出されるcallbackに処理を実装するというアーキテクチャになっているため、callback部分をテストするためにはcallbackクラスに全部インターフェースを噛ませてDI的なことをする必要がありさすがに大規模工事になるので静的ライブラリに関連するクラスのテストは諦めました。

それとモックはハマリポイントがちょくちょくあります。

  • モックオブジェクトがデストラクタで破棄される際にテストが評価されるためモックオブジェクトが破棄されないような作りになっているとエラーになる
  • 内部でコピーコンストラクタを使っているので unique_ptr を使っているとそのままモックできず一枚噛ませないといけない

などなど癖があり本格的に使う場合は根気強く向き合っていくことになりそうです。テスト時にカジュアルにスタブに置き換えるような仕組みがあればいいなーと思いました。

一方でメソッドレベルのテストでは見つからない、ゲームの連戦時のみ発生するような不具合もあり単体テストだけでなくバトルのマッチングからバトル終了までの一連のシナリオをテストするシナリオテストの仕組みが必要そうと感じてます。

さいごに

面白法人カヤックではC++でもPerlでもGoでもRubyでもなんでもばっちこいなエンジニアも募集しております!

明日18日目は s_gozaru さんで がおー です!お楽しみに!