76
68

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.

『テスト駆動開発による組み込みプログラミング』を読んで学んだこと

Last updated at Posted at 2018-04-08

『テスト駆動開発による組み込みプログラミング』を読んで学んだことをまとめます。本の要約6割、自分なりの解釈&追記4割といった感じです。

組み込みでTDDってできるの?

  • できる。ハードウェアやOSが絡む組み込みこそやるべき。
  • ユニットテストによって品質が上がるというQA的なメリットに加え、綺麗な設計になるというメリットが大きい。
    1. テストしようとする
    2. うまくテストできない(大抵は依存性の問題)
    3. テストできるように設計を見直す(依存性を取り除く)
    4. 綺麗な(SOLIDな)設計になる
    5. 定期的にリファクタリングする

ユニットテストフレームワーク

名前 言語 Mockサポート その他
Unity C cmock。Mock自体はrubyスクリプトで別途生成する必要がある 軽量。テストケースの手動登録(インストール)が必要
CppUTest C++ CppUMock Unity用コードに変換可能らしい
Google Test C++ GoogleMock。クラスに対してのモックが作れる。自由度高い pthread必須?

ユニットテストをするのがホスト環境だけなら、今ならGoogle Test一択かな? という感じ。非Linuxのターゲットマイコン上でもユニットテストをする可能性があるときにはUnityかな。

ちなみに、TDD = ユニットテスト、ではない。ユニットテストはTDDをするためのツール。

テストの進め方

テストケースの書き方

  • 4フェーズテストパターンに従ったテストケースにする
    • 準備 (Setup)
    • 実行 (Exercise)
    • 検証 (Verify)
    • 後片付け (Cleanup)
  • 共通の準備と後片付けは、ユニットテストフレームワークが提供するテストフィクスチャのSetUp()、TearDown()にまとめられる
  • 他の処理も共通化できるものはどんどんまとめるべき

テストの進め方

  1. テストコード (テストケース)を書く。(テストファースト!!)
  2. コンパイルが通るようにする (ヘッダを作る)
  3. リンクが通るようにする (空関数を作る)
  4. テストが通るようにする (実実装をする)

コラボレータ

以下の2つのモジュールを考えます。左側は計算をするMyMathというモジュールで、例えば、単純な足し算をするadd()という関数を持っています。右側はLチカをするためのモジュールだと想像してください。クラス図っぽく書いていますが、Cでも同じです。

image.png

足し算をするadd()は、依存モジュールがなく、純粋なロジックだけを扱い、入出力インターフェイスがシンプルです。そのため、テストがしやすいです。この場合は、戻り値をチェックすることでテストが出来ます。
しかし、右側のBlinkは戻り値がありません。また、Blinkをテストするためには、LedDriverが必要ですし、さらにハードウェアが必要になります。

このように、テスト対象コード(モジュール)が依存しているもの(関数、データ、モジュール、デバイス)をコラボレータと呼んでいます。
コラボレータの存在がテストを難しくさせます。これをいかにして解決するかというのが、肝になる所です。本書の内容も、ほとんどがこれにつながります。そして、これをうまいこと解決してテストがしやすくなる = SOLIDな良い設計になります。

テストダブル

スタブやモック、ダミーといった方が一般的かと思います。テスト対象モジュールが依存しているコラボレータの代わりになるものをテストダブルといいます。
一番重要なのは、テスト対象モジュールが、ダミーを使っていることに気付かない、ということです。そのため、テストダブルは本物と同じインターフェイス(同じ関数型)である必要があります。

テストダブルでどこまでやるかは、テスト内容によって様々です。例えば、リンクを通すだけの空関数だったり、常に同じ固定値を返すだけだったりします。
今回は、実際にLEDを点滅させるタイミングでturnOn/turnOff関数を呼んだかどうかをチェックしてみようと思います。そのため、テストから結果を見れるようにする必要があります。そのため、LedDriverのテストダブルには追加でgetStatus()という関数を用意します。これによって、Blinkが期待通りにLedDriverを叩いたかをチェックできます。Blinkのテストとしてはこれで十分です。実際にLEDが光るようにハードウェアを叩くかどうかは別途LedDriverの方でテストをします。

