組み込みソフトウェアの開発では、「テストは実機がそろってから」と考えられがちです。しかし単体テストは、実機ではなく開発用のPCで実行するのが一般的です。

使うのは、C言語向けのテストフレームワーク「Unity」と、gccに付属するカバレッジ測定ツール「gcov」です。題材は、公開中のArduino総合演習のコードで、これをPC上で単体テストします。

Unityは、unity.cを一緒にコンパイルするだけで使えます。gcovで分岐まで測ると、テストが全部PASSしていても通っていない分岐が見つかります。

この記事でできるようになること

  • C言語の関数にUnityでテストを書き、実機なしでPC上で回す
  • gcovでテストが通っていない分岐を見つけ、テストを足して詰める
  • 条件が2つの判定を、MC/DCまで測る
  • レジスタを触るコードを切り離してテストする

費用 0円Windows(MSYS2)gcc 16.1Unity 2.7.0

だだいき現役の車載組み込みソフトエンジニア車載マイコンのソフトウェアを業務でC言語を使って開発しています。応用情報技術者、C言語プログラミング能力検定1級。この記事のテスト結果とカバレッジの数値は、Windows 11のPCで実際に測ったものです。

C言語の単体テストはPCで回せる

PCで確かめられること、確かめられないこと

組み込みソフトウェアのコードは、ロジックとハードウェアへの指令でできています。単体テストで確かめるのは、このうちロジックの正しさや境界値、エラー処理です。gccの警告とcppcheckが2つとも見逃したバグのうち、データ次第のゼロ除算と8ビットのタイマの差は、単体テストの境界値で見つかります(C言語の静的解析は無料でできる|gccの警告とcppcheckで27種のバグを比べた)。

PCでのテストは、x86向けにコンパイルしたコードで動くので、マイコンの上での実行を確かめるわけではありません。タイミングや消費電力のように実機に載せて初めて分かる問題は、統合テストやシステムテスト、実機でのテストで確かめます。Unityはマイコンの上でも動かせますが、PC上で回せば実機を用意せずに始められます。

フレームワークはUnity(Cだけで書ける)

Unityは、C言語向けの軽量な単体テストフレームワークで、主に組み込みソフトウェア向けに作られています。有名なゲームエンジンのUnityとは別物です。

テストコードもすべてC言語で書けるので、C++を持ち込まずに済みます。本体は、Cのソースが1つとヘッダが2つの、合わせて3つのファイルです。

GitHubのリリースから取得すると、srcフォルダにunity.c・unity.h・unity_internals.hが入っています。使ったのはv2.7.0(2026年7月16日公開・MITライセンス)です。

gccとgcovを用意する(Windows)

カバレッジを測るgcovは、gccに付属しています。Windowsでgccとgcovをそろえるには、MSYS2というLinuxに似た環境を使います。公式サイトの手順は次のとおりです。

  1. MSYS2のトップページの「Installation」からインストーラをダウンロードして実行する(64ビットのWindows 10 1809以降が必要)
  2. インストール先は既定のままでよい。完了すると、UCRT64のターミナルが開く
  3. ターミナルで次のコマンドを実行し、確認が出たらEnterキーを押す
UCRT64のターミナル

$ pacman -S mingw-w64-ucrt-x86_64-gcc

gcc --versionでバージョンが表示されれば、インストールはできています。

UCRT64のターミナル

$ gcc --version
gcc.exe (Rev5, Built by MSYS2 project) 16.1.0

この記事の実行結果は、このgcc 16.1.0で取ったものです。公式サイトの例では16.2.0と表示されていて、入れた時期によって版は変わります。ターミナルを閉じてしまったときは、スタートメニューのMSYS2からMSYS2 UCRT64を選べば、いつでも開けます。

作業フォルダを作る

作業用のフォルダは、MSYS2のホーム(~)の下に作ります。

UCRT64のターミナル

$ mkdir -p ~/c-unit-test-demo/unity ~/c-unit-test-demo/src ~/c-unit-test-demo/test
$ cd ~/c-unit-test-demo

既定の場所にインストールした場合、~は、WindowsのエクスプローラーではC:\msys64\home\ユーザー名です。Windowsのユーザー名に日本語が含まれると、このフォルダの名前にも日本語が入り、後で測るカバレッジが0%になることがあります。その場合は、コマンドの~を英数字だけのフォルダ(例:/c/work)に置き換えて作ります。

Unityの3ファイルは、c-unit-test-demoの中のunityフォルダにコピーします。

フォルダの構成は次のとおりです。

フォルダ構成(~/c-unit-test-demo)

c-unit-test-demo/
  unity/  unity.c  unity.h  unity_internals.h
  src/    wbgt.c  wbgt.h  config.h(テストする側のコード)
  test/   test_wbgt.c(テストのコード)

最初のテストを書いて回す

普通の入力でテストを4本書き、4本ともPASSしました。

題材:暑さ指数を求める wbgt.c

題材は、公開中のArduino総合演習(自動温湿度調整システム)の中にある、暑さ指数(WBGT)を求めるwbgt.cです。原本のファイル名はwbgt.cppですが、中身はC言語だけで書かれているので、wbgt.cに名前を変えてgccでコンパイルします。

wbgt_estimate()は、気温と相対湿度から、表を引いて暑さ指数を返します。表にあるのは気温21〜40℃と湿度20〜100%だけなので、その外の値は表の端の値として扱います。

wbgt_judge()は、暑さ指数から4段階の警告レベルを決めます。危険側へはすぐ上げ、安全側へは、そのレベルに入るしきい値より2℃下がるまで下げません。しきい値の付近で、警告が上がったり下がったりを繰り返さないようにするためです。

どちらの関数も、引数だけで戻り値が決まるので、テストを書きやすい題材です。

題材にした総合演習のコードは、実機では動かしていません。確かめたのは、PCでのテストと、UNO向けのコンパイラでコンパイルが通るところまでです。

src/wbgt.h

#ifndef WBGT_H
#define WBGT_H

#include <stdint.h>

/* 警告レベル(値が大きいほど危険側) */
typedef enum {
    RISK_NORMAL  = 0,   /* 注意未満(WBGT 25未満) */
    RISK_CAUTION = 1,   /* 警戒(25以上28未満) */
    RISK_SEVERE  = 2,   /* 厳重警戒(28以上31未満) */
    RISK_DANGER  = 3    /* 危険(31以上) */
} RiskLevel;

/* 気温(℃)と相対湿度(%)から室内のWBGT(℃)を推定する(21〜40℃/20〜100%。範囲外は端へ丸め) */
int8_t wbgt_estimate(int8_t temp_c, uint8_t humidity);

/* WBGTから警告レベルを判定する(ヒステリシス付き。currentは前回のレベル) */
RiskLevel wbgt_judge(int8_t wbgt, RiskLevel current);

#endif
src/wbgt.c

#include "wbgt.h"
#include "config.h"

/* ---- 室内用WBGT簡易推定テーブル ----
 * 行:気温21〜40℃(1℃刻み)/列:相対湿度20〜100%(5%刻み)
 * 出典:日本生気象学会「日常生活における熱中症予防指針」Ver.4(2022)
 *       図3「室内を対象とした気温と相対湿度からWBGTを簡易的に推定する図」を数表化
 * 適用条件:日射のない室内・温湿度が一様・気流の弱い環境(屋外には使えない)
 */
#define WBGT_T_MIN  21
#define WBGT_T_MAX  40
#define WBGT_RH_COLS 17

static const int8_t WBGT_TABLE[WBGT_T_MAX - WBGT_T_MIN + 1][WBGT_RH_COLS] = {
    /* 21℃ */ { 14, 14, 15, 15, 16, 16, 17, 17, 18, 18, 19, 19, 19, 20, 20, 21, 21 },
    /* 22℃ */ { 15, 15, 16, 16, 17, 17, 18, 18, 19, 19, 20, 20, 20, 21, 21, 22, 22 },
    /* 23℃ */ { 15, 16, 16, 17, 18, 18, 19, 19, 20, 20, 20, 21, 21, 22, 22, 23, 23 },
    /* 24℃ */ { 16, 17, 17, 18, 18, 19, 19, 20, 20, 21, 21, 22, 22, 23, 23, 24, 24 },
    /* 25℃ */ { 17, 17, 18, 19, 19, 20, 20, 21, 21, 22, 22, 23, 23, 24, 24, 25, 25 },
    /* 26℃ */ { 18, 18, 19, 20, 20, 21, 21, 22, 22, 23, 23, 24, 24, 25, 25, 26, 26 },
    /* 27℃ */ { 18, 19, 20, 20, 21, 22, 22, 23, 23, 24, 24, 25, 25, 26, 26, 27, 27 },
    /* 28℃ */ { 19, 20, 21, 21, 22, 22, 23, 24, 24, 25, 25, 26, 26, 27, 27, 28, 28 },
    /* 29℃ */ { 20, 21, 21, 22, 23, 23, 24, 24, 25, 26, 26, 27, 27, 28, 28, 29, 29 },
    /* 30℃ */ { 21, 21, 22, 23, 23, 24, 25, 25, 26, 26, 27, 28, 28, 29, 29, 30, 30 },
    /* 31℃ */ { 21, 22, 23, 24, 24, 25, 26, 26, 27, 27, 28, 29, 29, 30, 30, 31, 31 },
    /* 32℃ */ { 22, 23, 24, 24, 25, 26, 26, 27, 28, 28, 29, 29, 30, 31, 31, 32, 32 },
    /* 33℃ */ { 23, 24, 25, 25, 26, 27, 27, 28, 29, 29, 30, 30, 31, 31, 32, 33, 33 },
    /* 34℃ */ { 24, 25, 25, 26, 27, 28, 28, 29, 30, 30, 31, 31, 32, 32, 33, 34, 34 },
    /* 35℃ */ { 24, 25, 26, 27, 28, 28, 29, 30, 30, 31, 32, 32, 33, 33, 34, 34, 35 },
    /* 36℃ */ { 25, 26, 27, 28, 29, 29, 30, 31, 31, 32, 33, 33, 34, 34, 35, 35, 36 },
    /* 37℃ */ { 26, 27, 28, 29, 29, 30, 31, 32, 32, 33, 34, 34, 35, 35, 36, 36, 37 },
    /* 38℃ */ { 27, 28, 29, 29, 30, 31, 32, 33, 33, 34, 35, 35, 36, 36, 37, 37, 38 },
    /* 39℃ */ { 27, 28, 29, 30, 31, 32, 33, 33, 34, 35, 35, 36, 37, 37, 38, 38, 39 },
    /* 40℃ */ { 28, 29, 30, 31, 32, 33, 34, 34, 35, 36, 36, 37, 38, 38, 39, 39, 40 }
};

