LチカをTDDに基づいて開発したときの、ソフト構造が作られていく過程を見ていきます。
前回(https://qiita.com/take-iwiw/items/396959d1d7dffee479f7 )の続きになります。
LedBlinkを作る
LedBlinkの責務とインターフェイスを考える
まず最初に、全体像をイメージしました。上図のようなものを頭の中に思い描きました。LedBlinkモジュールから作ろうとしました。LedBlinkにはvoid start(int interval)
というインターフェイスが必要で、以下のような責務を与えました。
- LedBlinkの責務
- 指定された間隔(ミリ秒)で、LEDを点滅させる
LedBlinkをテストしようとする
TDDでは、 テストFIRST が大事なので、まずLedBlinkのテストケースを書こうとします。
しかし、何をテストすべきかが分かりません。LedBlinkの責務から期待動作に変換してみます。
テストケースを考える時には、まず日本語で対象モジュール(関数)の責務を考え、その後具体的な期待動作(コラボレータへの操作等)に変換するとやりやすかったです。
でも、あまり細かくしてしまうと、テストケース = 中身のコードとなってしまうので注意が必要そう。
LedBlinkをテストできるように設計を見直す
- LedBlinkの期待動作
-
start()
で、Timer0にコールバック登録する - コールバックが呼ばれたときに時間経過を調べて、指定間隔のたびに、LEDをON/OFFする
-
2つ目のLED ON/OFF確認は、LedBlinkが直接LEDをON/OFFしているというのが問題を難しくしてます。実際にLEDをON/OFFするのは別途LedDriverを用意して、そいつの責務にします。
- LedBlinkの期待動作 (最終)
-
start()
で、Timer0にコールバック登録する - コールバックが呼ばれたときに時間経過を調べて、指定間隔のたびに、`LedDriver_turnOn()/LedDriver_turnOff()を呼ぶ
-
その結果、まずは以下のように、ソフト構造を見直しました。
これでもまだ、LedBlinkはテストできません。LedBlinkをテストするためには、LedBlinkが依存しているLedDriverとTimer0モジュールが必要になります。さらに、それに紐づいてavr/io.h
も必要になります。
テスト時には、両モジュールをFakeに置き換えて、Fake内で関数の呼ばれ方や状態を保持し、テストケースがそれを盗み見る、という方法でチェックできそうです。プロダクト用コードとFakeを切り替える方法はいろいろありますが、今回はInterfaceを使用しました。
プロダクト用関数とテスト用のFake関数で同じインターフェイス(関数の型)を持たせて、LedBlinkの呼び元で指定できるようにします(依存性の注入)。
その結果、ソフト構造を以下のようにしました。
LedBlinkのテストに必要なモジュール(ひとまずFake)を実装する
LedDriverFakeとTimer0Fakeを実装します。(プロダクト用の本物のLedDriverとTimer0はまだ実装しません。)
interface
ですが色々と実装方法はあります。いっそ、C++にして 本当の interfaceにしてもいいと思います。Cでも模擬することが出来ます。あるいは、全関数の関数ポインタを突っ込むという方法でもできます。
今回は、関数ポインタを持つ構造体テーブルを使うことにしました。
この実装方法も、誰にどれくらい使われるか? マルチインスタンスは有り得るのか? などを考慮して決める必要があります。
コード全文は載せませんが、以下のような雰囲気です。
typedef struct {
void (*create)();
void (*destroy)();
void (*turnOn)();
void (*turnOff)();
} ILedDriver;
extern ILedDriver LedDriver;
extern ILedDriver LedDriverFake;
ILedDriver LedDriverFake = {
.create = LedDriverFake_create,
.destroy = LedDriverFake_destroy,
.turnOn = LedDriverFake_turnOn,
.turnOff = LedDriverFake_turnOff,
};
また、Fakeモジュールには、結果を盗み見れるようなスパイ用関数を用意しました。
最後に、必要に応じて、Fakeモジュールのテストも行います。(これもユニットテストで、テストケースを書く)
LedBlinkのテストをする
ようやく、テスト対象モジュールであるLedBlinkのテストが出来るようになりました。テスト時の各モジュールの関連は上図のようになります。
初期化処理のテストを考えます。テストケース内では、必要な事前処理をして、LedBlink_start()
を呼び、結果をFakeモジュールから盗み見て確認します。
最初はLedBlink_start()
は未実装なので、テストは失敗します。
テストが通るようにLedBlinkを実装します
テストが通るようにLedBlinkを実装します
他の処理についても、テスト作成 → 実装、する
例えば、コールバック時にちゃんとLEDを点滅するかどうかをテストします。
コールバックを発生させるために、Timer0Fakeに、割り込みを模擬する関数を追加します。
割り込みは1msec間隔で起きるので、1000回目を区切りにOFF->ONするかどうかをチェックしています。
このときのテストケースは以下のようになります。
割り込みにからむ処理をちゃんとテストできたのはかなり凄いと感動しました。
TEST(LedBlinkTestCommon, blink)
{
LedBlink_start(&LedDriverFake, &Timer0Fake, 1000);
TEST_ASSERT_EQUAL(0, LedDriverFake_getStatus());
for (int i = 0; i < 999; i++) {
Timer0Fake_invokeIRQ();
}
TEST_ASSERT_EQUAL(0, LedDriverFake_getStatus());
Timer0Fake_invokeIRQ();
TEST_ASSERT_EQUAL(1, LedDriverFake_getStatus());
}
他にも、終了時の処理等、必要なケースを追加していきます。
LedBlinkが完成した
これでLedBlinkは完成です。LedDriverとTimer0が期待通りに動けば、実機でLチカが動くはずです。
なお、これまで作成したテストはHostとTargetの両方で動かせるのでCommonのテストとしました。
(Hostだけのテストでもいいような気もしますが、特にHostだけにする必要もないのでCommonにします。)
LedDriverを作る
流れとしてはLedBlinkと同じです。
LedDriverの責務とインターフェイスを考える
LedDriverの責務とインターフェイスは、LedBlink作成時に既に決めていました。
- LedDriverの責務
-
create()
でLED用GPIOを出力設定する -
turnOn()
でLED用GPIOをHighにする -
turnOff()
でLED用GPIOをLowにする - (LED用GPIOのポートは固定の決め打ちにする)
-
LedDriverをテストしようとする
LedDriverはavr/io.h
を使用しています。これは、ATMELから提供されているハードウェアレジスタにアクセスするためのマクロ群です。
例えば、PORTB = 0xFF
は(*(volatile uint8_t *)((0x05) + 0x20)) = 0xFF;
に展開されます。
LedDriverをテストするときに、avr/io.h
が邪魔になります。
ハードウェアアクセスをどうするか?
ハードウェアへのアクセスですが、メモリマップドレジスタ方式においては、アドレスと値だけを定義した、下記のようなインターフェイスで一般化できます。
void write(int address, int data);
int read(int address);
今回もこの方式にすれば、CMockによる置き換えや、アクセス先のアドレスを適当な変数にして結果をダンプして確認する、といった手段でテストが可能になります。
しかし、大量のレジスタやビット制御のあるいまどきのマイコンに対して、そんなことはしたくありません。
せっかくマクロ(avr/io.h
)が提供されているので、それを使うようにしたいです。
Linuxでも/dev/mem
をmemmapすればどんなデバイ制御もできますが、実際にはioctl経由での制御になると思います。これも、関数ポインタなんかじゃごまかせないです。
もしもこれが関数だったら
もしもハードウェアアクセス用のマクロが、「関数」として提供されていたら話は簡単になります。ハードウェアアクセス用関数はWrapper経由で使用するようにして、そのWrapperをFakeに置き換えることでテスト可能にできそうです。
例えば、STM32のHALであれば、ライブラリとして各種ハードウェアアクセスのためには関数(HAL_GPIO_WritePin()
等)が提供されています。
マクロにはマクロで対応するしかない
もっといい方法があると思います。
が、とりあえずマクロで定義されているものは、マクロで定義し返すという解決策しか思いつきませんでした。
#ifndef UNIT_TEST_HOST
#include <avr/io.h>
#define AVR_WRAPPER_IO(X) X
#else
extern int Avr_wrapper_io_fake_val;
#define AVR_WRAPPER_IO(X) Avr_wrapper_io_fake_val
int Avr_wrapper_io_fake_getLastVal();
void Avr_wrapper_io_fake_setLastVal(int val);
#endif /* UNIT_TEST_HOST */
上記のようにマクロを定義して、自分のコード内からはAVR_WRAPPER_IO()
経由でアクセスするようにします。で、ホスト環境でのユニットテストの時には変数にアクセスするようにします。その変数はavr_wrapper_fakeから盗み見れるようにします。
LedDriveのテストをする
最終的には、以下のような構成でLedDriverをテストします。
また、avr_wrapperを切り替えることで、Target上でもテストを流すことが出来ます。その際には期待値はLEDが実際に光ることなので目視での確認になります。(所定レジスタの値を読んで比較でもいいかもしれない。明るさセンサを持った自動テスト装置を作るということも可能)
Timer0を作る
力尽きたので、Timer0は普通に作った。
あきらめた理由
avr/io.h
にはレジスタアクセス用マクロに加えて、ビット位置指定の定義(OCIE0A)などがあり、Timer0ではそれらを使っています。
そのため、ホスト環境においてもavr/io.h
を取り込む必要があります。すると、avr/io.h
がさらに取り込んでいる他のヘッダも取り込む必要があります。
ホスト環境のビルド時に、AVR toolchainのヘッダをincludeパスに含めればいいのですが、一般的なstdio.h等が競合してしまいました。
必要なヘッダファイルだけをコピーすれば解決できそうですが、あまりスマートじゃないので止めました。
ここまでのソフト構造
ここまでの開発で、以下のような構造のソフトが出来上がりました。
実際に僕が実装したのはここまでになります。
プロダクト用
プロダクト用では以下のような関連になります。
ちなみに、LEDはちゃんとチカチカ点滅しました。
作ってはいないけど、設計だけ考えた
先ほどのavr/io.h
に対する依存をもう少しスマートに解決できないかを考えました。考えただけなので、実装はしていません。
GPIO Driverの導入
LedDriverがあまりにもそのままなので失念していましたが、通常はGPIO Driverを使いますよね。ということで、GPIO Driver経由でavr/io.h
にアクセスします。
しかし、これはただ単に問題の先送りで、GPIO Driverのテストをどうするか? という問題にすり替わっただけです。
完璧な解決策ではないのですが、Host上でのテストはあきらめて、Target上でテストすればいいのかなと思いました。テストケース内で、所定のアドレスのレジスタの値をreadすることでチェックで出来そうです。
Timer0の改善
Timer0の方はどうしましょう。テストしないという判断もありだとは思うのですが、登録されたコールバック関数の管理など、ロジックをチェックしたい所もあります。
悩んだのですが、ロジック部とハードウェアアクセス部に分けて、ロジック部だけテストするというのが現実的な落としどころかなと思いました。
Timer0_ioの関数の粒度ですが、あまり細かくすると大変な手間がかかります。(レジスタ一つ一つに対して分けると数十個の関数が必要になってしまいます。)
ですので、Timer0と同じくらいの粒度(init()など)でいいかなと思います。
テスト可能にする
それぞれテスト可能にするには、GPIODriverとTimer0_ioをFakeに置き換えればOKです。置き換え方法は何でもいいのですが、リンカで置き換えるのが一番楽かなと思います。(interfaceや関数ポインタでやるには手間がかかる割に、メリットが少ないと思います)
最終形態
ということで、Lチカ用の"ぼくのかんがえたさいきょうのソフト構造"は以下になります。
明らかにやりすぎだというのは分かっていますよ
おわりに
TDDによる恩恵
Lチカでやる意味はありませんが、以下のようなメリットがありました。
- LedBlinkはどんなLedDriverでも使えるようになった (インターフェイスが同じである限り)
- Ledという名前を変えるべきでしたが、LED以外のどんなライトでも点滅できます
- LedBlinkはどんなTimerでも使えるようになった (インターフェイスが同じである限り)
- これもTimer0の0が余計ですが、他のタイマやOSから提供されるようなタイマにも対応できます。
- テストをしながらの開発なので、品質も高いはず
- でも、繰り返しになりますが、TDDの本当の狙いはテストによる品質向上よりも、良い設計にみちびくためのものだと思います
気になった点
- 開発時間
- Lチカの実現だけなら30分くらいで出来るはずが、6時間くらいかかった (総ステップ数 = 971行)
- メンテの手間
- モック(Fake)モジュールをたくさん作ったけど、メンテが大変そう
- テストケースのメンテも大変そう
- 事前に全体をイメージできないと難しそう
- デバイス制御に対してテストFIRSTは難しそう
- 新しいマイコンのペリやデバイスをいじるときって、色々と試行錯誤すると思います
- 事前にテスト期待値を考えるのは難しそう。というかそれができれば仕事の半分は終わっているという。。。
- でも
-
start()
関数をよんだら、startビットが立ったね、みたいな簡単な確認になら使えそう - デバドラ内でもロジック部とハードウェア依存部があるので、そこを切れる設計につながるのはいいと思った
-
感想
大変でした。
仕事で導入しようとすると、一時的にパフォーマンスが大幅に下がると思うので、上長やチームの理解が必要そうです。
長い目で見たらプラスになる(らしい)のですが、一度成功体験がないと厳しいんじゃないかと思います。自分のモチベーション的にも、説得材料的にも。
新規に導入するには
まずはユニットテスト(≠ TDD)から導入するのがハードルが低くていいと思いました。コスパ的にも良いと思います。
複雑なロジックを持つ関数を実装するときに、まずはホスト環境で試してみるというのはよくやると思います。
そのときにユニットテストを使い、使用したテストケースとテスト実行環境を残しておき、定期的に実行できるようにします。
すると、実装時の効率化、品質向上に加えて、将来的なデグレ防止にもつながります。
自モジュール内のピュアなロジック関数(依存モジュールがなく、入出力が引数と戻り値だけの関数)に対してはすぐに適用できると思います。
また、出来るだけテスト可能箇所を増やそうと思うことで、外部依存部とロジック部を明確に分けるような設計を意識するようにもなると思います。(軽いTDDとでもいうのかな?)
(追記)------------------
でも、こういうヘルパー関数って大抵はstaticをつけてファイルスコープにすると思います。例えば、static int convA2B(int A){...}
みたいな関数はテストケースからは呼べません。同じファイル内でint UTestBackdoor_convA2B(int A){convA2B(A);}
みたいにバックドアを用意したらできるけど、恰好悪い。。。
それか、ヘルパー関数群はまとめて別ファイルに定義すればいいのかも。(余計なヘッダincludeも持ちたくないので、こうした方がよさげ)
(追記ここまで)------------------
デバドラに対しては、以下のように回帰テストに適用できると思います。
- デバドラが完成してそれなりに動いた後、レジスタダンプをしてそれを期待値として保存しておく。HW屋さんにもチェックしてもらう
- 定期的にその期待値と一致するかどうかの回帰テストをする
みたいな感じ。
また、手動でやっているテストの一部自動化なんかにも役立つと思います。全部自動化するのにこだわるのではなく、手動による操作/確認と組み合わせてもいいと思います。