開発・デバッグ・テスト

ユニットテスト

部品単位の自動テスト。

概要

ユニットテスト(Unit Test)とは、ソフトウェアの最小単位(関数・クラス・モジュール)を独立して検証する自動テスト手法です。コードの正確性・堅牢性を確保し、変更によるデグレード(リグレッション)を早期に検出します。

組み込みシステムでのユニットテストは特有の課題があります。ターゲットハードウェアに依存したコード(レジスタ操作、ペリフェラル制御)が多いため、テスト実行環境をホストPC(x86)上に移す際にハードウェア抽象化(HAL)が必要です。これを「ネイティブ(ホスト)テスト」と呼びます。

ターゲット上でのテスト実行(オンターゲットテスト)も可能ですが、実行速度・フィードバック速度の面からネイティブテストとの組み合わせが推奨されます。CI/CDパイプラインにユニットテストを統合することで、コードプッシュのたびに自動検証が行われます。

歴史・背景

1970年代:構造化プログラミングの普及とともに、個々のサブルーチンを独立してテストするアイデアが生まれました。

1994年:Kent Beckが最初のxUnitフレームワーク(SUnit、Smalltalk向け)を開発。テスト駆動開発(TDD)の概念も整理されました。

1999年:JUnit(Java)がリリースされ、自動化ユニットテストが爆発的に普及。「テストを先に書く(TDD)」スタイルが広まりました。

2002年:CppUnit(C++)などのC/C++向けテストフレームワークが普及。組み込みC向けにはUnity(James Grenningらによる)が2007年頃から開発されました。

2010年代:Google Test(gtest)・Catch2などの現代的なC++テストフレームワークが登場。組み込みでもTDD(テスト駆動開発)を実践するエンジニアが増えました。James Grenningの著書「Test-Driven Development for Embedded C」(2011年)が組み込みTDDのバイブルとなりました。

2020年代:CI/CDの普及により、組み込みプロジェクトでもGitHub Actions・GitLab CI等でのユニットテスト自動実行が標準化しつつあります。

技術仕様

組み込みC向け主要テストフレームワーク

フレームワーク言語特徴
UnityC軽量・組み込み特化・Ceedlingと統合
CppUTestC/C++ヘッダのみ、モック機能(CppUMock)内蔵
Google TestC++多機能・CI/CD統合しやすい
Catch2C++ヘッダのみ・BDD記法対応
cmockaCモック機能に特化
CUnitC古くからある標準的なCフレームワーク

Unity を使った基本的なテスト

// src/calc.c - テスト対象のモジュール
#include "calc.h"
#include <stdint.h>

int32_t add(int32_t a, int32_t b) {
    return a + b;
}

// オーバーフロー検出付き乗算
bool safe_multiply(int32_t a, int32_t b, int32_t *result) {
    if (a != 0 && b > INT32_MAX / a) {
        return false;  // オーバーフロー
    }
    *result = a * b;
    return true;
}
// test/test_calc.c - Unityテスト
#include "unity.h"
#include "calc.h"

// テスト前に毎回呼ばれる初期化
void setUp(void) {}

// テスト後に毎回呼ばれるクリーンアップ
void tearDown(void) {}

void test_add_positive_numbers(void) {
    TEST_ASSERT_EQUAL_INT32(5, add(2, 3));
}

void test_add_negative_numbers(void) {
    TEST_ASSERT_EQUAL_INT32(-5, add(-2, -3));
}

void test_add_overflow_protection(void) {
    // INT32_MAX + 1 のオーバーフローをテスト
    TEST_ASSERT_EQUAL_INT32(INT32_MIN, add(INT32_MAX, 1));
    // ※ この実装はオーバーフローしてしまう!→ テストで検出
}

void test_safe_multiply_normal(void) {
    int32_t result;
    TEST_ASSERT_TRUE(safe_multiply(3, 4, &result));
    TEST_ASSERT_EQUAL_INT32(12, result);
}

void test_safe_multiply_overflow_detected(void) {
    int32_t result;
    TEST_ASSERT_FALSE(safe_multiply(INT32_MAX, 2, &result));
}

int main(void) {
    UNITY_BEGIN();
    RUN_TEST(test_add_positive_numbers);
    RUN_TEST(test_add_negative_numbers);
    RUN_TEST(test_add_overflow_protection);
    RUN_TEST(test_safe_multiply_normal);
    RUN_TEST(test_safe_multiply_overflow_detected);
    return UNITY_END();
}

ハードウェア依存コードのモック化

組み込みの最大の課題はハードウェア依存コードのテストです。ハードウェアアブストラクション(HAL)をモック(スタブ)に置き換えます:

// hal.h - ハードウェア抽象化インターフェース
#ifndef HAL_H
#define HAL_H
#include <stdint.h>

// ハードウェアに依存するI/O関数(実装は別)
uint8_t hal_i2c_read_byte(uint8_t addr, uint8_t reg);
void    hal_i2c_write_byte(uint8_t addr, uint8_t reg, uint8_t data);
uint32_t hal_get_tick_ms(void);

#endif
// test/mock_hal.c - テスト用モック実装(実ハードウェアを使わない)
#include "hal.h"
#include <stdint.h>

static uint8_t mock_i2c_return_value = 0;
static uint32_t mock_tick = 0;

// テストからモックの戻り値を設定できる
void mock_hal_set_i2c_return(uint8_t value) {
    mock_i2c_return_value = value;
}

void mock_hal_set_tick(uint32_t tick) {
    mock_tick = tick;
}

// モック実装(ハードウェアにアクセスしない)
uint8_t hal_i2c_read_byte(uint8_t addr, uint8_t reg) {
    (void)addr; (void)reg;
    return mock_i2c_return_value;
}