int8_t wbgt_estimate(int8_t temp_c, uint8_t humidity) {
    /* 表の範囲外は端の値へ丸める(DHT11は0〜50℃/20〜90%を返す) */
    if (temp_c < WBGT_T_MIN) {
        temp_c = WBGT_T_MIN;
    }
    if (temp_c > WBGT_T_MAX) {
        temp_c = WBGT_T_MAX;
    }
    if (humidity < 20) {
        humidity = 20;
    }
    if (humidity > 100) {
        humidity = 100;
    }

    uint8_t row = (uint8_t)(temp_c - WBGT_T_MIN);
    uint8_t col = (uint8_t)((humidity - 20 + 2) / 5);   /* 最も近い5%刻みの列へ */
    if (col >= WBGT_RH_COLS) {
        col = WBGT_RH_COLS - 1;
    }
    return WBGT_TABLE[row][col];
}

/* WBGT→レベルの単純変換(ヒステリシスなし) */
static RiskLevel level_of(int8_t wbgt) {
    if (wbgt >= WBGT_DANGER) {
        return RISK_DANGER;
    } else if (wbgt >= WBGT_SEVERE) {
        return RISK_SEVERE;
    } else if (wbgt >= WBGT_CAUTION) {
        return RISK_CAUTION;
    } else {
        return RISK_NORMAL;
    }
}

/* レベルの入口しきい値(RISK_CAUTION以上で使う) */
static int8_t threshold_of(RiskLevel level) {
    if (level == RISK_DANGER) {
        return WBGT_DANGER;
    } else if (level == RISK_SEVERE) {
        return WBGT_SEVERE;
    } else {
        return WBGT_CAUTION;
    }
}

RiskLevel wbgt_judge(int8_t wbgt, RiskLevel current) {
    RiskLevel raw = level_of(wbgt);

    if (raw > current) {
        /* 危険側への変化は即時に反映する(安全側) */
        return raw;
    }
    if (raw < current) {
        /* 安全側への変化は、今のレベルの入口よりWBGT_HYST℃下がるまで保留。
         * しきい値の上下でON/OFFがバタつくのを防ぐ(1段ずつ降ろす) */
        if (wbgt <= (int8_t)(threshold_of(current) - 1 - WBGT_HYST)) {
            return (RiskLevel)(current - 1);
        }
        return current;
    }
    return current;
}
src/config.h

#ifndef CONFIG_H
#define CONFIG_H

/* ============================================================
 * 自動温湿度調整システム 設定値(要件定義書の規定値はここに集約)
 * ============================================================ */

/* ---- ピン割り当て ---- */
#define PIN_DHT    2    /* DHT11 DATA(#4と同じ) */
#define PIN_IR_RX  8    /* IR受信モジュールOUT(PB0。アイドルHigh) */
/* D11(PB3/OC2A)=赤外線LED。Timer2のOC2A出力固定のため変更不可(#5と同じ) */
/* D13(PB5)=警告LED(内蔵LED) */

/* ---- 周期(すべて1秒tickの数で数える) ---- */
#define MEAS_PERIOD_S    10u     /* 温湿度の測定・判定周期:10秒 */
#define TREND_PERIOD_S   300u    /* 冷房中のトレンド判定周期:5分 */
#define RESUME_PERIOD_S  1800u   /* 人間優先からの復帰時間:30分 */

/* 試験・デモ用の時間短縮倍率。1で実時間、60にすると5分→5秒になる */
#define TIME_SCALE       1u

/* ---- しきい値(WBGT℃。日本生気象学会「日常生活における熱中症予防指針」Ver.4の4段階) ---- */
#define WBGT_CAUTION     25   /* 警戒(25以上28未満) */
#define WBGT_SEVERE      28   /* 厳重警戒(28以上31未満)=エアコンON判断 */
#define WBGT_DANGER      31   /* 危険(31以上) */
#define WBGT_HYST        1    /* レベルを下げるときのヒステリシス幅(℃) */

/* ---- 制御 ---- */
#define TARGET_TEMP_C    25   /* 冷房で近づけたい室温(℃) */
#define SENSOR_FAIL_MAX  3    /* 連続失敗この回数でセンサ異常と判断 */

#endif

テストを4本書く

テストのファイルには、テストの前に呼ばれるsetUp()と、後に呼ばれるtearDown()を必ず書きます。使わないときも、書いておかないとエラーになるので、中身は空で置いておきます。

テスト関数の中では、期待値と実際の値をアサーションで比べます。main()では、UNITY_BEGIN()とUNITY_END()の間に、RUN_TEST()でテスト関数を並べます。1本目は表を引く処理、残りの3本はレベルの判定を、普通の入力で確かめます。

test/test_wbgt.c

/* 1回目:普通の入力だけを確かめるテスト */
#include "unity.h"
#include "wbgt.h"

void setUp(void) {
}

void tearDown(void) {
}

/* 気温30℃・湿度60%は表の10行目・9列目(26℃) */
void test_estimate_reads_table(void) {
    TEST_ASSERT_EQUAL_INT8(26, wbgt_estimate(30, 60));
}

/* 危険側への変化はすぐに上げる */
void test_judge_raises_immediately(void) {
    TEST_ASSERT_EQUAL(RISK_SEVERE, wbgt_judge(28, RISK_NORMAL));
}

/* 安全側への変化は、今のレベルのしきい値(28)より1℃下がっただけでは保留する */
void test_judge_holds_near_threshold(void) {
    TEST_ASSERT_EQUAL(RISK_SEVERE, wbgt_judge(27, RISK_SEVERE));
}

/* しきい値(28)より2℃下がったら1段だけ下げる */
void test_judge_lowers_one_step(void) {
    TEST_ASSERT_EQUAL(RISK_CAUTION, wbgt_judge(26, RISK_SEVERE));
}

int main(void) {
    UNITY_BEGIN();
    RUN_TEST(test_estimate_reads_table);
    RUN_TEST(test_judge_raises_immediately);
    RUN_TEST(test_judge_holds_near_threshold);
    RUN_TEST(test_judge_lowers_one_step);
    return UNITY_END();
}

ビルドして実行する

テストのファイルを、テストする側のwbgt.cとunity.cと一緒にコンパイルするだけです。

UCRT64のターミナル(~/c-unit-test-demo で実行)

$ gcc -std=c99 -Wall -Wextra -Iunity -Isrc -o test_wbgt test/test_wbgt.c src/wbgt.c unity/unity.c
$ ./test_wbgt
test/test_wbgt.c:33:test_estimate_reads_table:PASS
test/test_wbgt.c:34:test_judge_raises_immediately:PASS
test/test_wbgt.c:35:test_judge_holds_near_threshold:PASS
test/test_wbgt.c:36:test_judge_lowers_one_step:PASS
-----------------------
4 Tests 0 Failures 0 Ignored 
OK

PASSが成功、FAILが失敗です。最後の行に、いくつテストし、いくつ失敗したかが出ます。

gcovで「テストが通っていない分岐」を見る

4本のテストで通った分岐は、26個中16個(61.54%)でした。

テストがすべてPASSしても、プログラムのどれだけの部分を動かしたかまでは分かりません。それを測るのがgcovです。

–coverageを付けてビルドし、gcov -bを実行する

カバレッジを測るには、コンパイルのオプションに--coverageを足すだけです。付けるのは測りたいwbgt.cだけで、Unityとテストのファイルには付けません。ただし、リンクするときにも--coverageを付けます(リンクでは-lgcovと同じ意味になり、付けないとリンクが失敗します)。