image.png

テストダブルの種類

テストダブルは、その役割に応じて一応名前がついています。学校の試験じゃないので名前を覚える必要はありませんが、テストダブルでどういうことをやるか、というのは知っておいた方がいいと思います。どれくらいまで労力をかけてテストダブルを作るかの目安になります。

テストダミー

リンクを通すためだけのもの。呼ばれない前提の関数に使う。

テストスタブ

固定値を返す関数。

テストスパイ

テスト対象モジュールが渡したパラメータを記録する。
前述の、LedDriverのテストダブルはこれ。

モックオブジェクト

テスト対象モジュールが他のモジュール(関数)を呼ぶ際の、関数名、パラメータ、呼ぶ順番をチェックできる。また、戻り値の設定が出来る。
コード自体は、ユニットテストフレームワークによって用意される(Unityだとスクリプト(cmock)でヘッダファイルから生成する必要がある)。

例えば、先ほどのBlinkが、id=3のLEDをOn, Off, On, Offするのが期待動作だとしたら、Blinkを呼ぶ前に以下のように設定すれば、自動でチェックしてくれる。
もしも期待動作以外の呼び方をしていたら、FAILしてくれる。

Mockの期待動作設定例(CppUTest)
mock().expectOneCall("LedDriver_turnOn").withParameter("id", 3);
mock().expectOneCall("LedDriver_turnOff").withParameter("id", 3);
mock().expectOneCall("LedDriver_turnOn").withParameter("id", 3);
mock().expectOneCall("LedDriver_turnOff").withParameter("id", 3);

フェイクオブジェクト

部分的に実装したもの。

その他

テストダブルは、テスト用に関数を追加してOK。
例えば、先ほどのLedDriverのテストダブルではgetStatus()を追加しました。
他にも、例えばタイマモジュールを模擬するときには、今の時間を設定する関数を追加したりする。

だまし方

テストダブルによって、テスト対象モジュールが依存するモジュールが無くてもテストできるし、期待動作の確認もしやすくなりました。
繰り返しになりますが、この時、テスト対象モジュールはテストダブルを使っているということを意識してはいけません。テスト対象モジュールをうまくだます必要があります。
例えば、同じ関数名で同じ型にして、リンカで切り替えるなどがあります。(先ほどの例だと、プロダクト用コードではLedDriverをリンクする。テスト用にはLedDriverのテストダブルをリンクする)

だまし方にもいろいろあります。上手くだませる = 抽象度が高い(SOLID) = 良い設計になります。

ifdefスイッチ

あまりよろしくない。コードが汚れる。

リンカで置き換える

プロダクト用とテスト用で明らかに分けたいときには一番楽だと思います。
ただ、例えば先ほどのLedDriverの場合ですと、LedDriver自体もテストしたいときに、困ります。

(テストとは関係ありませんが、同じ商品群でも型番によって使用するデバイスが違うときなんかにも使うテクニックです。)

関数ポインタで置き換える

動的に置き換えが出来ます。そのため、あるテストではテストダブルを使うが、別のテストではプロダクト用コードを使う、といったことが出来ます。
欠点としては、コードが複雑になります。関数を呼んだときに、実際にどの処理が走るかが一目でわからないし、IDEの関数ジャンプも使えなくなります。(Grepして、設定している所を調べる必要がある)

まず、関数定義を関数ポインタにします。その後、実実装の関数で代入します。基本はこれが動きます。

LedDriver.h
// void LedDriver_turnOn(int id);
extern void (*LedDriver_turnOn)(int id);
LedDriver.c
// void LedDriver_turnOn(int id)
static void LedDriver_turnOn_impl(int id)
{
	/* LEDをONにする実処理(プロダクト用コード) */
}

