自由課題

学んだり、考えたり、試したりしたこと。

C++における単体テストのための依存性注入方法まとめ

はじめに

あるモジュールを作成した時、当然ながら(ですよね?)このモジュール(以降テスト対象モジュールと呼びます)を何らかの方法でテストする必要があります。テスト対象モジュールが別のモジュール(以降依存モジュールと呼びます)に依存している場合、

  1. テスト対象モジュールと依存モジュールを一緒にテストする
  2. 何とかして依存モジュールをテスト対象モジュールと切り離し、テスト対象モジュールを単独でテストする。

一般的に、1は結合テスト、2は単体テストと呼ばれます。
この記事では、単体テストで何とかして依存モジュールを切り離す、言い換えるとテストのために別の依存モジュール(依存性)を注入する方法を紹介します。
一応記事タイトルをC++としていますが、いくつかは別の言語でも使えるはずです。

注入対象

テスト時に注入したい対象について説明します。

ロジック

例えばDBアクセスやネットワークアクセスを行うためのロジックです。例えば下記のようなイメージです。

/* HTTPアクセスのためのインターフェイスを提供する抽象クラス(http.h) */
class HTTP {
public:
  virtual HTTPResponse get(const HTTPRequest &request) = 0;
  ...
};

/* HTTPクラスを実装した具象クラス(Testeeクラスの依存モジュール) */
class ConcreteHTTP : public HTTP {
public:
  HTTPResponse get(const HTTPRequest &request) override {
    /* 何らかのHTTPアクセス処理 */
  }
};

/* テスト対象モジュール(testee.h) */
class Testee {
public:
  Testee(int param);

  void doSomething() {
    HTTP http = new ConcreateHTTP();
    HTTPResponse response = http.get("http://somedomain/somedirectory/someimage.jpg");
    ...
  }
};

Testeeクラスから見ると、ConcreteHTTPクラスが依存モジュールであり、単体テスト時に動作を切り替えたい部分となります。例えば、単体テストを高速に実行するために、単体テスト時にはget()を実際にはネットワークアクセスをしないようにしたい場合が考えられます。

定数

アプリケーションで使用する設定ファイルのパスなど、プロダクション環境では定数でよいものを単体テスト時には切り替えたい場合も存在します。例えば以下のようなコードです。

class Testee {
public:
  Testee(int param);

  void doSomething() {
    /* 設定ファイルの読み込み。
       プロダクション環境ではユーザのホームディレクトリから読み込むが、
       単体テストでは別のパスから読み込みたい */
    std::ifstream configFile("~/.applicationname_config");

    /* 設定ファイルの内容を元に動作を行う */
  }
};

注入する方法

以下に注入対象を注入する方法について説明します。おおまかにはタイミングで分類しており、単体テストコードのコーディング時コンパイル時に行う方法、単体テスト実行ファイルのリンク時に行う方法、そして単体テスト実行ファイルの実行時に行う方法を紹介しています。なお、本記事では、単体テストGoogle Testを用いるものとします。

コーディング時

単体テストでのみ用いるコンストラクタを用意する方法です。

/* テスト対象クラスのヘッダファイル(testee.h)*/
class HTTP;
class Testee {
public:
  /* プロダクション環境で使用するコンストラクタ。
     このコンストラクタではTestee::httpをConcreteHTTPクラスのオブジェクトで初期化する。 */
  Testee(int param); 

  /*単体テストでのみ使用するコンストラクタ*/
  Testee(int param, const HTTP &http):http(http){...}; 

  void doSomething() {
    /* コンストラクタで初期化されたhttpを使用する */
    HTTPResponse response = (this->http).get("http://somedomain/somedirectory/someimage.jpg");
    ...
  }
  ...

private:
  HTTP http; /* HTTP通信時に使用するオブジェクト */
};
/*テストコード*/
#include"gtest.h"
#include"http.h"
#include"testee.h"

/* 単体テスト用のスタブHTTPクラス */
class StubHTTP : public HTTP {
public:
  StubHTTP();
  HTTPResponse get(const HTTPRequest &request) override {
    /* ネットワークには接続せず、テストデータを使用する */
  }
};

TEST(testeeTest, someTest) {
  Testee testee(0, StubHTTP()); /* テストコードではスタブのHTTPを注入する*/

  /*Testeeに対するテストを行うためのコード*/
  testee.doSomething();
}

上記のようにTesteeが単体テストを行うためのコンストラクタを用意することにより、単体テスト時にはHTTP通信の動作を切り替えることができます。

  • セッタによる注入

コンストラクタの場合とほぼ同様ですが、単体テスト用の動作や設定をセッタで注入することもできます。 この場合、テスト対象モジュールは以下のようになります。

class Testee {
public:
  Testee(int param);

  void setHTTP(const HTTP &http);//HTTP動作を注入するためのセッタ
  void doSomething();
  ...
private:
  HTTP http; /* HTTP通信時に使用するオブジェクト */
};

テストコードは以下のようになります。

TEST(testeeTest, someTest) {
  Testee testee(0);
  testee.setHTTP(HTTPStub()); //スタブ用HTTPを注入

  /*Testeeに対するテストを行うためのコード*/
  testee.doSomething();
}
  • テンプレートによる注入

テンプレートを用いて注入することもできます。しかし、クラスをテンプレート化するといろいろトレードオフが生じるので、テストのためだけにわざわざこの方法を選択するというのはどうかなとは思います。

