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 さんで がおー です!お楽しみに!

SlackのReactionのEventに応じて〇〇する

この記事を読んだらできるようになること

  • SlackのReactionが押されたときに、何かの処理をしたりできるようになります

この記事で伝えたいこと

  • Real Time Messaging APIとEvents APIの存在
  • Slackをハックする楽しさ

今回作ったもの

f:id:serinuntius:20171211203819p:plain f:id:serinuntius:20171211205232p:plain

  • リアクションがたくさんある投稿(質問内容)が上にくる質問システム

(弊社では年に2回ぜんいん社長合宿というものがあるのですが、質問タイムのときに特定のチャンネルで呟いてもらい、共感した人はReactionを押すと注目度の高い質問が上の方へ来るという仕組みです。司会がWeb上のコンテンツをプロジェクターで表示しながら、質問に答えていくというスタイルでやりたいと、今年の司会であるはまーさんから製作依頼を受けました。)

このエントリーのターゲット

  • SlackのReactionを使って何かしてみたい人

* すでにSlackのReal Time Messaging APIやEvents APIを扱ったことがある人は退屈だと思います。

はじめに

こんにちは。 Tech KAYAC Advent Calendar 2017の16日目の記事をお届けします。受託チーム(CL)の新卒サーバサイドエンジニア、 serinuntiusこと芹川です。

皆様、Slackをハックしてますか?

趣味でSlackのBotを製作して社内で公開してから、Slack関係のソフトウェアの製作依頼や質問が来るようになりました。 嬉しいことですね!

早速ですが、Botを作成していきましょう!

SlackのBotsを作成する

1. Apps を押す

f:id:serinuntius:20171214181745p:plain

2. View App Directory を押す

f:id:serinuntius:20171214181840p:plain

3. Bots と打って選択

f:id:serinuntius:20171214182127p:plain

4. Botを追加する権限があれば

Add configuration が選べる。

f:id:serinuntius:20171214182441p:plain

もし、権限がないとこんな表示になると思います。 Request to install を押すとAdminの人とかに(たぶん)通知がいくので承認してもらいましょう!

f:id:serinuntius:20171214182745p:plain

5. Bot名を適当に入れて鍵を入手しましょう

f:id:serinuntius:20171215173355p:plain

後で使うので、コピーしておいてください。

言うまでもないがこの鍵は慎重に扱いましょう! (漏れるとBotがいるチャンネルでの会話が筒抜けになる可能性がある)

その他、アイコン等も設定しておくと良いと思います。 まったくつぶやかないBotなら設定しなくてもいいと思います。

6. Botを部屋に招待しておく

これが終わればSlack側の設定は終わったはず!!!

BotがいるチャンネルのReactionだけが取れます。

f:id:serinuntius:20171214185429p:plain

コードの部分

今回はサクッと作りたかったので、Node.jsで実装しました。 本当はServerless & Lambdaで作ろうと思ってたから・・・

1. 依存ライブラリのインストールとか

mkdir slack-reaction
cd slack-reaction
yarn add @slack/client dotenv
echo SLACK_BOT_TOKEN=xoxb-xxxxxxx > .env

2. ミニマムな構成のコード

// main.js
const RtmClient = require('@slack/client').RtmClient;
const RTM_EVENTS = require('@slack/client').RTM_EVENTS;
const dotenv = require('dotenv');

dotenv.load();


const botToken = process.env.SLACK_BOT_TOKEN || '';

const rtm = new RtmClient(botToken);

rtm.on(RTM_EVENTS.REACTION_ADDED, event =>{
  console.log('Reaction added:', event);
  // ここに好きな処理を書きましょう!!!
});


rtm.start();

特に説明するほどのこともないかと思いますが、こんな感じに書いて

node main.js

で起動させて、

f:id:serinuntius:20171215122845p:plain

SlackでBotを招待したチャンネルで呟いてReactionを付けると次のようなログが吐き出されると思います。

Reaction added: { type: 'reaction_added',
  user: 'U6HJ69ECD',
  item: 
   { type: 'message',
     channel: 'C7JHG5GQG',
     ts: '1513301808.000130' },
  reaction: '100',
  item_user: 'U6HJ69ECD',
  event_ts: '1513301811.000176',
  ts: '1513301811.000176' }

各項目について一応説明しておくとこんな感じ。 event_tsとtsの違うがよくわからん・・・。

key 説明
user Slack内部で使われているuserのID
item.type messageとかfileとかfile_commentとか
item.channel Slack内部で使われているチャンネルのID
item.ts messageのuniqなtimestamp(これがメッセージのIDみたいなもの)
reaction reactionの名前
item_user そのmessageを書いたuser
event_ts eventが発生したtimestamp(↓違いがわからん)
ts timestamp(↑と違いがわからん)