あとは、できた実行ファイルを普通に起動します。テストが終わったら、最後にgcov -bを実行すると、カバレッジのレポート(wbgt.c.gcov)ができます。-bを付けると、分岐ごとにどれだけの割合で通ったかも出力されます(GCCのマニュアル)。

UCRT64のターミナル(~/c-unit-test-demo で実行)

$ gcc -std=c99 -Wall -Wextra -Iunity -Isrc -c unity/unity.c
$ gcc -std=c99 -Wall -Wextra -Iunity -Isrc -c test/test_wbgt.c
$ gcc -std=c99 -Wall -Wextra -Iunity -Isrc --coverage -c src/wbgt.c
$ gcc --coverage -o test_wbgt test_wbgt.o wbgt.o unity.o
$ ./test_wbgt
test/test_wbgt.c:33:test_estimate_reads_table:PASS
test/test_wbgt.c:34:test_judge_raises_immediately:PASS
test/test_wbgt.c:35:test_judge_holds_near_threshold:PASS
test/test_wbgt.c:36:test_judge_lowers_one_step:PASS
-----------------------
4 Tests 0 Failures 0 Ignored 
OK
$ gcov -b wbgt.c
File 'src/wbgt.c'
Lines executed:72.97% of 37
Branches executed:100.00% of 26
Taken at least once:61.54% of 26
Calls executed:100.00% of 2
Creating 'wbgt.c.gcov'
Lines executed:72.97% of 37

Lines executedは実行した行の割合で、命令文がどの程度動作したかを表すC0カバレッジに当たります。Taken at least onceが、一度でも通った分岐の割合です。

日本語を含むフォルダでは0%になることがある
この記事の環境では、日本語を含むフォルダでテストを実行すると、テストは4本ともPASSするのに、gcovが0%と表示されました。実行したときにCannot create directoryと出て、カバレッジのデータ(wbgt.gcda)が書き出されていません。

gcovを実行すると、次のメッセージが出ます。

wbgt.gcda:cannot open data file, assuming not executed

テストの実行ファイルは、このデータを、オブジェクトファイルに埋め込まれた絶対パスへ書き出します。英数字だけのフォルダで実行するか、環境変数GCOV_PREFIXとGCOV_PREFIX_STRIPで書き出し先を変えると、正しく測れました(GCCのマニュアル)。

GCOV_PREFIX=. GCOV_PREFIX_STRIP=64 ./test_wbgt

GCOV_PREFIX_STRIPは、埋め込まれた絶対パスの先頭から取り除くフォルダの数です。64のように大きな値にするとフォルダが全部外れて、GCOV_PREFIXで指定した今のフォルダ(.)にwbgt.gcdaができます。

注釈つきソースの読み方

wbgt.c.gcovを開くと、ソースの各行の左に、その行を実行した回数が付いています。分岐のある行の下には、分岐ごとにbranch 0、branch 1の行が出ます。

  • 数字:その行を実行した回数
  • -:実行する文が無い行
  • #####:一度も実行されていない行
  • taken 0%:その分岐を一度も通っていない
wbgt.c.gcov(1回目・39〜57行目)

  1. 139 if (temp_c < WBGT_T_MIN) {
  2. branch 0 taken 0% (fallthrough)
  3. branch 1 taken 100%
  4. #####40 temp_c = WBGT_T_MIN;
  5. -41 }
  6. 142 if (temp_c > WBGT_T_MAX) {
  7. branch 0 taken 0% (fallthrough)
  8. branch 1 taken 100%
  9. #####43 temp_c = WBGT_T_MAX;
  10. -44 }
  11. 145 if (humidity < 20) {
  12. branch 0 taken 0% (fallthrough)
  13. branch 1 taken 100%
  14. #####46 humidity = 20;
  15. -47 }
  16. 148 if (humidity > 100) {
  17. branch 0 taken 0% (fallthrough)
  18. branch 1 taken 100%
  19. #####49 humidity = 100;
  20. -50 }
  21. -51
  22. 152 uint8_t row = (uint8_t)(temp_c - WBGT_T_MIN);
  23. 153 uint8_t col = (uint8_t)((humidity - 20 + 2) / 5); /* 最も近い5%刻みの列へ */
  24. 154 if (col >= WBGT_RH_COLS) {
  25. branch 0 taken 0% (fallthrough)
  26. branch 1 taken 100%
  27. #####55 col = WBGT_RH_COLS - 1;
  28. -56 }
  29. 157 return WBGT_TABLE[row][col];

琥珀=一度も実行されていない行と、一度も通っていない分岐

どちら側の分岐が残ったかは、すぐ下の行が#####かどうかで読み取れます。39行目はbranch 0が0%で、すぐ下の40行目が#####なので、「気温が21℃より低い」ときのテストが無いと読めます。このように#####の行を探して、そこを通るテストを足していきます。

確認問題

次の出力で、テストが通っていない分岐はどれでしょうか(1回目の4本のテスト・64〜70行目)。

wbgt.c.gcov(1回目・64〜70行目)

  1. 364 } else if (wbgt >= WBGT_SEVERE) {
  2. branch 0 taken 33% (fallthrough)
  3. branch 1 taken 67%
  4. 165 return RISK_SEVERE;
  5. 266 } else if (wbgt >= WBGT_CAUTION) {
  6. branch 0 taken 100% (fallthrough)
  7. branch 1 taken 0%
  8. 267 return RISK_CAUTION;
  9. -68 } else {
  10. #####69 return RISK_NORMAL;
  11. -70 }
答えを見る
66行目のbranch 1です。すぐ下の行ではなく、else側の69行目が#####なので、「暑さ指数が25より低い」ときに注意未満を返すテストが無いと読めます。

境界値と、残った分岐のテストを8本足す

境界値と残った分岐のテストを8本足すと、通った分岐は26個中25個(96.15%)まで増えました。

1回目に残った分岐は10個で、表を引くwbgt_estimate()に5個、レベルを決める側(wbgt_judge()と、そこから呼ぶ関数)に5個でした。wbgt_estimate()の5か所は、どれも「21℃より低い」「100%より大きい」のような境界のある条件です。境界の付近は、仕様の読み違いや書き間違いが起きやすい場所です。

範囲にぎりぎり入る値と、ぎりぎり入らない値を選んでテストします。これを境界値分析と呼び、同じ振る舞いになる入力の範囲から代表の値を選ぶ同値分割と組み合わせて使います。

気温の下限と上限、湿度の下限と上限を1本ずつ、判定の側で残った分岐を通すテストを4本、合わせて8本足しました。1回目のtest_wbgt.cをコピーしてtest_wbgt_boundary.cを作り、テスト関数と、main()のRUN_TEST()を8本ずつ足します。

test/test_wbgt_boundary.c(1回目の4本に8本を足した全体)

/* 2回目:1回目の4本に、境界値と残った分岐のテストを8本足した */
#include "unity.h"
#include "wbgt.h"

void setUp(void) {
}

void tearDown(void) {
}

/* 気温30℃・湿度60%は表の10行目・9列目(26℃) */
void test_estimate_reads_table(void) {
    TEST_ASSERT_EQUAL_INT8(26, wbgt_estimate(30, 60));
}

/* 危険側への変化はすぐに上げる */
void test_judge_raises_immediately(void) {
    TEST_ASSERT_EQUAL(RISK_SEVERE, wbgt_judge(28, RISK_NORMAL));
}

/* 安全側への変化は、今のレベルのしきい値(28)より1℃下がっただけでは保留する */
void test_judge_holds_near_threshold(void) {
    TEST_ASSERT_EQUAL(RISK_SEVERE, wbgt_judge(27, RISK_SEVERE));
}

/* しきい値(28)より2℃下がったら1段だけ下げる */
void test_judge_lowers_one_step(void) {
    TEST_ASSERT_EQUAL(RISK_CAUTION, wbgt_judge(26, RISK_SEVERE));
}

/* ---- 2回目で足した:表の外側は端の値へ丸める(湿度60%は9列目) ---- */
/* 気温の下限:21℃は表の1行目、20℃も1行目の値になる */
void test_estimate_clamps_low_temp(void) {
    TEST_ASSERT_EQUAL_INT8(18, wbgt_estimate(21, 60));
    TEST_ASSERT_EQUAL_INT8(18, wbgt_estimate(20, 60));
}

/* 気温の上限:40℃は表の20行目、41℃も20行目の値になる */
void test_estimate_clamps_high_temp(void) {
    TEST_ASSERT_EQUAL_INT8(35, wbgt_estimate(40, 60));
    TEST_ASSERT_EQUAL_INT8(35, wbgt_estimate(41, 60));
}

/* 湿度の下限:気温30℃で、20%は1列目、19%も1列目の値になる */
void test_estimate_clamps_low_humidity(void) {
    TEST_ASSERT_EQUAL_INT8(21, wbgt_estimate(30, 20));
    TEST_ASSERT_EQUAL_INT8(21, wbgt_estimate(30, 19));
}

/* 湿度の上限:気温30℃で、100%は17列目、101%も17列目の値になる */
void test_estimate_clamps_high_humidity(void) {
    TEST_ASSERT_EQUAL_INT8(30, wbgt_estimate(30, 100));
    TEST_ASSERT_EQUAL_INT8(30, wbgt_estimate(30, 101));
}