template <class T = ConcreteHTTP> class Testee {
public:
  Testee(int param);

  void doSomething();
  ...
private:
  T http; /* HTTP通信時に使用するオブジェクト */
};
TEST(testeeTest, someTest) {
  Testee<HTTPStub> testee(0);

  /*Testeeに対するテストを行うためのコード*/
  testee.doSomething();
}

注入する方法そのものではないですが、関数オブジェクト・ラムダ式により依存性を注入することもできます。テストコード中にインラインで書けるという意味ではラムダ式が楽そうです。

class Testee {
public:
  Testee(int param);

  void setHTTP(function<HTTPResponse(const HTTPRequest&)> httpGetter);//HTTP動作を注入するためのセッタ
  void doSomething();
  ...
private:
  function<HTTPResponse(const HTTPRequest&)> httpGetter;
};

注入方法そのものとしてはコンストラクタ・セッタ・テンプレートのいずれでも使えると思います。

コンパイル

  • マクロ

文字列程度であれば、コード上はマクロにしておいて単体テスト用にコードをコンパイルする時に設定することもできます。

#include"testee.h"

#define CONFIG_PATH "~/.applicationname_config" /* プロダクション環境における設定ファイルのパス */

void Testee::doSomething() {
  std::ifstream configFile(CONFIG_PATH);

  /* 設定ファイルの内容を元に動作を行う */
}

上記のようにテスト対象のコードを書いておくと、単体テスト用にコードをコンパイルする時にコンパイラ-Dオプションを使用することにより、マクロの内容を上書きすることができます。以下に例を示します。

gcc -D"./.applicationname_config_for_unit_test" testee.cpp

実際はmakefileにテスト用のターゲットを書くのがよいと思います。

リンク時

  • リンカオプション

依存モジュールが共有ライブラリ*1である場合は、単体テスト実行ファイルのリンク時にコンパイラオプションを用いることで、プロダクション環境で用いるライブラリとは別のライブラリにリンクすることができます。以下は、linux/gccにおける例です。 例えば、依存モジュールがvoid func()という関数を持つ/usr/lib/libsome.soである場合には、リンクオプションとして-lsomeとリンカオプションを指定します。
ここで、単体テスト用に依存モジュールと同じvoid func()という関数を持つlibsome_stub.soというスタブの共有ライブラリをカレントディレクトリに作成した場合、以下のようにコンパイルすることでスタブ用の共有ライブラリをリンクすることができます。

gcc -o unit_test_executable -L. -lsome_stub testee.o

このようにして作成した単体テスト用実行ファイル(unit_test_executable)は、LD_LIBRARY_PATH環境変数を用いて下記のように実行することができます。

LD_LIBRARY_PATH=. ./unit_test_executable

共有ライブラリの詳細は例えばここを参照してください。また、GCCのオプションについてはここを参照してください。

実行時

#include"testee.h"
#include<stdlib.h>

char *defaultConfigPath = "~/.applicationname_config"; /* プロダクション環境における設定ファイルのパス */

void Testee::doSomething() {
  char *configPath;

  /* 環境変数が設定されていない場合はデフォルトのものを使用する */
  if((configPath = getenv("CONFIG_PATH")) == NULL){
    configPath = defaultConfigPath;
  }

  std::ifstream configFile(CONFIG_PATH);

  /* 設定ファイルの内容を元に動作を行う */
}

このようなコードを含む実行ファイルの実行時の環境変数は、例えば

CONFIG_PATH=./.applicationname_config_for_unit_test ./unit_test_executable

のようにして設定することができます(unit_test_executableは実行ファイルの名前)。また、setenvを用いることにより、環境変数はテストコード内からも設定することができます。
環境変数に格納できるのは文字だけですが、これを応用することができます。例えば、dlopen*2の引数で指定する共有ライブラリのパスを環境変数から指定することができ、"リンカオプション"で説明したような振る舞いの変更を実行時に制御することができます。

まとめ

本記事では、単体テストを行うのに有用な依存性の注入方法を説明しました。実際にどのような注入方法を採用すべきかは、

  • 柔軟性
    実行時に近い(注入のタイミングが遅い)ほど、概ね記事の下のものほど高い
  • パフォーマンス
  • 依存モジュールの呼び出し方法
    依存モジュールの構造によっては採用できる方法が制限される

などを考慮して検討する必要があります。

なお、本記事で紹介した手法は、単体テストの時だけでなく様々な場面で使用することができます(詳しくは"SPLE"などでググってみてください)。また、特に実行時に注入する方法は、モジュールの性質によってはセキュリティ上問題がある可能性があるので留意してください。個人的には、単体テストが目的である限りは実行時まで注入を遅らせる理由はあまりないように思います。

他に良い方法をご存知の方は教えて頂けると助かります。

参考文献

C++ ポケットリファレンス

C++ ポケットリファレンス

新装版 マルチパラダイムデザイン

新装版 マルチパラダイムデザイン

  • 作者: ジェームス・O・コプリン,James O. Coplien,平鍋健児,金沢典子,羽生田栄一
  • 出版社/メーカー: ピアソン桐原
  • 発売日: 2009/12/01
  • メディア: 単行本
  • 購入: 3人 クリック: 41回
  • この商品を含むブログ (6件) を見る

*1:Unixでは.so。Windowsでは.dll

*2:共有ライブラリをプログラム実行時にロードするAPIです。