8
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

組込みに近いものをTDDで開発してみる〜file open編〜

Last updated at Posted at 2018-02-16

はじめに

googletest/googlemockを使って、組込みLinux上で動作するソフトウェアをTDDで開発しています。
実際にTDDで開発している手順をリアルタイムでそのまま書いているため、行き当たりばったりな部分があります(まさかfile openだけでこんな分量になるとは…)。
過程も楽しんで頂ければ幸いです。
不明な点、間違っている点があれば、コメント下さい。励みになります。

これまでの経緯を詳しく知りたい方は、前回までの記事をご覧下さい。
組込みに近いものをTDDで開発してみる〜準備編〜
組込みに近いものをTDDで開発してみる〜問題提起編〜

To Doリストを作る

私は基本に忠実な人間だ。
なので、Kent BeckやJames W. Grenningの教えに倣って、To Doリストを作成する。

Key input event

  • 仮想的な利用方法を示すテストを作る
  • Input deviceを初期化する
  • Input deviceの終了処理をする
  • 「A」キー押下を検出する

LED brightnesscontrol

  • 仮想的な利用方法を示すテストを作る
  • LED deviceを初期化する
  • LED deviceの終了処理をする
  • LEDをOnする
  • LEDをOffする

思いつくままざっと並べた。今は他に思いつくものがない。
実装を進めれていけば新しいアイテムを思いつくだろう。

さて、手を付けやすいのは、どこだろうか?
今、我々はlibevdevへの理解がある。キー入力イベントの実装から始めるのが良さそうだ。

想定する利用方法のテストを書く

KeyInputEventはどのように使われるべきだろうか?
最終形式を思い描いて、仮想のテストを書く。
このテストは、開発を進める上で本当のテストになったり、修正を加えたりする。
あくまでもスタート地点だ。

TEST_F(KeyInputEventTest, AbstractUse) {
  EXPECT_TRUE(InitKeyInputDevice());
  SetKeyInputEventListener(condition);

  for (int i = 0; i < 10; i++)
    EXPECT_FALSE(EventDetected());
  EXPECT_TRUE(EventDetected());

  EXPECT_TRUE(FinalizeKeyInputDevice());
}

このテストで想定する入力イベントモジュールの使い方はこうだ。

  1. デバイスを初期化すると、trueが返ってくる。
  2. 検出するイベント(「A」キーを押す)を設定する。
  3. 「A」キーが押されるまでは、EventDetected()はfalseを返す。
  4. 「A」キーが押されると、EventDetected()がtrueを返す。
  5. デバイスの終了処理を行う。

特に違和感はない。
このテストをコメントアウトして、最初のテストに移ろう。

最初のテスト

Input eventの初期化を細分化すると次の2つの項目がある。
Input event初期化のテストを作っていくが、まずはdevice fileのopenからだ。

  • Input device fileをopenする
  • libevdevを初期化する

Input eventの初期化をテストする

テストは次の通り。
初期化を呼び出すとtrueが帰ってくることを期待する。

TEST_F(KeyInputEventTest, InitKeyInputDevice) {
  EXPECT_TRUE(InitKeyInputDevice());
}

この時点でテストを実行しようとしても、コンパイルすら通らない。
このテストを通すための実装は次のようになる。
最初はtrueを返すだけの実装をしても良い。
今回は実装する内容が明らか(fileのopenだ!)なので、そこまで細かくステップを踏まない。

#define DEVICE_FILE "/dev/input/event2"

bool InitKeyInputDevice() {
  int fd = open(DEVICE_FILE, O_RDONLY|O_NONBLOCK);
  if (fd < 0) return false;

  return true;
}

コードをビルドして実行する。(環境によってはroot権限が必要だ)
テストはパスする。

ここで、ふと気になったことがある。
このモジュールはシングルインスタンス想定で良いのだろうか?
今は、まだ答えが出ない。To Doリストに問いを追加して、先を進める。

  • マルチインスタンスへの対応をする?

問題提起編では、エラー処理が動作するかどうか不明確だった。
今回はどうだろうか?例えば、次のテストを成功させたい。
そのためには、初期化を失敗させる必要がある。

TEST_F(KeyInputEventTest, FailToInitInputDevice) {
  EXPECT_FALSE(InitKeyInputDevice());
}

今のプロダクトコードでエラーを発生させるのは難しい。
eventデバイスファイルを消せばエラーが発生するが、ユニットテストを成功させるためにシステムを破壊するのは頂けない。
問題は明らかだ。
プロダクトコード内において、マクロでデバイスファイルのパスをハードコーディングしているせいだ。