/* ---- 2回目で足した:判定の残りの分岐 ---- */
/* 31以上なら危険へ上げる */
void test_judge_raises_to_danger(void) {
    TEST_ASSERT_EQUAL(RISK_DANGER, wbgt_judge(31, RISK_SEVERE));
}

/* 25未満のままなら注意未満のまま */
void test_judge_keeps_normal(void) {
    TEST_ASSERT_EQUAL(RISK_NORMAL, wbgt_judge(20, RISK_NORMAL));
}

/* 危険のしきい値(31)より2℃下がったら厳重警戒へ */
void test_judge_lowers_from_danger(void) {
    TEST_ASSERT_EQUAL(RISK_SEVERE, wbgt_judge(29, RISK_DANGER));
}

/* 警戒のしきい値(25)より2℃下がったら注意未満へ */
void test_judge_lowers_from_caution(void) {
    TEST_ASSERT_EQUAL(RISK_NORMAL, wbgt_judge(23, RISK_CAUTION));
}

int main(void) {
    UNITY_BEGIN();
    RUN_TEST(test_estimate_reads_table);
    RUN_TEST(test_judge_raises_immediately);
    RUN_TEST(test_judge_holds_near_threshold);
    RUN_TEST(test_judge_lowers_one_step);
    RUN_TEST(test_estimate_clamps_low_temp);
    RUN_TEST(test_estimate_clamps_high_temp);
    RUN_TEST(test_estimate_clamps_low_humidity);
    RUN_TEST(test_estimate_clamps_high_humidity);
    RUN_TEST(test_judge_raises_to_danger);
    RUN_TEST(test_judge_keeps_normal);
    RUN_TEST(test_judge_lowers_from_danger);
    RUN_TEST(test_judge_lowers_from_caution);
    return UNITY_END();
}

テストを実行するたびに、回数がwbgt.gcdaに足されていくので、測り直す前にwbgt.gcdaを消します。

UCRT64のターミナル(~/c-unit-test-demo で実行)

$ rm -f wbgt.gcda
$ gcc -std=c99 -Wall -Wextra -Iunity -Isrc -c test/test_wbgt_boundary.c
$ gcc --coverage -o test_wbgt_boundary test_wbgt_boundary.o wbgt.o unity.o
$ ./test_wbgt_boundary
test/test_wbgt_boundary.c:79:test_estimate_reads_table:PASS
test/test_wbgt_boundary.c:80:test_judge_raises_immediately:PASS
test/test_wbgt_boundary.c:81:test_judge_holds_near_threshold:PASS
test/test_wbgt_boundary.c:82:test_judge_lowers_one_step:PASS
test/test_wbgt_boundary.c:83:test_estimate_clamps_low_temp:PASS
test/test_wbgt_boundary.c:84:test_estimate_clamps_high_temp:PASS
test/test_wbgt_boundary.c:85:test_estimate_clamps_low_humidity:PASS
test/test_wbgt_boundary.c:86:test_estimate_clamps_high_humidity:PASS
test/test_wbgt_boundary.c:87:test_judge_raises_to_danger:PASS
test/test_wbgt_boundary.c:88:test_judge_keeps_normal:PASS
test/test_wbgt_boundary.c:89:test_judge_lowers_from_danger:PASS
test/test_wbgt_boundary.c:90:test_judge_lowers_from_caution:PASS
-----------------------
12 Tests 0 Failures 0 Ignored 
OK
$ gcov -b wbgt.c
File 'src/wbgt.c'
Lines executed:97.30% of 37
Branches executed:100.00% of 26
Taken at least once:96.15% of 26
Calls executed:100.00% of 2
Creating 'wbgt.c.gcov'
Lines executed:97.30% of 37

12本ともPASSしました。残ったのは、54行目のif (col >= WBGT_RH_COLS)の中だけです。

wbgt.c.gcov(2回目・52〜57行目)

  1. 952 uint8_t row = (uint8_t)(temp_c - WBGT_T_MIN);
  2. 953 uint8_t col = (uint8_t)((humidity - 20 + 2) / 5); /* 最も近い5%刻みの列へ */
  3. 954 if (col >= WBGT_RH_COLS) {
  4. branch 0 taken 0% (fallthrough)
  5. branch 1 taken 100%
  6. #####55 col = WBGT_RH_COLS - 1;
  7. -56 }
  8. 957 return WBGT_TABLE[row][col];

残った1か所。54行目の branch 0 と、55行目

どの入力でも通らない分岐がある

全65,536通りの入力を入れても、54行目の分岐は65,536回判定されて、一度も真になりませんでした。

全部の入力を入れてみる

気温はint8_t、湿度はuint8_tなので、入力の組み合わせは256×256=65,536通りしかありません。単体テストなら、このすべてを入れる全数テストもできます。

test/test_wbgt_all.c

/* 3回目:int8_t の気温 × uint8_t の湿度をすべて入れて、残った分岐が通るかを確かめる */
#include "unity.h"
#include "wbgt.h"

void setUp(void) {
}

void tearDown(void) {
}

/* 65,536通りすべてで、返る値が表の最小値14〜最大値40に収まる */
void test_estimate_all_inputs(void) {
    int t;
    int h;
    for (t = -128; t <= 127; t++) {
        for (h = 0; h <= 255; h++) {
            int8_t w = wbgt_estimate((int8_t)t, (uint8_t)h);
            TEST_ASSERT_INT8_WITHIN(13, 27, w);
        }
    }
}

int main(void) {
    UNITY_BEGIN();
    RUN_TEST(test_estimate_all_inputs);
    return UNITY_END();
}
UCRT64のターミナル(~/c-unit-test-demo で実行)

$ rm -f wbgt.gcda
$ gcc -std=c99 -Wall -Wextra -Iunity -Isrc -c test/test_wbgt_all.c
$ gcc --coverage -o test_wbgt_all test_wbgt_all.o wbgt.o unity.o
$ ./test_wbgt_all
test/test_wbgt_all.c:25:test_estimate_all_inputs:PASS
-----------------------
1 Tests 0 Failures 0 Ignored 
OK
$ gcov -b wbgt.c
File 'src/wbgt.c'
Lines executed:35.14% of 37
Branches executed:38.46% of 26
Taken at least once:34.62% of 26
Calls executed:0.00% of 2
Creating 'wbgt.c.gcov'
Lines executed:35.14% of 37

テストはPASSし、戻り値もすべて表の範囲(14〜40)に収まりました。Taken at least onceが34.62%と低いのは、このテストがwbgt_estimate()しか呼ばず、判定の側の分岐を通らないためです。見るのは、表を引く側の分岐です。

wbgt.c.gcov(全65,536通り・48〜57行目)

  1. 6553648 if (humidity > 100) {
  2. branch 0 taken 61% (fallthrough)
  3. branch 1 taken 39%
  4. 3968049 humidity = 100;
  5. -50 }
  6. -51
  7. 6553652 uint8_t row = (uint8_t)(temp_c - WBGT_T_MIN);
  8. 6553653 uint8_t col = (uint8_t)((humidity - 20 + 2) / 5); /* 最も近い5%刻みの列へ */
  9. 6553654 if (col >= WBGT_RH_COLS) {
  10. branch 0 taken 0% (fallthrough)
  11. branch 1 taken 100%
  12. #####55 col = WBGT_RH_COLS - 1;
  13. -56 }
  14. 6553657 return WBGT_TABLE[row][col];

49行目で湿度を100に丸めてから、53行目で列を求める。54行目の branch 0 は一度も通っていない

なぜ通らないのか

直前で湿度を20〜100%に丸めているので、列の番号(humidity - 20 + 2) / 5は、最大でも(100 − 20 + 2) ÷ 5 = 16です。Cの整数どうしの割り算なので、16.4の小数点以下は切り捨てられます。表の列は0〜16の17個なので、col >= 17は成り立ちません。前の処理で条件が常に偽に決まってしまったので、中の55行目は、どの入力でも実行されない到達不能コードです。

テストの結果だけを見ていても、この分岐には気づけません。

現場では、通らない分岐は出ないようにする

私の現場では、どの入力でも通らない分岐は、基本的には出ないようにしています。ただし、車種共通で使うコードの中に、その車種では使わないI/Fがあるときは、通らない理由をつけて記録します。

この例の分岐は、車種共通のI/Fではなく、前の処理(湿度を100に丸める)と役目が重なっているだけなので、消してよい分岐です。

消した版で確かめる

消した版では分岐が24個中24個(100%)になり、戻り値は全65,536通りで消す前と同じでした。

消すときは、外から見た振る舞いを変えないことが条件です。変更の前と後で、同じテストがすべて通ることを確かめます。3行を消した版をwbgt_fixed.cとして作りました。

src/wbgt.c → src/wbgt_fixed.c(消した3行)

     uint8_t col = (uint8_t)((humidity - 20 + 2) / 5);   /* 最も近い5%刻みの列へ */
-    if (col >= WBGT_RH_COLS) {
-        col = WBGT_RH_COLS - 1;
-    }
     return WBGT_TABLE[row][col];
UCRT64のターミナル(~/c-unit-test-demo で実行)

