このシリーズはC言語組み込み開発における単体テストを体系的に整理することを目的としています。
- #1(本記事):概念・テストレベル・手法・テストダブル
- #2:C言語ツール選定 ― 商用ツール・GoogleTest・Unity 徹底比較
- #3:TDD・CI/CD ― テスト自動化で品質担保と工数削減を実現する
はじめに
組み込みソフトウェアの開発現場では、「テストはハードウェアが揃ってから」という文化がまだ根強く残っている。実機がなければ動作確認ができない、という前提が長らく当然視されてきた。
しかしソフトウェアの規模と複雑性が増す中で、その前提はリスクになりつつある。結合直前になって発覚するバグ、原因特定に何日もかかるデバッグ作業、リリース直前の手戻り。これらの多くは、関数単位での検証——つまり単体テスト——を早期に実施することで防げる。
本記事ではシリーズ第1回として、単体テストの基本概念・テストレベルの全体像・テスト手法・テストダブル(スタブ・モック・フェイク)までを整理する。ツール選定や実践的な自動化の話題は#2・#3で扱う。
1. 単体テストとは
定義
単体テスト(Unit Test)とは、ソフトウェアの**最小構成単位(関数・モジュール)**を独立して検証するテストである。外部依存(ハードウェア、他モジュール、OS)を切り離し、対象の関数が仕様通りに動作するかをコードレベルで確認する。
テストレベルの全体像
ソフトウェアテストは一般に以下の4レベルで構成される。
| レベル | 対象 | 実施タイミング |
|---|---|---|
| 単体テスト | 関数・モジュール | 実装直後 |
| 結合テスト | モジュール間インタフェース | 統合時 |
| システムテスト | システム全体の動作 | 完成後 |
| 受け入れテスト | ユーザー要件の充足 | リリース前 |
単体テストは最も早いフェーズで実施されるため、バグの発見コストが最も低い。「テストは後工程で」という考え方は、実際には後工程のコストを増大させる。
テストレベルごとの観点・役割・考え方の違い
各レベルは「何を確認するか」「誰が責任を持つか」「どこまで依存を許容するか」が異なる。単体テストだけを充実させても不十分であり、各レベルが適切な役割を果たすことで初めてソフトウェア品質が保証される。
単体テスト(UT:Unit Test)
テスト観点: 関数・モジュール単体が仕様通りに動作するか。正常系・異常系・境界値を網羅できているか。内部ロジックの分岐が意図した通りに動くか。
役割: 実装者自身が書く「開発者テスト」。コードを書いた直後に実施することでバグを最小単位で特定できる。設計の誤りを早期に発見し、リファクタリングの安全網にもなる。
考え方: 外部依存(ハードウェア・他モジュール・OS)はモック・スタブで切り離す。「この関数は、この入力に対して、この出力を返すべきか」という観点で設計する。カバレッジ(C0/C1/MC/DC)の充足がここで問われる。
組み込みでの特徴: ハードウェア非依存なロジック層が主な対象。ドライバ層はHAL境界でスタブに差し替えてテストする。ホスト環境で実行できるため、実機なしに早期から開始できる点が大きな利点。
結合テスト(IT:Integration Test)
テスト観点: モジュール間のインタフェースが正しく機能するか。データの受け渡し・呼び出し順序・タイミングに問題はないか。モジュールを組み合わせたときに想定外の干渉が起きないか。
役割: 個々のモジュールが「単体では正しい」ことが前提のうえで、それらを繋いだときの動作を確認する。単体テストでは検出できない、インタフェースの誤解・API仕様の取り違いを発見する場。
考え方: 単体テストとは異なり、モックで切り離した依存を実際のモジュールに差し替えて検証する。テスト範囲が広がる分、問題が発生したときの原因特定は難しくなる。
組み込みでの特徴: ISO 26262では結合テストで「関数カバレッジ・コールカバレッジ」の充足が求められる。ソフトウェア結合テスト(SW-IT)とソフトウェア・ハードウェア結合テスト(HW-SW IT)を分けて考えることが多い。後者は実機やHIL(Hardware In the Loop)環境が必要になる。
システムテスト(ST:System Test)
テスト観点: システム全体として要件・仕様を満たしているか。性能・信頼性・安全性の要件を満たしているか。実環境(または実環境に近い環境)での動作確認。
役割: ソフトウェアとハードウェアが統合されたシステムを対象に、ユーザー視点の要件を検証する。開発組織として「出荷できる品質か」を判断する最後の内部関門。
考え方: 単体・結合テストがロジック中心であるのに対し、システムテストは「ユーザーがどう使うか」という振る舞いベース(ブラックボックス的)の観点が中心になる。カバレッジよりもシナリオの網羅性が重視される。
組み込みでの特徴: 実機必須。環境依存のバグ(タイミング、電源変動、温度特性など)がここで初めて現れることも多い。テスト実行時間が長くなるため、事前の単体・結合テストで品質を担保しておくことが重要。
受け入れテスト(AT:Acceptance Test)
テスト観点: 顧客・エンドユーザーの要求を満たしているか。契約仕様・規制要件への適合。
役割: 顧客またはそれに準じる立場(品質保証部門など)が実施する最終確認。「開発側が正しく作った」ことの証明ではなく、「顧客が求めたものを作った」ことの確認。
考え方: 開発者視点ではなく、ユーザーストーリー・ユースケースベースで設計する。機能安全領域では規格への適合証跡としての意味も持つ。
各レベルの考え方の違いをまとめると
| 観点 | 単体テスト | 結合テスト | システムテスト | 受け入れテスト |
|---|---|---|---|---|
| 確認対象 | 関数の正しさ | インタフェースの整合性 | システム要件の充足 | 顧客要件の充足 |
| テスト設計の起点 | 詳細設計・コード | インタフェース仕様 | システム要件仕様 | ユーザー要求仕様 |
| 依存の扱い | モック・スタブで切離 | 実モジュールに差替 | フル統合 | フル統合 |
| 実機の要否 | 不要(ホストで可) | 一部不要 | 必要 | 必要 |
| バグ発見時の原因特定 | 容易(範囲が狭い) | やや難 | 難 | 難 |
| 主な実施者 | 実装担当者 | 統合担当者・テスト担当 | テスト担当 | 顧客・品質保証部門 |
| カバレッジ要件 | C0/C1/MC/DC | 関数・コール | シナリオ網羅 | 要件網羅 |
重要な考え方: テストレベルは「下から積み上げる」ものであり、上位レベルで下位レベルの代替はできない。単体テストで発見すべき問題をシステムテストで発見すると、修正コストは数倍〜数十倍に膨らむ。各レベルが本来の役割を果たすことで、全体の品質コストが最適化される。
単体テストのメリット
- 早期バグ検出:実装直後に問題を発見できる
- リグレッション防止:変更後に既存動作が壊れていないことを確認できる
- 設計品質の向上:テストしやすい設計=疎結合な設計であることが多い
- ドキュメント代替:テストコードが関数の仕様・使い方を示す
組み込み開発での注意点
組み込みC言語開発では、ハードウェア依存コード(レジスタ操作、割り込み処理など)を直接テストすることは難しい。このため、ハードウェア抽象化層(HAL)を設け、ビジネスロジックをHALから分離する設計が単体テスト導入の前提となることが多い。
2. テスト手法
ホワイトボックステストとブラックボックステスト
| 種別 | 視点 | 特徴 |
|---|---|---|
| ホワイトボックス | 内部構造(コード)を見る | カバレッジ計測と組み合わせる |
| ブラックボックス | 入出力仕様のみを見る | 実装非依存、要件ベース |
単体テストではホワイトボックス的アプローチが主体になるが、仕様から導出したテストケース(ブラックボックス)と、コードから導出したテストケース(ホワイトボックス)を組み合わせるのが実践的である。
同値分割と境界値分析
テストケースを効率よく設計するための代表的な手法。
同値分割:入力値を「同じ振る舞いをするグループ(同値クラス)」に分け、各グループから1つ代表値を選ぶ。
例)引数が 0〜100 の範囲のみ有効な関数
有効クラス:0〜100 → 代表値:50
無効クラス1:< 0 → 代表値:-1
無効クラス2:> 100 → 代表値:101
境界値分析:バグは境界付近に集中しやすい。同値クラスの境界値を重点的にテストする。
例)上記の場合
境界値:-1, 0, 1, 99, 100, 101
カバレッジ基準
コードのどの程度をテストで網羅したかを示す指標。
| カバレッジ | 意味 | 要求される規格例 |
|---|---|---|
| C0(命令カバレッジ) | 全命令を1回以上実行 | 一般的なベースライン |
| C1(分岐カバレッジ) | 全分岐(true/false)を実行 | IEC 61508, A-SPICE推奨 |
| MC/DC | 各条件が判定に独立して影響することを確認 | ISO 26262, DO-178C |
組み込み向け機能安全規格(ISO 26262, IEC 61508)では、安全度水準に応じたカバレッジ基準の達成が求められる。
スタブ・モック・フェイク
単体テストでは、テスト対象の関数が他の関数やハードウェアに依存していることが多い。こうした外部依存を切り離すための代替コードを総称して「テストダブル」と呼ぶ。スタブ・モック・フェイクはそれぞれ役割が異なる。
なぜ外部依存を切り離すのか
例えば、センサー値を読み取って判定する関数をテストしたい場合を考える。
// テストしたい関数
int judge_temperature(void) {
int temp = HAL_ReadTemperature(); // ハードウェア依存
if (temp > 80) return ERROR;
return OK;
}
HAL_ReadTemperature() はハードウェアを叩く関数なので、ホストPC上のテスト環境では動作しない。また、テストで「温度が80℃を超えたときの挙動」を確認したくても、実際のセンサー値は制御できない。
これを解決するのがテストダブルの考え方で、HAL_ReadTemperature() を任意の値を返す偽物に差し替えてテストを行う。
スタブ(Stub)
スタブは呼び出されたときに固定値や事前に設定した値を返すだけの代替関数。テスト対象関数が依存している関数のうち、「呼び出し先の動作を制御したい」場合に使う。戻り値の制御が主目的で、呼び出されたかどうかの検証は行わない。
// 本物のHAL関数の代わりに使うスタブ
int HAL_ReadTemperature(void) {
return 90; // 常に90℃を返す(異常系テスト用)
}
スタブの差し替え方法はいくつかある。
【差し替えの主な方法】
① ソースファイルの置き換え(リンク時に差し替え)
テスト用ビルドでスタブのオブジェクトをリンクする。
本番コードに手を加えなくてよいのがメリット。
② 関数ポインタによる差し替え
HAL関数を関数ポインタ経由で呼ぶ設計にしておき、
テスト時にポインタをスタブ関数に向ける。
③ コンパイルスイッチによる切り替え
#ifdef TEST_MODE ... #endif でスタブと本実装を切り替える。
手軽だが本番コードへの混入リスクがある。
モック(Mock)
モックはスタブとは目的が異なる代替関数で、「どう呼ばれたか」を検証することが主目的。「この関数が正しい引数で1回だけ呼ばれたか」「呼び出し順序は正しいか」といった振る舞いの検証(behavior verification)に使う。スタブが戻り値の制御(状態の検証)を目的とするのに対し、モックは関数間のやり取り自体を検証する点が本質的な違いだ。
// FFFを使ったモックの例
#include "fff.h"
DEFINE_FFF_GLOBALS;
FAKE_VALUE_FUNC(int, HAL_ReadTemperature); // モック関数を定義
void test_judge_temperature_error(void) {
HAL_ReadTemperature_fake.return_val = 90; // 返す値を設定
int result = judge_temperature();
TEST_ASSERT_EQUAL(ERROR, result); // 戻り値の検証
TEST_ASSERT_EQUAL(1, HAL_ReadTemperature_fake.call_count); // 呼び出し回数の検証
}
スタブとモックの使い分けは「何を検証したいか」によって決まる。
| スタブ | モック | |
|---|---|---|
| 主な目的 | 依存を切り離してテストを成立させる | 関数間のやり取りを検証する |
| 戻り値の制御 | ○ | ○ |
| 呼び出し回数・引数の検証 | ✕ | ○ |
| 実装の手間 | 低い | やや高い |
| 使いどころ | 出力値・状態の確認が目的のテスト | 関数が正しく呼ばれたかを確認したいテスト |
フェイク(Fake)
フェイクは本物に近い動作をするが、テスト用に簡略化された実装。スタブのように固定値を返すのではなく、ある程度の内部状態を持って動作する。例えば「本物のフラッシュドライバの代わりに、メモリ上で読み書きをエミュレートするフェイクドライバ」のようなもの。
// フェイクの例:実際のフラッシュの代わりにRAM上で動作する
static uint8_t fake_flash[FLASH_SIZE];
int FLASH_Write(uint32_t addr, uint8_t data) {
fake_flash[addr] = data;
return OK;
}
int FLASH_Read(uint32_t addr) {
return fake_flash[addr];
}
テストダブルの種類まとめ
テストダブル(外部依存の代替コード全般の総称)
├── スタブ :固定値を返す。依存の切り離しが目的
├── モック :返り値+呼び出しを検証。関数間やり取りの確認が目的
├── フェイク :簡易実装で本物に近い動作をエミュレート
├── スパイ :本物の実装を使いながら呼び出し情報を記録(組み込みCでは少ないが、JavaやJSでは頻出)
└── ダミー :引数を埋めるだけで実際には使われない(最もシンプル)
組み込みC言語の単体テストで日常的に使うのはスタブとモックが中心で、フラッシュやUARTのような周辺ドライバが絡む場合にフェイクが有効になる。
おわりに
本記事では単体テストの概念・テストレベルの役割の違い・テスト手法・テストダブルまでを整理した。次回はこれらの知識を前提に、C言語での具体的なツール選定を扱う。
次の記事
【単体テスト#2】C言語ツール選定編 ― 商用ツール・GoogleTest・Unity 徹底比較
(公開後にリンクを追記予定)