そこで、初期化関数のインタフェースを変更する。
オープンするデバイスファイルを外部から与えてあげるのだ。

bool InitKeyInputDevice(const char *device_file);

初期化が失敗することをテストするコードは次のようになる。
ありえないデバイスファイルのパスを渡してあげれば、ENOENT(指定したファイルが見つからない)でエラーとなるはずだ。

TEST_F(KeyInputEventTest, FailToInitInputDevice) {
  EXPECT_FALSE(InitKeyInputDevice("/dev/input/not_found"));
}

テストは成功する。
テスト可能な実装を考えたことで、コードがより柔軟になった。これはかなり気分が良い。

ここで、ふと思った。初期化が成功するテストでは、わざわざ本物のインプットイベントデバイスをオープンしているが、その必要があるだろうか?
本物を使う論理的な理由が思いつかない。初期化を成功させるテストはこれで等価だ。

TEST_F(KeyInputEventTest, CanInitInputDevice) {
  std::ofstream("./test_event");
  EXPECT_TRUE(InitKeyInputDevice("./test_event"));
  std::remove("./test_event");  // 立つ鳥跡を濁さず
}

テストはパスする。よしよし、これでテスト実行にroot権限が不要になった。
テストはこれから何万回と実行する。すこしでも簡単に実行できる方が良い。

そして、次はこう思う。
今のコードでテスト可能なファイルオープンのエラーは、ENOENTくらいだ。
他のエラーはテストしなくて良いだろうか?
プロダクトで必要なテストは書くべきだ(ただし時間と相談だ)。
今回、本物のインプットイベントデバイスは権限がないとオープンできないことがわかっている。
権限がなくてオープンできなかったことは、ログで出力してあげると親切だ。
To Doリストに次の項目を追加する。

  • Permission deniedでオープンに失敗したらログを出力する。

ただ、POSIX標準ライブラリのモックを作るのは難しい。
write()の定義は、libc内で行われており、これはC言語のプログラムを作る上では自動的にリンクされてしまう。
頑張ってリンカを誤魔化しても良いが、ラッパー関数を用意する方が手軽そうだ。

  int IO_OPEN(const char *pathname, int flags);

テストでは、IO_OPEN()のモックをリンクし、プロダクトコードでは、IO_OPEN()内でopen()を呼び出す。

モックを使ったテストは次のようになる。
成功の場合は、正の数のファイルディスクリプタが返る。通常は3以上となるはずだ。
EXPECT_CALL()の補足だが、このテストでは、IO_OPEN()が1回呼ばれることをテストする。プロダクトコードがIO_OPEN()を呼び出した際、返り値として3を返す。

TEST_F(KeyInputEventTest, CanInitInputDevice) {
  EXPECT_CALL(*mock_io, IO_OPEN(_, _)).WillOnce(Return(3));
  EXPECT_TRUE(InitKeyInputDevice("./test_event"));
}

プロダクトコードを復習すると、IO_OPEN()が0以上の数値を返すと、InitKeyInputDevice()がtrueを返す。
モックが3を返してくれるので、↑のテストは成功する。

bool InitKeyInputDevice(const char *device_file) {
  int fd = IO_OPEN(device_file, O_RDONLY|O_NONBLOCK);
  if (fd < 0) return false;

  return true;
}

file open失敗の場合、Man page of OPENによると

エラーが発生した場合は -1 を返す (その場合は errno が適切に設定される)。

ということで、IO_OPEN()失敗をテストするには、モックが呼び出されると、-1が返るようにすれば良い。

TEST_F(KeyInputEventTest, FailToInitInputDevice) {
  EXPECT_CALL(*mock_io, IO_OPEN(_, _)).WillOnce(Return(-1));
  EXPECT_FALSE(InitKeyInputDevice("./file_not_found"));
}

さて、前置きが長くなったが、今やることは↓だ。

  • Permission deniedでオープンに失敗したらログを出力する。

errnoによってプロダクトコードが振る舞いを変えることをテストしたいので、モックの呼び出しアクション内でerrnoを設定する。
googlemockでは、モック呼び出し時に、任意の関数を実行することが可能だ。
今回は、ラムダ式で、errnoにEACCESを設定し、IO_OPEN()の返り値としては、-1が返る関数が実行されるようにする。

TEST_F(KeyInputEventTest, FileOpenPermissionDenied) {
  EXPECT_CALL(*mock_io, IO_OPEN(_, _)).WillOnce(
    Invoke([](const char*, int) { errno = EACCES; return -1; }));
  EXPECT_FALSE(InitKeyInputDevice("./event"));
}