$ gcc -std=c99 -Wall -Wextra -Iunity -Isrc --coverage -c src/wbgt_fixed.c
$ gcc --coverage -o test_wbgt_fixed test_wbgt_boundary.o wbgt_fixed.o unity.o
$ ./test_wbgt_fixed
test/test_wbgt_boundary.c:79:test_estimate_reads_table:PASS
test/test_wbgt_boundary.c:80:test_judge_raises_immediately:PASS
test/test_wbgt_boundary.c:81:test_judge_holds_near_threshold:PASS
test/test_wbgt_boundary.c:82:test_judge_lowers_one_step:PASS
test/test_wbgt_boundary.c:83:test_estimate_clamps_low_temp:PASS
test/test_wbgt_boundary.c:84:test_estimate_clamps_high_temp:PASS
test/test_wbgt_boundary.c:85:test_estimate_clamps_low_humidity:PASS
test/test_wbgt_boundary.c:86:test_estimate_clamps_high_humidity:PASS
test/test_wbgt_boundary.c:87:test_judge_raises_to_danger:PASS
test/test_wbgt_boundary.c:88:test_judge_keeps_normal:PASS
test/test_wbgt_boundary.c:89:test_judge_lowers_from_danger:PASS
test/test_wbgt_boundary.c:90:test_judge_lowers_from_caution:PASS
-----------------------
12 Tests 0 Failures 0 Ignored 
OK
$ gcov -b wbgt_fixed.c
File 'src/wbgt_fixed.c'
Lines executed:100.00% of 35
Branches executed:100.00% of 24
Taken at least once:100.00% of 24
Calls executed:100.00% of 2
Creating 'wbgt_fixed.c.gcov'
Lines executed:100.00% of 35

境界値まで足した12本は、消した版でもすべてPASSしました。

さらに、消す前と後の関数に同じ65,536通りを入れて、戻り値を1つずつ比べます。消す前のwbgt.cは、関数名を変えてもう1本リンクし、2つの戻り値を比べています。

test/test_wbgt_diff.c

/* 通らない分岐を消した版(wbgt_fixed.c)と原本(wbgt.c)が、
 * int8_t の気温 × uint8_t の湿度の全65,536通りで同じ値を返すかを確かめる */
#include "unity.h"
#include "wbgt.h"

int8_t wbgt_estimate_orig(int8_t temp_c, uint8_t humidity);

void setUp(void) {
}

void tearDown(void) {
}

void test_fixed_equals_original_for_all_inputs(void) {
    int t;
    int h;
    for (t = -128; t <= 127; t++) {
        for (h = 0; h <= 255; h++) {
            TEST_ASSERT_EQUAL_INT8(wbgt_estimate_orig((int8_t)t, (uint8_t)h),
                                   wbgt_estimate((int8_t)t, (uint8_t)h));
        }
    }
}

int main(void) {
    UNITY_BEGIN();
    RUN_TEST(test_fixed_equals_original_for_all_inputs);
    return UNITY_END();
}
test/wbgt_orig.c

/* 差分テスト用:原本の wbgt.c を、関数名を変えてもう1本リンクする */
#define wbgt_estimate wbgt_estimate_orig
#define wbgt_judge    wbgt_judge_orig
#include "../src/wbgt.c"
UCRT64のターミナル(~/c-unit-test-demo で実行)

$ gcc -std=c99 -Wall -Wextra -Iunity -Isrc -c test/test_wbgt_diff.c
$ gcc -std=c99 -Wall -Wextra -Iunity -Isrc -c test/wbgt_orig.c
$ gcc -std=c99 -Wall -Wextra -Iunity -Isrc -c src/wbgt_fixed.c -o wbgt_fixed_nocov.o
$ gcc -o test_wbgt_diff test_wbgt_diff.o wbgt_fixed_nocov.o wbgt_orig.o unity.o
$ ./test_wbgt_diff
test/test_wbgt_diff.c:27:test_fixed_equals_original_for_all_inputs:PASS
-----------------------
1 Tests 0 Failures 0 Ignored 
OK

PASSは、65,536通りのどの入力でも、消す前と後の戻り値が同じだったという意味です。

条件が2つの判定は、真と偽の2本ではMC/DCを満たさない

条件が2つの判定では、真と偽の2本だけだと分岐もMC/DCも4個中3個で止まり、3本目を足して100%になりました。

MC/DCとは

ifのような条件文の分岐がどこまで動いたかを網羅率で表したものを、C1(分岐網羅)と呼びます(ガイオ・テクノロジーの解説)。MC/DCは、条件文に書かれた複合条件について、どこまで動いたかを表します。判定式の各条件が、判定の結果に単独で影響することを確かめる基準で、自動車に載せるソフトウェアのように高い安全性が求められる場合に使われます。

私の現場でも、単体テストはMC/DCで測ります。ただし、QM(特別な安全要求の無い部分)はMC/DCまでは求めない、というルールがあります。

gccでMC/DCを測るには、GCC 14以降で-fcondition-coverageを付けてコンパイルし、gcov -gで表示します。

条件が1つなら、MC/DCは分岐と同じ

&&や||を含まない条件なら、その条件が真になるテストと偽になるテストがあれば、MC/DCを満たします。wbgt.cの判定はどれも条件が1つです。-fcondition-coverageを付けてコンパイルし直し、境界値まで足した12本で測ると、MC/DCは分岐と同じ26個中25個(96.15%)でした。

UCRT64のターミナル(~/c-unit-test-demo で実行)

$ rm -f wbgt.gcda
$ gcc -std=c99 -Wall -Wextra -Iunity -Isrc --coverage -fcondition-coverage -c src/wbgt.c
$ gcc --coverage -o test_wbgt_boundary test_wbgt_boundary.o wbgt.o unity.o
$ ./test_wbgt_boundary
test/test_wbgt_boundary.c:79:test_estimate_reads_table:PASS
test/test_wbgt_boundary.c:80:test_judge_raises_immediately:PASS
test/test_wbgt_boundary.c:81:test_judge_holds_near_threshold:PASS
test/test_wbgt_boundary.c:82:test_judge_lowers_one_step:PASS
test/test_wbgt_boundary.c:83:test_estimate_clamps_low_temp:PASS
test/test_wbgt_boundary.c:84:test_estimate_clamps_high_temp:PASS
test/test_wbgt_boundary.c:85:test_estimate_clamps_low_humidity:PASS
test/test_wbgt_boundary.c:86:test_estimate_clamps_high_humidity:PASS
test/test_wbgt_boundary.c:87:test_judge_raises_to_danger:PASS
test/test_wbgt_boundary.c:88:test_judge_keeps_normal:PASS
test/test_wbgt_boundary.c:89:test_judge_lowers_from_danger:PASS
test/test_wbgt_boundary.c:90:test_judge_lowers_from_caution:PASS
-----------------------
12 Tests 0 Failures 0 Ignored 
OK
$ gcov -b -g wbgt.c
File 'src/wbgt.c'
Lines executed:97.30% of 37
Branches executed:100.00% of 26
Taken at least once:96.15% of 26
Calls executed:100.00% of 2
Condition outcomes covered:96.15% of 26
Creating 'wbgt.c.gcov'
Lines executed:97.30% of 37

冷房の判定式を関数に抜き出す

総合演習のmain.cppには、冷房中に設定温度を下げるかを決める判定があります。室温が目標の25℃より高く、しかも前回から下がっていなければ、設定温度を1℃下げる、という判定です。

main.cpp(総合演習の原本・145〜158行目)

/* ---- トレンド判定タスク(冷房中にTREND_PERIOD_Sごと) ----
 * 上下ボタンしかないエアコンを目標室温へ近づける:
 * 前回の室温と比べて下がっていなければ、設定温度を1℃下げる */
static void trend_task(void) {
    if (g_sys.mode != MODE_COOLING) {
        return;
    }
    if (g_sys.temp_c > TARGET_TEMP_C && g_sys.temp_c >= g_sys.trend_prev_temp) {
        ir_send_raw(AC_FRAME_TEMP_DOWN, AC_LEN(AC_FRAME_TEMP_DOWN));
        eeprom_log_write(EV_AC_TEMP_DOWN, g_sys.wbgt, g_sys.temp_c, g_sys.humidity);
        Serial.println(F("AC TEMP DOWN"));
    }
    g_sys.trend_prev_temp = g_sys.temp_c;
}

この関数は、赤外線の送信やEEPROMへの記録、シリアルへの出力も呼んでいるので、このままではPCでテストできません。main.cppは<Arduino.h>や<avr/interrupt.h>を読み込んでいるので、PCのgccではコンパイルもできません。そこで、判定式だけをtrend.cという別ファイルの関数に抜き出します。

src/trend.h

#ifndef TREND_H
#define TREND_H

#include <stdint.h>

/* 冷房中のトレンド判定(原本 main.cpp の trend_task から判定式だけを抜き出した)。
 * 室温が目標より高く、かつ前回から下がっていなければ 1(設定温度を1℃下げる) */
int trend_needs_temp_down(int8_t temp_c, int8_t prev_temp);

#endif
src/trend.c

#include "trend.h"
#include "config.h"