void hal_i2c_write_byte(uint8_t addr, uint8_t reg, uint8_t data) {
    (void)addr; (void)reg; (void)data;
    // 書き込みを記録するバッファを持つことも可能
}

uint32_t hal_get_tick_ms(void) {
    return mock_tick;
}
// test/test_sensor.c - センサードライバのテスト(ハード不要)
#include "unity.h"
#include "sensor_driver.h"
#include "mock_hal.h"

void test_temperature_sensor_reads_correctly(void) {
    // BMP280の温度レジスタが0x50を返す場合のテスト
    mock_hal_set_i2c_return(0x50);

    float temp = sensor_read_temperature();

    // モデルによる計算結果と一致するか確認
    TEST_ASSERT_FLOAT_WITHIN(0.5f, 24.8f, temp);
}

void test_sensor_timeout_detection(void) {
    // タイムアウト条件をシミュレート
    mock_hal_set_tick(0);
    sensor_start_conversion();

    mock_hal_set_tick(10000);  // 10秒後
    SensorStatus status = sensor_check_status();

    TEST_ASSERT_EQUAL(SENSOR_TIMEOUT, status);
}

動作原理

テスト実行のフロー(ホストPC上)

src/sensor_driver.c
src/sensor_driver.h
       ↓ コンパイル(ホストのGCC)
       ↓ test/mock_hal.c も一緒にリンク
test/test_sensor.c

テスト実行ファイル(x86_64 Linux/Mac上で実行)

テスト結果:
-----------------------
RUNNING test_temperature_sensor_reads_correctly
PASS
RUNNING test_sensor_timeout_detection
PASS
-----------------------
2 Tests 0 Failures 0 Ignored
OK

CeedlingによるビルドとCI

# project.yml (Ceedling設定)
:project:
  :build_root: build
  :test_file_prefix: test_

:paths:
  :test:
    - test/**
  :source:
    - src/**
  :include:
    - include/**

:defines:
  :test:
    - TEST_BUILD
    - UNIT_TEST

# ビルドコマンド
# ceedling test:all    → 全テスト実行
# ceedling gcov:all    → カバレッジ計測付き
# GitHub Actions でのユニットテスト自動実行
name: Unit Tests
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Install dependencies
        run: |
          sudo apt-get install -y gcc ruby
          gem install ceedling
      - name: Run unit tests
        run: ceedling test:all
      - name: Run coverage report
        run: ceedling gcov:all

コードカバレッジ

テストがコードのどれだけの割合を実行しているかを示す指標:

# GCC でカバレッジ計測付きビルド
gcc -fprofile-arcs -ftest-coverage -o test_calc test_calc.c calc.c

# テスト実行後
./test_calc

# lcov でカバレッジ集計
lcov --capture --directory . --output-file coverage.info
genhtml coverage.info --output-directory coverage_html

# 結果例:
# Lines:   95.2% (40/42)
# Branches: 87.5% (14/16)

用途・ユースケース

安全規格対応(IEC 61508/ISO 26262)

IEC 61508(機能安全)やISO 26262(車載機能安全)ではソフトウェアの検証が義務付けられており、ユニットテストはその中心的な手法です。SIL2/3以上ではMC/DC(Modified Condition/Decision Coverage:変更条件/決定カバレッジ)100%が要求される場合があります。

TDD(テスト駆動開発)の実践

TDDサイクル(Red → Green → Refactor):
1. 失敗するテストを書く(Red)
2. テストが通る最小限の実装を書く(Green)
3. コードをリファクタリング(Refactor)
4. 1に戻る

組み込みでのTDD適用例:
- CRCチェック関数: まず既知の入力・出力ペアのテストを書く
- フィルターアルゴリズム: 入力信号列と期待出力をテストに書く
- プロトコルパーサー: 各メッセージタイプのパースをテスト

リグレッションテスト

バグ修正後に同じバグが再発しないことを保証するため、バグを再現するテストケースを追加します:

// バグ: センサー値が0の時にゼロ除算クラッシュ
// 修正後、このテストを永続的に維持する
void test_division_by_zero_protection(void) {
    mock_hal_set_i2c_return(0);  // センサー値0
    float result = calculate_normalized_value();
    // NaNや無限大にならないことを確認
    TEST_ASSERT_FALSE(isnan(result));
    TEST_ASSERT_FALSE(isinf(result));
    TEST_ASSERT_EQUAL_FLOAT(0.0f, result);
}

実装・開発のポイント

テスト可能な設計(Testability)

// テストしにくいコード(グローバル状態に依存)
float g_temperature = 0.0f;

void process_sensor_data(void) {
    g_temperature = hal_i2c_read_byte(0x48, 0x00) * 0.5f;
    if (g_temperature > 80.0f) {
        activate_cooling_fan();  // 副作用(テスト困難)
    }
}

// テストしやすいコード(依存性注入)
typedef struct {
    float (*read_temperature)(void);
    void  (*activate_fan)(void);
} SensorCallbacks;

void process_sensor_data(SensorCallbacks *cb, float *out_temp) {
    *out_temp = cb->read_temperature();
    if (*out_temp > 80.0f) {
        cb->activate_fan();
    }
}
// → テスト時にモックのコールバックを注入できる

他技術との比較

比較項目ユニットテスト統合テストHIL実機テスト
テスト対象関数/モジュール単体複数モジュール結合実ハード+模擬環境実システム全体
実行速度非常に高速(秒単位)中速(分単位)中速低速(実機接続)
実機不要可(ホストPC実行)多くは可一部実機必要実機必須
異常系テスト容易(モックで制御)中程度容易(シミュレーション)困難(再現困難)
CI/CD統合最も容易容易専用インフラ必要困難
発見できる問題ロジックバグインターフェースバグリアルタイムバグシステム全体の問題

関連用語

参考リンク