void (*LedDriver_turnOn)(int id) = LedDriver_turnOn_impl;

置き換え用のテストダブルを用意します。今まで名前を付けずにLedDriverのテストダブルと言っていましたが、とりあえずFakeLedDriverとします。

FakeLedDriver.c
static int ledStatus[16];
void FakeLedDriver_turnOn(int id)
{
	ledStatus[id] = 1;
}

void FakeLedDriver_getStatus(int id)
{
	return ledStatus[id];
}

実際にテストする前後で置き換えます。

BlinkTest.c
TEST(BlinkTest, SampleTest)
{
	void (*save)(int id) = LedDriver_turnOn;
	LedDriver_turnOn = FakeLedDriver_turnOn;
	Blink_start();
	LONGS_EQUAL(1, FakeLedDriver_getStatus(3));
	LedDriver_turnOn = save;
}

関数ポインタで置き換える(テーブル管理)

置き換える関数の数が増えてきたら、同じモジュールのものは構造体を定義して、テーブルとして管理。

typedef struct {
	void (*turnOn)(int id);
	void (*turnOff)(int id);
} LIGHT_FUNC_TABLE;

LIGHT_FUNC_TABLE ioFuncTable = {
	.turnOn = LedDriver_turnOn,
	.turnOff  = LedDriver_turnOff,
};

依存性の注入

そもそも、Blinkが直接LedDriverを呼んでいるせいで、色々と騙す必要があるのです。
例えば以下のように、Blinkの呼び元(この場合はテスト)がどの関数を呼ぶかを指定できるようにすれば、よりスマートになります。

image.png

BlinkTest.c
TEST(BlinkTest, SampleTest)
{
	Blink_setFunc(FakeLedDriver_turnOn, FakeLedDriver_turnOff);
	Blink_start();
	LONGS_EQUAL(1, FakeLedDriver_getStatus(3));
}

このようにすることを、「依存性の注入」といいます。
嬉しい副作用があります。今回はプロダクト用コードとテスト用コードの置き換えだけでした。しかし、将来的にBlink(点滅)させるのをLEDだけでなく、電球や蛍光灯にも対応させたいという要求があったとします。この時、Blinkモジュールはそのまま再利用できます。LightBulbDriver_turnOn()LampDriver_turnOn()といった関数を同じ型で作り、それを突っ込むだけで対応完了します。

依存性の注入(Interfaceを使う)

先ほどの例では、関数をそのまま突っ込みましたが、実際にはInterfaceを使うと思います。
下図のように、IDriverと同じインターフェイスを持つモジュールであれば、テスト用のテストダブルであろうと、他の照明器具用ドライバであろうと、どんなモジュールにも置き換えることが出来ます。
なお、この図ではC++のクラス図っぽく書いていますが、Cでクラスやインターフェイスを実現する方法もあります。実際に本書では、Cでvtableを使った実現方法について結構なページ数を割いて説明しています。

image.png

依存性の注入(簡易版)

実は、本書ではまずLedDriverのテストから説明されていました。LedDriverはハードウェアを制御します。
これをテストできるようにするため、本書ではLedDriverに対してメモリマップドレジスタのアドレスを設定することでテスト可能としていました。これも、依存性の注入になります。
プロダクト用コードではレジスタアドレス(e.g. 0xF800_0000)を設定し、テスト用コードでは適当な変数のポインタを設定することで、テスト、書き込んだ値のチェックが出来ます。

image.png

あるいは、IOアクセスをするモジュールを新たに作り、そのモジュールへのInterfaceを用いるような設計にもできます。どこまでやるかは、正解はなく、状況に応じて変わってくると思います。(無いと思いますが)仮にIOアクセスがメモリマップドレジスタとIO命令の両方があり得るシステムの場合には、このように切り替えられるようにした方がいいです。
用意するテストダブルも、自分で作っても良いですし、Mockを使って書き込み値のチェックをするのでもOKです。

