家庭用コンピュータ環境の模索 >CppUTest+CppUMockで組み込みテスト
最終更新: 2016-12-18
この記事は C言語 Advent Calendar 2016 の18日目の記事です。
組み込み環境でのテスト
世の中、ユニットテストは常識になってきたが、組み込み環境での実施については標準的なテキストが無かった。次に示すJames W. Grenning らの書籍が、まとまった知識としては現時点で、ほぼ唯一のものだ。
テスト駆動開発による組み込みプログラミング ―C言語とオブジェクト指向で学ぶアジャイルな設計
この本は、組み込みでのユニットテストの必要性、テストフレームワークとして Unity と CppUTest の紹介、リファクタリング方法、Mocking 手法の紹介、という内容になっている。
テストフレームワークとしては、Unity と CppUTest が上げられているが、開発の活発さ、自動テスト採取、モックフレームワークや拡張性などから、CppUTest のほうが多機能で実用性がある。CppUTest と言う名前だが、C++ だけでなく、C も十分にサポートされている。ただし、C99の配列の指示初期化子はC++の規格外なので相性が悪い。
また、Mocking 手法は書籍オリジナルの内容となっている。CppUTest の拡張である CppMockの使い方の解説は書籍中には記されていない。またウェブ上の情報も不足している。
本稿の目的は、CppUTest と CppUMock を使って組み込み環境で UnitTest を実施するための具体的な手法を示すことだ。
CppUTest の使い方
おさらいとして、簡単に CppUTest の使い方を振り返っておこう。
test_group1.cpp というようなファイルを作って、次のように書く。
#include "CppUTest/CommandLineTestRunner.h" TEST_GROUP(TestGroup1) { }; TEST(TestGroup1, Test1) { int ret; ret = product_code(); CHECK_EQUAL(0, ret); }
- まず、テストハーネスを include する。
- 次に、テストグループ(TestGroup1)を定義する。
- 次にテスト(Test1)を定義する。
- テスト対象のコードを実行して期待値と比較する(CHECK_EQUAL)。
main 関数の方は定型的だ。
#include "CppUTest/CommandLineTestRunner.h" int main(int ac, char** av) { return CommandLineTestRunner::RunAllTests(ac, av); }
これで、テストコード、プロダクトコード、main 関数をリンクすればテストが実行できる。
組み込みにおけるMocking
組み込みにおいて、ユニットテストの実行を困難にするのはハードウエアの存在だ。たとえばMCUのGPIOポートに外部のスイッチが接続されていて、スイッチの入力状態によってコードの動作が変わる。
一般的に、GPIOなどのペリフェラルにアクセスするには、特定のレジスタを読み書きする。最近のMCUの場合、それらは HAL層としてライブラリ化されてベンダーから供給されていることが多い。例えば ST社のSTM32シリーズの場合は CubeMX というツールを使ってGUIでペリフェラルの設定をして、その設定に必要なコードを自動生成し、さらにレジスタアクセスは HALライブラリとして提供される。
つまり、HALライブラリを置き換えてリンクしてやれば、実機を用いずにテストを実行できる。この置き換えライブラリを、Mock といい(モックアップとか言うでしょう)、Mockを作って置き換えることを Mocking という。
また、これをするために必要なコーディングルールとして「アプリケーションコードから直接レジスタをアクセスしない。レジスタをアクセスするときはかならず、HALライブラリを通す(ベンダーから提供されなければHALライブラリを自作する)。」
CppUTest において Mocking を支援するライブラリが CppUMockである。
Mockに求められる機能としては次のものがある。
- 本来のHALの機能を代替する(Test Dobuleともいう)。
- テストに適したウソの値を返す。
- 期待するHALの呼び出し方法を定め、それと違う呼ばれ方をすればエラーとする。
CppUMockの使い方
ウェブ上で日本語の CppUMock に関する情報は非常に少ない。それを補うのが本稿の目的である。
例としてキーの取り込みをする関数をテストすることを考える。まず、テストコードは次のようなものになるだろう。
#include "CppUTest/TestHarnes.h" TEST_GROUP(TestGroup1) { TEST_SETUP() { } TEST_TEARDOWN() { mock_c()->clear(); } }; TEST(TestGroup1, Test1) { int ret; ret = check_key(); // キーが押されているかどうかをチェックする。 CHECK_EQUAL(1, ret); }
テスト対象となるプロダクションコードは次のようなものであろう。ここでは、単純にGPIOを使ってピンの状態を読むものを考える。GPIOにはスイッチが接続されているとしよう。
const int port=1; const int pin =2; int check_key() { return HAL_GPIO_Read(port, pin); }
実機で動作させるときには、HALライブラリをリンクさせる。HALライブラリでは、HAL_GPIO_Read はレジスタへのアクセスへと変換される。
テストの時はレジスタは無いので、HALライブラリをMocking しなければならない。Mock として HAL_GPIO_Read を定義して、HALライブラリの代わりにこちらをリンクする。
int return HAL_GPIO_Read(port, pin) { return c_mock() ->actualCall("HAL_GPIO_Read") ->withIntParameters("port", port) ->withIntParameters("pin", pin) ->returnValue().value.intValue; }
意味は次のとおりだ。
- HAL_GPIO_Readという関数だ(文字列として情報登録)
- 一つ目の引数はportという名前(文字列として情報登録)で、コード上では port という仮引数で与えられる。これは、テスト実行時に、exception(期待)として注入される。
- 二つ目の引数も同様。
- int の値を返す。これも、テスト実行時に、exception(期待)として注入される。
このような Mock版 HAL_GPIO_Read をリンクすれば、それが返す値を、テストコード側から設定できる。
#include "CppUTest/TestHarnes.h" #include "CppUTestExt/MockSupport.h" TEST_GROUP(TestGroup1) { }; TEST(TestGroup1, Test1) { int ret; c_mock() ->expcetOneCall("HAL_GPIO_Read") ->withIntParameter("port", 1) ->withIntParameter("pin", 2) ->andReturnIntValue(1); ret = check_key(); CHECK_EQUAL(1, ret); }
c_mock() で始まる行は次の意味だ。
- これから、HAL_GPIO_Read という関数が呼ばれる。
- その引数は、port == 1、pin == 2のはずだ(期待)。
- そして、HAL_GPIO_Readは1を返す。
引数が違うと、checkExpectations がエラーを発生する。つまり、check_key にバグがあって、期待したように HAL_GPIO_Read に引数を与えていないということだ。
引数が合っている場合、Mock化されたHAL_GPIO_Readはproduct_code に対して、上で指定した値を返す。check_key はHAL_GPIO_Readが返す値を正しく処理して値を返す。その値は ret で受けて CHECK_EQUAL で検査される。
引数の期待を表明するためには、型に応じて次のAPIがある。このへんはC++版の方がオーバーライドされていて分かりやすい。
- withBoolParameter
- withIntParameter
- withUnsignedIntParameter
- withLongIntParameter
- withUnsignedLongIntParameter
- withDoubleParameter
- withStringParameter
- withPointerParameter
- withConstPointerParameter
- withFunctionPointerParameter
- withMemoryBufferParameter
- withParameterParameter
- withOutputParameterParameter
- withOutputParameterOfTypeParameter
返り値についても同様だ。
- andReturnBoolValue
- andReturnIntValue
- andReturnUnsignedIntValue
- andReturnLongIntValue
- andReturnUnsignedLongIntValue
- andReturnDoubleValue
- andReturnStringValue
- andReturnPointerValue
- andReturnConstPointerValue
- andReturnFunctionPointerValue
CppUMockの使い方2
これでは、あまり、ありがたさが解らないかもしれない。
チャタリングで誤動作しないように、3回連続でGPIOポートを読んで、キー判定するように、プロダクトコードを修正する。
const int port=1; const int pin =2; int check_key() { int i1,i2,i3; i1 = HAL_GPIO_Read(port, pin); i2 = HAL_GPIO_Read(port, pin); i3 = HAL_GPIO_Read(port, pin); if (i1 && i2 && i3) { return 1; } else { return 0; } }
その場合のテストコードは次のようになる。
#include "CppUTest/TestHarnes.h" #include "CppUTestExt/MockSupport.h" TEST_GROUP(TestGroup1) { }; TEST(TestGroup1, Test1) { int ret; c_mock() ->expcetOneCall("HAL_GPIO_Read") ->withIntParameter("port", 1) ->withIntParameter("pin", 2) ->withReturnIntValue(1); c_mock() ->expcetOneCall("HAL_GPIO_Read") ->withIntParameter("port", 1) ->withIntParameter("pin", 2) ->withReturnIntValue(1); c_mock() ->expcetOneCall("HAL_GPIO_Read") ->withIntParameter("port", 1) ->withIntParameter("pin", 2) ->withReturnIntValue(1); ret = check_key(); CHECK_EQUAL(1, ret); } TEST(TestGroup1, Test2) { int ret; c_mock() ->expcetOneCall("HAL_GPIO_Read") ->withIntParameter("port", 1) ->withIntParameter("pin", 2) ->withReturnIntValue(1); c_mock() ->expcetOneCall("HAL_GPIO_Read") ->withIntParameter("port", 1) ->withIntParameter("pin", 2) ->withReturnIntValue(1); c_mock() ->expcetOneCall("HAL_GPIO_Read") ->withIntParameter("port", 1) ->withIntParameter("pin", 2) ->withReturnIntValue(0); ret = check_key(); CHECK_EQUAL(0, ret); // 3回目のGPIO読み出しが 0 なら check_key は0を返す。 }
一つ目のテストケースは、HAL_GPIO_Readが三回連続で1を返すので、check_key も1を返す。2つ目のテストケースは、HAL_GPIO_Readが二回しか1を返さないので、check_key は0を返す。
このようなチャタリングの状況を神技のようなボタン操作で再現することは困難だ。ユニットテストのありがたさはこういうところにある。