int trend_needs_temp_down(int8_t temp_c, int8_t prev_temp) {
    if (temp_c > TARGET_TEMP_C && temp_c >= prev_temp) {
        return 1;
    }
    return 0;
}

抜き出したコードはハードウェアに依存しないので、PCで簡単にビルドしてテストできます。判定式を写して関数にしただけで、main.cppはまだ書き換えていません。実際に抜き出すなら、trend_task()のifの条件を、この関数の呼び出しに置き換えます。

真と偽の2本では足りない

まず、判定が真になる入力(28, 28)と、偽になる入力(25, 25)の2本でテストします。

test/test_trend_c1.c

/* 判定(if)が真になる入力と偽になる入力の2本だけ */
#include "unity.h"
#include "trend.h"

void setUp(void) {
}

void tearDown(void) {
}

/* 目標(25℃)より高く、前回から下がっていない → 下げる */
void test_hot_and_not_falling(void) {
    TEST_ASSERT_EQUAL_INT(1, trend_needs_temp_down(28, 28));
}

/* 目標以下 → 下げない */
void test_already_cool(void) {
    TEST_ASSERT_EQUAL_INT(0, trend_needs_temp_down(25, 25));
}

int main(void) {
    UNITY_BEGIN();
    RUN_TEST(test_hot_and_not_falling);
    RUN_TEST(test_already_cool);
    return UNITY_END();
}
UCRT64のターミナル(~/c-unit-test-demo で実行)

$ gcc -std=c99 -Wall -Wextra -Iunity -Isrc -c test/test_trend_c1.c
$ gcc -std=c99 -Wall -Wextra -Iunity -Isrc --coverage -fcondition-coverage -c src/trend.c
$ gcc --coverage -o test_trend_c1 test_trend_c1.o trend.o unity.o
$ ./test_trend_c1
test/test_trend_c1.c:23:test_hot_and_not_falling:PASS
test/test_trend_c1.c:24:test_already_cool:PASS
-----------------------
2 Tests 0 Failures 0 Ignored 
OK
$ gcov -b -g trend.c
File 'src/trend.c'
Lines executed:100.00% of 4
Branches executed:100.00% of 4
Taken at least once:75.00% of 4
No calls
Condition outcomes covered:75.00% of 4
Creating 'trend.c.gcov'
Lines executed:100.00% of 4
trend.c.gcov(2本のテスト・4〜9行目)

  1. 24int trend_needs_temp_down(int8_t temp_c, int8_t prev_temp) {
  2. 25 if (temp_c > TARGET_TEMP_C && temp_c >= prev_temp) {
  3. branch 0 taken 50% (fallthrough)
  4. branch 1 taken 50%
  5. branch 2 taken 100% (fallthrough)
  6. branch 3 taken 0%
  7. condition outcomes covered 3/4
  8. condition 1 not covered (false)
  9. 16 return 1;
  10. -7 }
  11. 18 return 0;
  12. -9}

5行目の branch 3 と、condition 1 の偽の側が残った

&&の左側が偽なら、右側は評価されずに結果が決まります(短絡評価)。gcovは、コンパイラが作る基本ブロック(入口と出口が1つずつで、内部に分岐を含まないコードのまとまり)の間の飛び先ごとに分岐を数えます。&&の2つの条件が、それぞれ真と偽の飛び先を持つので、1行に4つの分岐が出ます。

判定は真と偽の両方を通り、6行目も8行目も実行されているのに、分岐は4個中3個、MC/DCも4個中3個でした。2本のテストでは、2つ目の条件が偽になる側、つまり「前回より下がっている」場合を一度も通っていません。ifの判定の真と偽だけを数える教科書どおりのC1なら、この2本で100%です。

condition 1 not covered (false)は、左から2つ目の条件(temp_c >= prev_temp)についての表示です(GCCのマニュアル)。この条件が偽になって判定を変えるテストが無い、という意味です。-bの出力からは5行目のbranch 3が0%だと分かるだけですが、-gは、どの条件のどちら側が足りないかまで示します。

3本目を足す

1つ目の条件が真のときは、2つ目の条件の真偽で結果が変わりますが、1つ目が偽なら、2つ目がどちらでも結果は偽です。つまり、1つ目の条件が偽のテストは1本でよく、MC/DCを満たすテストは3通りです。1つ目の条件は(28, 28)と(25, 25)の組、2つ目の条件は(28, 28)と(28, 29)の組で、それぞれその条件だけが変わり、判定が真と偽に分かれます。

(25, 25)では2つ目の条件は評価されませんが、25 >= 25の値は(28, 28)と同じ真なので、変わったのは1つ目の条件だけです。

入力(室温, 前回) 室温が目標より高い 前回から下がっていない 判定
(28, 28) 真 真 真(下げる)
(25, 25) 偽 真 偽(1つ目の条件だけが変わった)
(28, 29) 真 偽 偽(2つ目の条件だけが変わった)
test/test_trend_mcdc.c

/* MC/DC:2つの条件それぞれが、単独で結果を変えることを確かめる */
#include "unity.h"
#include "trend.h"

void setUp(void) {
}

void tearDown(void) {
}

/* 目標(25℃)より高く、前回から下がっていない → 下げる */
void test_hot_and_not_falling(void) {
    TEST_ASSERT_EQUAL_INT(1, trend_needs_temp_down(28, 28));
}

/* 目標以下 → 下げない(1つ目の条件だけが変わった。2つ目の 25 >= 25 は真のまま) */
void test_already_cool(void) {
    TEST_ASSERT_EQUAL_INT(0, trend_needs_temp_down(25, 25));
}

/* 目標より高いが、前回から下がっている → 下げない(2つ目の条件だけが変わった) */
void test_hot_but_falling(void) {
    TEST_ASSERT_EQUAL_INT(0, trend_needs_temp_down(28, 29));
}

int main(void) {
    UNITY_BEGIN();
    RUN_TEST(test_hot_and_not_falling);
    RUN_TEST(test_already_cool);
    RUN_TEST(test_hot_but_falling);
    return UNITY_END();
}
UCRT64のターミナル(~/c-unit-test-demo で実行)

$ rm -f trend.gcda
$ gcc -std=c99 -Wall -Wextra -Iunity -Isrc -c test/test_trend_mcdc.c
$ gcc --coverage -o test_trend_mcdc test_trend_mcdc.o trend.o unity.o
$ ./test_trend_mcdc
test/test_trend_mcdc.c:28:test_hot_and_not_falling:PASS
test/test_trend_mcdc.c:29:test_already_cool:PASS
test/test_trend_mcdc.c:30:test_hot_but_falling:PASS
-----------------------
3 Tests 0 Failures 0 Ignored 
OK
$ gcov -b -g trend.c
File 'src/trend.c'
Lines executed:100.00% of 4
Branches executed:100.00% of 4
Taken at least once:100.00% of 4
No calls
Condition outcomes covered:100.00% of 4
Creating 'trend.c.gcov'
Lines executed:100.00% of 4

目標より高いが前回から下がっている(28, 29)を足すと、分岐もMC/DCも4個中4個(100%)になりました。

レジスタを触るコードは切り離してテストする

EEPROMのレジスタを偽物に差し替えてテストすると、5本中1本がFAILし、コードの不具合が1つ見つかりました。

題材:EEPROMに記録を残す eeprom_log

eeprom_logは、起動回数とイベントの記録をEEPROMに残す処理です。記録は32件のリングバッファで、33件目からは一番古い記録を上書きします。書き込む位置は(現在位置 + 1) % 32で進むので、末尾から先頭へ戻ります。

今と同じ値のときは書き込まないようにして、EEPROMへの無駄な書き込みを減らしています。

EEPROMの読み書きは、EECR・EEAR・EEDRのレジスタを直接触る関数で行っています(レジスタの意味はArduino EEPROMのレジスタ操作の練習問題)。このレジスタはマイコンの上にしか無いので、このままではPCでビルドできません。

eeprom_log.cpp(総合演習の原本・8〜25行目)

static void eeprom_write_reg(uint16_t addr, uint8_t data) {
    while (EECR & _BV(EEPE)) {      /* 前の書き込みが終わるまで待つ */
    }
    cli();                          /* EEMPE→EEPEの手順を割り込みで邪魔されないように */
    EEAR = addr;
    EEDR = data;
    EECR |= _BV(EEMPE);             /* マスター書き込み許可(4サイクル以内にEEPE) */
    EECR |= _BV(EEPE);              /* 書き込み開始 */
    sei();
}

static uint8_t eeprom_read_reg(uint16_t addr) {
    while (EECR & _BV(EEPE)) {      /* 書き込み中は読まない */
    }
    EEAR = addr;
    EECR |= _BV(EERE);              /* 読み出し */
    return EEDR;
}

レジスタを触る関数を別ファイルに出す

レジスタを触る2つの関数を、eeprom_hal_avr.cという別ファイルへ移し、名前をeeprom_hal_read()・eeprom_hal_write()にします。関数の中身は原本の8〜25行目のままで、名前を変えてstaticを外しただけです。

テストのときは、同じ名前の関数を持つ偽物のファイルを代わりにリンクします。記録の処理のコードを書き換えずに、ハードウェアに依存するコードを置き換えられます。コンパイルスイッチで切り替える方法もありますが、コードが複雑になりやすく、リンクで差し替えるほうがプロダクトのコードを汚さずに済みます。