image.png

コラボレータまとめ

  • コラボレータを置き換えるテストダブルには種類がある。何をどこまでチェックするかによって、使い分ける
  • コラボレータの置き換え方法も色々ある。場合によっては設計にも影響する(大抵は設計をより良くする)

本書の内容もほとんどは、上記2点に関連することになります。重要なんだと思います。

自分なりの追記

割り込みやコールバックは?

image.png

これまで、テスト対象モジュールの外部への依存(コラボレータ)をどう解決するかを見てきました。いずれも、 テスト対象モジュールが呼んでいるケース です。
割り込みやコールバック等、 テスト対象モジュールが呼ばれるケース はどうでしょうか? 例えばLチカ用のBlinkモジュールは、タイマ割り込みを直接受けたり、または、別のタイマモジュールにコールバック登録していることもあり得ます。

特に本書では触れられていませんでしたが、基本的には「テストから該当関数を呼ぶ」でいいかなと思います。ただ、割り込みハンドラそのものを呼ぶのではなく、テストできる単位で呼ぶのがいいかと思います。そのようになっていないときは、コードを見直す必要があるかと思います。コールバックも同様かと。

割り込みハンドラでの処理
__INTR void irq_timer0()
{
	timeCounter++;
	その他いろいろの処理
}
割り込みハンドラでの処理(コード見直し後)
__INTR void irq_timer0()
{
	incrementTimeCounter();
	anotherProcessA();
	anotherProcessB();
}
テスト
TEST(BlinkTest, TimerIncrement)
{
	extern void incrementTimeCounter();
	incrementTimeCounter();
}

(追記)コールバックの場合
例えば、テスト対象モジュールがTimerモジュールにコールバック登録をしている場合。
テスト中はTimerモジュールの代わりにTimerFakeみたいなのを使うと思います。
割り込みを模擬するTimerFake_invokeIRQ()みたいな関数を用意して、それをテストケースから呼ぶといいと思います。

外部モジュール(OS, 他チーム提供のモジュール、サードパーティライブラリ)

Wrapperを使う

外部モジュール(OS, 他チーム提供のモジュール、サードパーティライブラリ)を使う際には、Wrapperをかませるようにした方がいいです。ライブラリがリリースされるまで蓋をするのも容易ですし、インターフェイスの差異を吸収できます。また、将来インターフェイス変更になった時にも、変更箇所が少なくて済みます(例えば、引数追加するときに、変更箇所はWrapperだけで済む可能性が高い)。

例えば、時間を取得する関数を直接呼ぶのではなく、下記のようにWrapperをかませます。下記の例ではtime()を使っています。(timeはCライブラリ提供の関数ですが、OSが提供する関数だと想定してください)。 インターフェイスを引数OUTから、戻り値OUTに変更しています。また、OSから取得できる単位がミリ秒だったりマイクロ秒だったりしても、その変換はここで吸収できます。さらに、組込みだと独自のタイマから時間を取得すると思いますが、差分は全てOSWrapper_getTime()で吸収できます。インターフェイス変更が生じても、OSWrapper_getTime()を使っているコードには一切手を加えないで済みます。

OSWrapper.c
#include <time.h>
int OSWrapper_getTime()
{ 
	time_t t;
	time(&t);
	return (int)t
}

Wrapper用のテストダブル

テストダブルはWrapperに対して作ります。OS提供関数そのものに対してのテストダブルを作ると、後で変更が発生した時に面倒になります。例えば、以下のようにします。

OSWrapper.c
#include <time.h>
// int OSWrapper_getTime()
static int OSWrapper_getTime_impl();
{ 
	time_t t;
	time(&t);
	return (int)t
}
OS_FUNC os = {
	.getTime = OSWrapper_getTime_impl,
	.sendMsg = OSWrapper_sendMsg_impl,
	.recvMsg = OSWrapper_recvMsg_impl,
};
int OSWrapper_getTime_Fake();
{ 
	return 10;
}
// テストでの呼び出し時に置き換える
os = .getTime = OSWrapper_getTime_Fake,