さて、まだ道半ばだ。
ログが出力されることはどうやってテストすれば良いだろうか?
スパイにプロダクトコードが出力したログを報告されせば良い。

TEST_F(KeyInputEventTest, FileOpenPermissionDenied) {
  // スパイの設定
  char *spy[] {new char[128]};
  set_DEBUG_LOG_spy(spy, 128);

  InitKeyInputDevice("./event");  // テスト対象の実行
  EXPECT_STREQ("Fail to open file. You may need root permission.",
               spy);
}

スパイがやっていることは、大したことではない。
プロダクトコードが、DEBUG_LOG()を呼び出すところを待ち構え、事前に渡されていたバッファに出力内容をコピーするだけだ。

static char *buffer = NULL;
static int buffer_size = 0;

void DEBUG_LOG(const char *message) {
  strncpy(buffer, message, buffer_size-1);
}

void set_DEBUG_LOG_spy(char *b, const int size) {
  buffer = b;
  buffer_size = size;
}

プロダクトコードは次の通りだ。
直接printf()といった関数を使ってログ出力しないところがポイントだ。
そこにスパイが付け入る隙がある。

bool InitKeyInputDevice(const char *device_file) {
  int fd = IO_OPEN(device_file, O_RDONLY|O_NONBLOCK);
  if (fd < 0) {
    if (errno == EACCES)
      DEBUG_LOG("Fail to open file. You may need root permission.");
    return false;
  }

  return true;
}

実際の開発でも、デバッグレベルを制御するデバッグ専用APIを利用することは多いはずだ。
そのようなAPIを、テストのときだけリンカを使って、スパイに誘導するのだ!

file open編の整理

To Doリストの状況は次の通りだ。

  • 仮想的な利用方法を示すテストを作る
  • Input deviceを初期化する
    • Input device fileをopenする
    • Permission deniedでオープンに失敗したらログを出力する。
    • libevdevを初期化する
  • Input deviceの終了処理をする
  • 「A」キー押下を検出する

この時点でのコードは、FileOpenタグで見ることができる。
led_controller下に今回作成したソースコードがある。
https://github.com/tomoyuki-nakabayashi/TDDforEmbeddedSystem

テストコード

3つのテストをパスしている。

TEST_F(KeyInputEventTest, CanInitInputDevice) {
  EXPECT_CALL(*mock_io, IO_OPEN(_, _)).WillOnce(Return(3));
  EXPECT_TRUE(InitKeyInputDevice("./test_event"));
}

TEST_F(KeyInputEventTest, FailToInitInputDevice) {
  EXPECT_CALL(*mock_io, IO_OPEN(_, _)).WillOnce(
    Invoke([](const char*, int) { errno = ENOENT; return -1; }));
  EXPECT_FALSE(InitKeyInputDevice("./file_not_found"));
}

TEST_F(KeyInputEventTest, FileOpenPermissionDenied) {
  std::unique_ptr<char[]> spy {new char[128]};
  set_DEBUG_LOG_spy(spy.get(), 128);

  EXPECT_CALL(*mock_io, IO_OPEN(_, _)).WillOnce(
    Invoke([](const char*, int) { errno = EACCES; return -1; }));

  EXPECT_FALSE(InitKeyInputDevice("./test_event"));
  EXPECT_STREQ("Fail to open file. You may need root permission.",
               spy.get());
}

プロダクトコード

bool InitKeyInputDevice(const char *device_file) {
  int fd = IO_OPEN(device_file, O_RDONLY|O_NONBLOCK);
  if (fd < 0) {
    if (errno == EACCES)
      DEBUG_LOG("Fail to open file. You may need root permission.");
    return false;
  }

  return true;
}
int main(void) {
  InitKeyInputDevice("/dev/input/event2");
  return 0;
}

int IO_OPEN(const char *pathname, int flags) {
  return open(pathname, flags);
}

void DEBUG_LOG(const char *message) {
  printf("%s\n", message);
}

もしあなたの環境が、インプットイベントのopenにroot権限を必要とする場合、root権限なしでプロダクトコードを実行すると、次のような結果になる。

./led_controller
Fail to open file. You may need root permission.

でたらめなファイルパスを指定した場合は、何も出力されない。
これは、100%テストで意図した通りの動作だ。
プロダクトコードの動作にかなりの自信を持つことができるはずだ。

次回予告

Input deviceの初期化、終了をTDDで開発していきます。
「A」キー押下を検出する、は1つの記事にするボリュームになると考えています。

一番上にも書きましたが、不明な点、間違っている点があれば、ぜひコメント下さい。

8
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?