ファイル UNO向け PCのテスト
eeprom_log.c(記録の処理) リンクする リンクする
eeprom_hal_avr.c(レジスタを触る) リンクする リンクしない
fake_eeprom.c(偽のEEPROM) リンクしない リンクする
test_eeprom_log.c・unity.c(テスト) リンクしない リンクする
src/eeprom_hal.h

#ifndef EEPROM_HAL_H
#define EEPROM_HAL_H

#include <stdint.h>

/* EEPROMを1バイト読み書きする。実機用は src_avr/eeprom_hal_avr.c、
 * テスト用は test/fake_eeprom.c。どちらをリンクするかで差し替える */
uint8_t eeprom_hal_read(uint16_t addr);
void eeprom_hal_write(uint16_t addr, uint8_t data);

#endif

切り出した後のファイルは、UNO向けのコンパイラ(avr-gcc 7.3.0)でもコンパイルが通ることを確かめています。

src/eeprom_log.h

#ifndef EEPROM_LOG_H
#define EEPROM_LOG_H

#include <stdint.h>

/* イベント種別(EEPROMに1バイトで残す) */
typedef enum {
    EV_BOOT        = 0x01,   /* 起動 */
    EV_LEVEL_UP    = 0x02,   /* 警告レベルが上がった */
    EV_LEVEL_DOWN  = 0x03,   /* 警告レベルが下がった */
    EV_AC_ON       = 0x04,   /* 冷房ONを送信 */
    EV_AC_TEMP_DOWN= 0x05,   /* 設定温度ダウンを送信 */
    EV_AC_OFF      = 0x06,   /* 停止を送信 */
    EV_HUMAN       = 0x07,   /* 人間のリモコン操作を検知(人間優先へ) */
    EV_RESUME      = 0x08,   /* 人間優先から自動制御へ復帰 */
    EV_SENSOR_FAIL = 0x09    /* センサ読み取りの連続失敗 */
} LogEvent;

void eeprom_log_init(void);                                    /* 起動回数+1とログ位置の復元 */
void eeprom_log_write(LogEvent ev, int8_t wbgt, int8_t temp_c, uint8_t humidity);
void eeprom_log_dump(void);                                    /* シリアルへCSV形式で全件表示 */

#endif
src/eeprom_log.c(記録の処理)

#include "eeprom_log.h"
#include "eeprom_hal.h"

/* 原本 eeprom_log.cpp から、レジスタを触る2関数を eeprom_hal へ出した版。
 * 処理(リングバッファ・壊れた値の復旧・同じ値なら書かない)は原本のまま。
 * eeprom_log_dump() は Serial に依存するため、実機側のファイルに残す。 */

/* 書き込み回数を減らす:今と同じ値なら書かない(update相当) */
static void eeprom_update_reg(uint16_t addr, uint8_t data) {
    if (eeprom_hal_read(addr) != data) {
        eeprom_hal_write(addr, data);
    }
}

/* ---- イベントログ(リングバッファ) ----
 * アドレス0:起動回数/アドレス1:次に書くレコード番号
 * アドレス8以降:1レコード4バイト(イベント種別・WBGT・気温・湿度)×32件
 * 32件を超えたら一番古いレコードから上書きする */
#define LOG_BASE   8
#define LOG_SIZE   4
#define LOG_COUNT  32

static uint8_t g_next = 0;          /* 次に書くレコード番号(0〜31) */

void eeprom_log_init(void) {
    uint8_t boot = eeprom_hal_read(0);
    eeprom_update_reg(0, (uint8_t)(boot + 1));

    g_next = eeprom_hal_read(1);
    if (g_next >= LOG_COUNT) {      /* 初回(0xFF)や壊れた値は0へ */
        g_next = 0;
    }
    eeprom_log_write(EV_BOOT, 0, 0, 0);
}

void eeprom_log_write(LogEvent ev, int8_t wbgt, int8_t temp_c, uint8_t humidity) {
    uint16_t addr = (uint16_t)(LOG_BASE + g_next * LOG_SIZE);
    eeprom_update_reg(addr + 0, (uint8_t)ev);
    eeprom_update_reg(addr + 1, (uint8_t)wbgt);
    eeprom_update_reg(addr + 2, (uint8_t)temp_c);
    eeprom_update_reg(addr + 3, humidity);

    g_next = (uint8_t)((g_next + 1) % LOG_COUNT);
    eeprom_update_reg(1, g_next);   /* 次回起動時に続きから書けるよう保存 */
}

偽のEEPROMを作る

偽物は、ATmega328Pと同じ1KBのEEPROMを配列で持ち、読み書きをその配列に対して行います。スタブのように固定値を返すのではなく、配列という内部の状態を持って本物に近く動く、テスト用の簡単な実装をフェイクと呼びます。書き込んだ回数も数えておき、同じ値を書かないことをテストで確かめられるようにします。

test/fake_eeprom.h

#ifndef FAKE_EEPROM_H
#define FAKE_EEPROM_H

#include <stdint.h>

#define FAKE_EEPROM_SIZE 1024

extern uint8_t fake_eeprom[FAKE_EEPROM_SIZE];
extern int fake_eeprom_write_count;

void fake_eeprom_erase(void);

#endif
test/fake_eeprom.c

/* テスト用の偽EEPROM。ATmega328P と同じ1KBを配列で持つ */
#include <string.h>
#include "eeprom_hal.h"
#include "fake_eeprom.h"

uint8_t fake_eeprom[FAKE_EEPROM_SIZE];
int fake_eeprom_write_count;

/* 工場出荷時(消去済み)の状態=全バイト0xFF */
void fake_eeprom_erase(void) {
    memset(fake_eeprom, 0xFF, sizeof(fake_eeprom));
    fake_eeprom_write_count = 0;
}

uint8_t eeprom_hal_read(uint16_t addr) {
    return fake_eeprom[addr];
}

void eeprom_hal_write(uint16_t addr, uint8_t data) {
    fake_eeprom[addr] = data;
    fake_eeprom_write_count++;
}

setUp()で毎回、工場出荷時と同じ全バイト0xFFの状態に戻してから、各テストを始めます。テストの期待値は、コードのコメントに書かれた仕様から決めています。

test/test_eeprom_log.c

/* eeprom_log の処理を、偽EEPROM(fake_eeprom.c)をリンクしてPCで確かめる。
 * 期待値はコードのコメントに書かれた仕様から決めた */
#include "unity.h"
#include "eeprom_log.h"
#include "fake_eeprom.h"

#define ADDR_BOOT   0
#define ADDR_NEXT   1
#define LOG_BASE    8
#define LOG_SIZE    4
#define LOG_COUNT   32

void setUp(void) {
    fake_eeprom_erase();    /* 毎回、消去済みのEEPROMから始める */
}

void tearDown(void) {
}

/* 「起動回数+1」:消去済みのEEPROMで初めて起動したら、起動回数は1になるはず */
void test_first_boot_counts_one(void) {
    eeprom_log_init();
    TEST_ASSERT_EQUAL_UINT8(1, fake_eeprom[ADDR_BOOT]);
}

/* 起動すると、先頭のレコードに起動イベントを残し、次の位置1を保存する */
void test_first_boot_writes_boot_record(void) {
    eeprom_log_init();
    TEST_ASSERT_EQUAL_UINT8(EV_BOOT, fake_eeprom[LOG_BASE]);
    TEST_ASSERT_EQUAL_UINT8(1, fake_eeprom[ADDR_NEXT]);
}

/* 「32件を超えたら一番古いレコードから上書き」:33件目は先頭へ戻る */
void test_wraps_after_32_records(void) {
    int i;
    eeprom_log_init();                          /* 1件目=起動 */
    for (i = 0; i < LOG_COUNT - 1; i++) {       /* 2〜32件目 */
        eeprom_log_write(EV_AC_ON, 30, 30, 60);
    }
    eeprom_log_write(EV_AC_OFF, 25, 26, 50);    /* 33件目 */
    TEST_ASSERT_EQUAL_UINT8(EV_AC_OFF, fake_eeprom[LOG_BASE]);
    TEST_ASSERT_EQUAL_UINT8(1, fake_eeprom[ADDR_NEXT]);
}

/* 「初回(0xFF)や壊れた値は0へ」:位置が範囲外なら先頭から書く */
void test_broken_next_restarts_from_zero(void) {
    fake_eeprom[ADDR_NEXT] = 200;
    eeprom_log_init();
    TEST_ASSERT_EQUAL_UINT8(EV_BOOT, fake_eeprom[LOG_BASE]);
    TEST_ASSERT_EQUAL_UINT8(1, fake_eeprom[ADDR_NEXT]);
}

/* 「今と同じ値なら書かない」:同じ内容のレコードを上書きしても、書くのは位置の1バイトだけ */
void test_same_value_is_not_written(void) {
    int i;
    int before;
    eeprom_log_init();                          /* 先頭=起動(0,0,0) */
    for (i = 0; i < LOG_COUNT - 1; i++) {
        eeprom_log_write(EV_AC_ON, 30, 30, 60);
    }
    before = fake_eeprom_write_count;
    eeprom_log_write(EV_BOOT, 0, 0, 0);         /* 先頭と同じ内容で上書き */
    TEST_ASSERT_EQUAL_INT(1, fake_eeprom_write_count - before);
}