これを見ていてびっくりするのが、messageの内容が書かれていないよう(激寒)ってこと。

なんとかして、reactionが付いたmessageが何か知りたい。

そう思ってググってたら、下記のStackoverflowが見つかりました。

slack api - How to search for a message by a specific ts - Stack Overflow

どうやら、conversation.history APIとmessageのtsを使えば取れそうだ。

3. 改良したメッセージの内容がわかるやつ

const RtmClient = require('@slack/client').RtmClient;
const RTM_EVENTS = require('@slack/client').RTM_EVENTS;
const WebClient = require('@slack/client').WebClient;
const dotenv = require('dotenv');
dotenv.load();

const botToken = process.env.SLACK_BOT_TOKEN || '';

const rtm = new RtmClient(botToken);
const web = new WebClient(botToken);


rtm.on(RTM_EVENTS.REACTION_ADDED, reaction =>{
  console.log('Reaction added:', reaction);
  web.conversations.history(reaction.item.channel, {
    latest: reaction.item.ts,
    limit: 1,
    inclusive: true,
  }, (err, res) => {
    if (err) {
      console.log(err);
      return
    }
    const messageText = res.messages[0].text;
    console.log(messageText)
  });
});


rtm.start();

よしよし!イイ感じに取れた。

けど、普通に考えてユーザ名も知りたいですよね・・・。

4. ユーザの名前もわかる版(完成版)

そろそろ、コールバックが辛くなるのでPromise化もしましょう!

users.info APIでuserIDからuserの名前を調べる。

const RtmClient = require('@slack/client').RtmClient;
const RTM_EVENTS = require('@slack/client').RTM_EVENTS;
const WebClient = require('@slack/client').WebClient;
const dotenv = require('dotenv');
dotenv.load();

const botToken = process.env.SLACK_BOT_TOKEN || '';

const rtm = new RtmClient(botToken);
const web = new WebClient(botToken);

const getMessageText = (event) => {
  return new Promise((resolve, reject) => {
    web.conversations.history(event.item.channel, {
      latest: event.item.ts,
      limit: 1,
      inclusive: true,
    }, (err, res) => {
      if (err) {
        reject(err);
      }
      const _messageText = res.messages[0].text;
      const _userID = res.messages[0].user;
      const _reaction = event.reaction;
      resolve({_messageText, _reaction, _userID})
    });
  })
};

const getName = (userID) => {
  return new Promise((resolve, reject) => {
    web.users.info(userID, (err, res) => {
      if (err) {
        reject(err)
      }
      resolve(res.user.name)
    })
  })
};


rtm.on(RTM_EVENTS.REACTION_ADDED, event => {
  let messageText, reaction, userName;

  if (event.item.type !== 'message') {
    console.log('this is not message');
  }
  getMessageText(event)
    .then(({_messageText, _reaction, _userID}) => {
      messageText = _messageText;
      reaction = _reaction;
      return getName(_userID)
    })
    .then(_userName => {
      userName = _userName;
      console.log(messageText, reaction, userName);
    })
    .catch((err) => {
      console.error(err);
    })

});


rtm.start();

これで、何を作るにしてもだいたい必要になる3種の神器がそろいました。

動作イメージ

f:id:serinuntius:20171215165458g:plain

実際のアプリケーションでは、DyanmoDBに保存したりフロントから参照するためにExpressでAPI作ったりしたのですが、今回の大筋とはかけ離れるのでやめておきます。

一応サンプルをGitHubにおいておきます。

github.com

おもったこと

今回あまり下調べをせずに勢いに任せてReal Time Messaging APIを使って実装してしまったのですが、Events APIの方を使えばいつも使っているお気に入り構成のServerless Framework & AWS Lambda & DynamoDB が出来たのにな〜って強く思いました。

Lambdaでも出来なくはないと思うのですが、タイムアウトが300秒なので5分毎に実行させて・・・

みたいなことになるので、あまりキレイな設計ではないですね。

Events APIの方を使って、API GatewayとLambdaでAPIを作ってReactionごとにAPIコールしてもらうのが一番いい構成です。(たぶん) 今回のアプリケーションはec2のt2.nanoとかで動かしました。

おわりに

いかがだったでしょうか。 今回はSlackのReactionごとに何かするアプリケーションを作りました。

Slackをハックする楽しみが少しでも伝われば幸いです。

カヤックではSlackをハックするのがエンジニアも、そうでないエンジニアも募集しております!

明日は tamiflu さんの GoogleTestかなあ です。乞うご期待!