あるいは、Wrapper内で置き換える

状況にもよりまずが、OSのようにプロダクト用コードとテスト用コードの置き換えくらいしか発生せず、めったに変更しないようなところであれば、以下のようにWrapper内でのifdefスイッチでも許されるような気もしています。

OSWrapper.c
#include <time.h>
int OSWrapper_getTime()
{ 
#ifndef UNIT_TEST_ON_HOST
	time_t t;
	time(&t);
	return (int)t
#else
	return OSWrapper_getTime_Fake();
#endif
}

やりすぎ注意

状況(開発規模、工数、他メンバーのレベル、プロダクトの寿命、将来性、自分のやる気、などなど)に応じて、どのレベルの設計にするかを見極めるのが大事だと思います。
例えば上述のOSWrapperですが、僕はifdefスイッチで良いと思います。しかし、マルチプラットフォーム展開を考えているような製品の場合には、ちゃんと切り替えられる仕組みを用意すべきです。

逆に、すべてのモジュールに対してそのような仕組みを導入するのはどうかと思います。全ての関数が関数ポインタになっていたらやりすぎですよね?
(クラスやOOPを習いたての人が無駄に継承使いまくるみたいな事態にならないようにしましょう)

教育大事

テストというか一般的なことになりますが、メンバーへの教育は非常に大事だと思います。

例えば、先ほどの関数ポインタで置き換えるという例ですが、これはテスト用に限らずしばしば使われるテクニックだと思います。ただし、初見だと何をやっているのか全く分からなかったり、知っていても実際の処理がどこにあるのかを探すのに時間を費やしたりします。場合によってはICEで追っかけたり。。。

また、複数のモジュール(または処理)を切り替えるのに、switch-caseではなく、インターフェイスを使うというのもよくあるテクニックだと思います。これも、拡張性を持たせるためにせっかく頑張って設計しても、他のメンバーに正しく伝わっていないと、結局switch-case文だらけになってしまったりします。

(どちらも僕が新卒エンジニアのときにやったことです。。。)

おわりに

本を読んでいないと分かりづらい記事になってしまったかもしれません。
でも、この本はめちゃくちゃ良いので、ぜひ買って読んでいただきたいです。
僕ももっと早くにこの本を読んでおけばよかったです。個人的には、今まで読んできた技術書の中で断トツで良かったです。ちなみにこれまでのトップはCODE COMPLETE。

この記事を書いていて再認識したのですが、「TDD = ユニットテスト」ではありません。「TDDは良い設計を導くもの。TDDをやるためにユニットテストが必要」という認識が正しいと思います。実際、この記事もテストの話よりも設計の話がほとんどですし。
(なので、TDDは導入しないけど、ユニットテストだけ導入してデグレを防ぐ、というのも有りだと思います。)

コード

本書のサンプルコードは、前半はUnityで、後半はCppUTestで書かれています。
Unity、CppUTest、GoogleTestのそれぞれで10章までの写経をしたので、よろしければ参考にしてみてください。また、各種モックも使ってみました。
https://github.com/take-iwiw/TDD_EmbeddedC

おまけに、Unity用にテストケースからテストランナーを生成するスクリプトを作りました。(https://github.com/take-iwiw/TDD_EmbeddedC/blob/master/Unity/generate_TEST_RUNNER.py)
(Unity公式のrubyスクリプトでも似たことはできるのですが、テストグループごとにmain関数が作られてしまうっぽいので、シンプルにコード生成だけをするスクリプトを作りました。)

本記事のコードは、記事を書きながら書いたものなので、申し訳ありませんが完全版はありません。

76
68
1

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
76
68

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?