int main(void) {
    UNITY_BEGIN();
    RUN_TEST(test_first_boot_counts_one);
    RUN_TEST(test_first_boot_writes_boot_record);
    RUN_TEST(test_wraps_after_32_records);
    RUN_TEST(test_broken_next_restarts_from_zero);
    RUN_TEST(test_same_value_is_not_written);
    return UNITY_END();
}

テストが1本落ちた

UCRT64のターミナル(~/c-unit-test-demo で実行)

$ gcc -std=c99 -Wall -Wextra -Iunity -Isrc -Itest -c test/fake_eeprom.c
$ gcc -std=c99 -Wall -Wextra -Iunity -Isrc -Itest -c test/test_eeprom_log.c
$ gcc -std=c99 -Wall -Wextra -Iunity -Isrc --coverage -c src/eeprom_log.c
$ gcc --coverage -o test_eeprom_log test_eeprom_log.o eeprom_log.o fake_eeprom.o unity.o
$ ./test_eeprom_log
test/test_eeprom_log.c:23:test_first_boot_counts_one:FAIL: Expected 1 Was 0
test/test_eeprom_log.c:69:test_first_boot_writes_boot_record:PASS
test/test_eeprom_log.c:70:test_wraps_after_32_records:PASS
test/test_eeprom_log.c:71:test_broken_next_restarts_from_zero:PASS
test/test_eeprom_log.c:72:test_same_value_is_not_written:PASS
-----------------------
5 Tests 1 Failures 0 Ignored 
FAIL
$ gcov -b eeprom_log.c
File 'src/eeprom_log.c'
Lines executed:100.00% of 21
Branches executed:100.00% of 4
Taken at least once:75.00% of 4
Calls executed:100.00% of 11
Creating 'eeprom_log.c.gcov'
Lines executed:100.00% of 21

FAILした行には、ファイル名、行番号、テスト関数の名前、期待値と実際の値が出ます。起動回数は1を期待しましたが、実際は0でした。

消去済みのEEPROMは全バイトが0xFFなので、初めての起動で0xFF + 1を計算すると、uint8_tでは0に戻ります。コメントの「起動回数+1」と食い違う、教材のコードの小さな不具合です。

カバレッジが指したテストの抜け

2回目の起動のテストを足すと分岐は4個中4個になりましたが、FAILは2本に増えました。
eeprom_log.c.gcov(5本のテスト・25〜34行目)

  1. 525void eeprom_log_init(void) {
  2. 526 uint8_t boot = eeprom_hal_read(0);
  3. call 0 returned 100%
  4. 527 eeprom_update_reg(0, (uint8_t)(boot + 1));
  5. call 0 returned 100%
  6. -28
  7. 529 g_next = eeprom_hal_read(1);
  8. call 0 returned 100%
  9. 530 if (g_next >= LOG_COUNT) { /* 初回(0xFF)や壊れた値は0へ */
  10. branch 0 taken 100% (fallthrough)
  11. branch 1 taken 0%
  12. 531 g_next = 0;
  13. -32 }
  14. 533 eeprom_log_write(EV_BOOT, 0, 0, 0);
  15. call 0 returned 100%
  16. 534}

30行目の branch 1(次に書く位置が範囲内のとき)が残った

gcovの要約では、21行はすべて実行されていますが、分岐は4個中3個です。30行目のif (g_next >= LOG_COUNT)の偽の側、つまり次に書く位置が範囲内のときを一度も通っていません。テストはどれも消去済みのEEPROMから始めていて、2回目以降の起動を確かめるテストがありませんでした。電源を入れ直した想定で、eeprom_log_init()を2回呼ぶテストを、test_eeprom_log.cのmain()の直前に、前後を1行ずつあけて足します。

test/test_eeprom_log.c(足したテスト)

/* 2回目の起動:起動回数は2、起動イベントは前回の続き(2件目)に書く */
void test_second_boot_continues(void) {
    eeprom_log_init();                          /* 1回目 */
    eeprom_log_init();                          /* 2回目(電源を入れ直した想定) */
    TEST_ASSERT_EQUAL_UINT8(2, fake_eeprom[ADDR_BOOT]);
    TEST_ASSERT_EQUAL_UINT8(EV_BOOT, fake_eeprom[LOG_BASE + LOG_SIZE]);
    TEST_ASSERT_EQUAL_UINT8(2, fake_eeprom[ADDR_NEXT]);
}

main()では、RUN_TEST()の並びの最後にRUN_TEST(test_second_boot_continues);を1行足します。

UCRT64のターミナル(~/c-unit-test-demo で実行)

$ rm -f eeprom_log.gcda
$ gcc -std=c99 -Wall -Wextra -Iunity -Isrc -Itest -c test/test_eeprom_log.c
$ gcc --coverage -o test_eeprom_log test_eeprom_log.o eeprom_log.o fake_eeprom.o unity.o
$ ./test_eeprom_log
test/test_eeprom_log.c:23:test_first_boot_counts_one:FAIL: Expected 1 Was 0
test/test_eeprom_log.c:78:test_first_boot_writes_boot_record:PASS
test/test_eeprom_log.c:79:test_wraps_after_32_records:PASS
test/test_eeprom_log.c:80:test_broken_next_restarts_from_zero:PASS
test/test_eeprom_log.c:81:test_same_value_is_not_written:PASS
test/test_eeprom_log.c:70:test_second_boot_continues:FAIL: Expected 2 Was 1
-----------------------
6 Tests 2 Failures 0 Ignored 
FAIL
$ gcov -b eeprom_log.c
File 'src/eeprom_log.c'
Lines executed:100.00% of 21
Branches executed:100.00% of 4
Taken at least once:100.00% of 4
Calls executed:100.00% of 11
Creating 'eeprom_log.c.gcov'
Lines executed:100.00% of 21

増えた1本も同じ不具合が原因で、2回目の起動回数が2ではなく1になっています。

直してもう一度回す

直した版では6本すべてPASSし、分岐も6個中6個(100%)になりました。

消去済みの0xFFを読んだときは、0回として数えます。起動回数は1バイトなので、255回目の起動で書いた0xFFも、次の起動では消去済みと見なされて1に戻ります。直した版をeeprom_log_fixed.cとして作りました。

src/eeprom_log.c → src/eeprom_log_fixed.c(足した3行)

     uint8_t boot = eeprom_hal_read(0);
+    if (boot == 0xFF) {             /* 消去済み(初回)は0回として数える */
+        boot = 0;
+    }
     eeprom_update_reg(0, (uint8_t)(boot + 1));
UCRT64のターミナル(~/c-unit-test-demo で実行)

$ gcc -std=c99 -Wall -Wextra -Iunity -Isrc --coverage -c src/eeprom_log_fixed.c
$ gcc --coverage -o test_eeprom_log_fixed test_eeprom_log.o eeprom_log_fixed.o fake_eeprom.o unity.o
$ ./test_eeprom_log_fixed
test/test_eeprom_log.c:77:test_first_boot_counts_one:PASS
test/test_eeprom_log.c:78:test_first_boot_writes_boot_record:PASS
test/test_eeprom_log.c:79:test_wraps_after_32_records:PASS
test/test_eeprom_log.c:80:test_broken_next_restarts_from_zero:PASS
test/test_eeprom_log.c:81:test_same_value_is_not_written:PASS
test/test_eeprom_log.c:82:test_second_boot_continues:PASS
-----------------------
6 Tests 0 Failures 0 Ignored 
OK
$ gcov -b eeprom_log_fixed.c
File 'src/eeprom_log_fixed.c'
Lines executed:100.00% of 23
Branches executed:100.00% of 6
Taken at least once:100.00% of 6
Calls executed:100.00% of 11
Creating 'eeprom_log_fixed.c.gcov'
Lines executed:100.00% of 23

直した分だけifが1つ増えたので、分岐の数は4個から6個になっています。テストを残しておけば、コードを変えるたびに実行して、同じ不具合が戻っていないかを確かめられます。総合演習の記事のコードも、この修正を入れた版に更新しています。

まとめ

C言語の単体テストは、Unityとgccがあれば、実機がなくてもPC上で回せます。gcovで分岐まで測ると、テストが全部PASSしていても通っていない分岐が見つかり、足すべきテストと、消してよいコードが分かりました。

条件が2つの判定は、真と偽の2本ではMC/DCを満たさず、条件ごとに結果を変えるテストが要ります。レジスタを触るコードは別ファイルに出し、テストのときだけ偽物をリンクすれば、PCでテストできます。この切り離し方でテストすると、起動回数を数える処理の不具合が1つ見つかりました。

ただし、カバレッジは分岐を通ったかを表すだけで、不具合がないことを意味するわけではありません。

題材のコードの全体を見る

テストした3つのコードが、自動温湿度調整システムの中でどうつながっているかは、総合演習の記事にまとめています。

Arduino総合演習を読む


// C言語の基礎を固める
C言語 基礎ドリル|組込みに必要な8テーマの練習問題まとめ