0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【単体テスト#3】TDD・CI/CD編 ― テスト自動化で品質担保と工数削減を実現する

0
Posted at

このシリーズはC言語組み込み開発における単体テストを体系的に整理することを目的としています。

  • #1:概念・テストレベル・手法・テストダブル(スタブ・モック・フェイク)
  • #2:C言語ツール選定 ― 商用ツール・GoogleTest・Unity 徹底比較
  • #3(本記事):TDD・CI/CD ― テスト自動化で品質担保と工数削減を実現する

本記事では、単体テストフレームワーク(GoogleTest・Unity等)の基本的な使い方は既知として扱う。ツール選定の詳細は[#2]を参照されたい。


1. TDD(テスト駆動開発)

TDDとは

TDD(Test-Driven Development)は、実装コードよりも先にテストを書くことを原則とする開発手法。Kent Beckが2002年に著書 "Test-Driven Development: By Example"(日本語訳『テスト駆動開発』はオーム社より2017年刊)で体系化し、XP(エクストリームプログラミング)やアジャイル開発の中核プラクティスとして広まった。

一見すると「テストを先に書く」という手順の話に聞こえるが、本質は設計手法にある。テストを先に書くことで「この関数をどう使うか」を先に考えることになり、自然と使いやすく・テストしやすいインタフェース設計に収束する。

Red / Green / Refactor サイクル

TDDの基本サイクルは3ステップで構成される。このサイクルを小さく・速く繰り返すことが肝要で、1サイクルは数分以内に完了するのが理想とされる。

┌─────────────────────────────────────────────┐
│                                             │
│   ① Red ─────────────────────────────────  │
│      失敗するテストを1つ書く                  │
│      (まだ実装がないのでコンパイルエラー      │
│       またはテスト失敗になって当然)           │
│                  ↓                          │
│   ② Green ───────────────────────────────  │
│      テストが通る最小限の実装を書く            │
│      (きれいなコードでなくてよい、            │
│       とにかく通すことだけを考える)           │
│                  ↓                          │
│   ③ Refactor ────────────────────────────  │
│      テストが通ったまま、コードを整理する      │
│      (重複排除・命名改善・責務の分離など)    │
│                  ↓                          │
│      ① に戻る                               │
│                                             │
└─────────────────────────────────────────────┘

ポイントは 「Greenになるまでリファクタリングしない」 こと。Redの段階でリファクタリングを始めると、テストが失敗している原因が「実装が不完全だから」なのか「リファクタリングで壊したから」なのか分からなくなる。

具体的な実践例(C言語+Unity)

温度値を受け取り、異常範囲を判定する関数 judge_temperature() をTDDで実装する例を示す。


Step 1:Red ─ まず失敗するテストを書く

仕様:「温度が -40〜125 の範囲内なら OK、範囲外なら ERROR を返す」

// test_temperature.c
#include "unity.h"
#include "temperature.h"  // まだ存在しない

void setUp(void) {}
void tearDown(void) {}

// 正常範囲の下限
void test_judge_temperature_lower_bound_ok(void) {
    TEST_ASSERT_EQUAL(OK, judge_temperature(-40));
}

// 正常範囲の上限
void test_judge_temperature_upper_bound_ok(void) {
    TEST_ASSERT_EQUAL(OK, judge_temperature(125));
}

// 下限を下回る異常
void test_judge_temperature_below_lower_bound(void) {
    TEST_ASSERT_EQUAL(ERROR, judge_temperature(-41));
}

// 上限を上回る異常
void test_judge_temperature_above_upper_bound(void) {
    TEST_ASSERT_EQUAL(ERROR, judge_temperature(126));
}

この時点では temperature.hjudge_temperature() も存在しないため、ビルドエラーになる(= Red)。これが正しい出発点。


Step 2:Green ─ テストが通る最小限の実装を書く

// temperature.h
#ifndef TEMPERATURE_H
#define TEMPERATURE_H

#define OK    0
#define ERROR 1

int judge_temperature(int temp);

#endif
// temperature.c(最小限の実装)
#include "temperature.h"

int judge_temperature(int temp) {
    if (temp < -40 || temp > 125) {
        return ERROR;
    }
    return OK;
}

ビルドしてテストを実行すると全件パスする(= Green)。ここでは「きれいに書くこと」より「通すこと」を優先する。


Step 3:Refactor ─ テストを通したままコードを整理する

// temperature.h(定数を明示的に定義)
#define TEMP_MIN  (-40)
#define TEMP_MAX  (125)

// temperature.c(マジックナンバーを排除)
int judge_temperature(int temp) {
    if (temp < TEMP_MIN || temp > TEMP_MAX) {
        return ERROR;
    }
    return OK;
}

リファクタリング後も全テストがGreenのままであることを確認する。


Step 4:次の仕様を追加する(サイクルを繰り返す)

仕様追加:「温度が 100〜125 の範囲は WARNING を返す」

// まず新しいテストを追加(Red)
void test_judge_temperature_warning_range(void) {
    TEST_ASSERT_EQUAL(WARNING, judge_temperature(100));
    TEST_ASSERT_EQUAL(WARNING, judge_temperature(125));
}

void test_judge_temperature_ok_range(void) {
    TEST_ASSERT_EQUAL(OK, judge_temperature(99));
    TEST_ASSERT_EQUAL(OK, judge_temperature(-40));
}
// 実装を更新(Green)
#define TEMP_WARNING_MIN (100)

int judge_temperature(int temp) {
    if (temp < TEMP_MIN || temp > TEMP_MAX) {
        return ERROR;
    }
    if (temp >= TEMP_WARNING_MIN) {
        return WARNING;
    }
    return OK;
}

このように「テスト追加 → 実装 → リファクタリング」の小さなサイクルを積み重ねて機能を育てていく。

TDDのメリット

設計品質の向上:
テストを先に書くと「この関数をどう呼び出すか」を先に考えることになる。結果として、引数が多すぎる関数・グローバル変数に依存した関数・複数の責務を持つ関数は自然と書きにくくなる。TDDを継続するとコードが疎結合・単一責任に収束しやすい。

デグレードの早期検出:
既存のテストがリグレッションガードとして機能するため、仕様変更や機能追加で既存動作を壊したことを即座に検出できる。

仕様のドキュメント化:
テストコードは「この関数がどう動くべきか」の実行可能な仕様書になる。コメントや設計書より陳腐化しにくい。

デバッグコストの削減:
バグを発生直後(実装直後)に発見できるため、原因の特定が容易。問題が後工程で発覚するほど原因の範囲が広がり、修正コストが増大する。

実装に対する自信:
常にテストがGreenの状態を維持することで、「このコードは動いている」という根拠のある自信を持てる。リファクタリングへの心理的障壁が下がる。

TDDのデメリット・注意点

学習コスト:
TDDは慣れるまでに時間がかかる。特に「Redから始める」感覚と「最小限の実装に留める」規律は、既存のコーディングスタイルからの切り替えが必要になる。

テストコードの保守コスト:
プロダクトコードとテストコードの両方を保守することになる。設計変更が発生するとテストコードの修正も伴うため、変更コストが増える局面がある。

全ての領域には適用できない:
ハードウェア依存のコードや割り込みハンドラなど、テストを先に書くこと自体が困難な領域が組み込み開発には存在する。

即効性がない:
TDDの恩恵(デグレード防止・設計改善)は積み重ねによって発揮される。短期的にはテストを書く分だけ時間がかかるように見えることがあり、チームや組織の理解を得るのに苦労することがある。

組み込み開発でのTDD

「ハードウェアがなければ何もできない」という前提がある組み込み開発では、TDDの適用範囲は限定されるが、適用できる領域は確実に存在する。

適用しやすい領域:

領域
制御アルゴリズム PID制御、ローパスフィルタ、移動平均
プロトコル処理 CANフレームのパース・生成、シリアルプロトコルの解釈
状態機械ロジック モード遷移、エラーハンドリングのステートマシン
ユーティリティ関数 CRC計算、データ変換、リングバッファ
診断・異常判定ロジック センサー値の妥当性チェック、フォルト検出

適用が難しい領域:

領域 理由
レジスタ直叩きのドライバ層 ハードウェアがなければ動作確認できない
割り込みハンドラ タイミング依存、テスト環境で割り込みを再現しにくい
ブートローダ・スタートアップ処理 実行環境への強い依存
リアルタイム性が要件の処理 ホスト環境とターゲットの実行速度差

現場での現実的なアプローチ:

完全なTDDを最初から目指す必要はない。まず「テストファースト的な意識」から入るのが現実的。

【段階的な導入ステップ】

Step 1:既存コードにテストを後付けする(テスト文化の醸成)
Step 2:新規関数を書く際にテストを先に考える習慣をつける
Step 3:HAL境界を設けてビジネスロジックをハードウェアから切り離す
Step 4:ロジック層でRedから始めるTDDサイクルを実践する

HALで依存を分離し、ビジネスロジック層にTDDを適用する。ドライバ層はスタブ・モックで代替し、ホスト環境でテストを回す構成が組み込みTDDの定番パターンになる。


2. CI/CDとDevOpsへの組み込み

CI/CDとは

CI(継続的インテグレーション):コード変更のたびにビルド・テストを自動実行し、問題を早期に検出する仕組み。

CD(継続的デリバリー/継続的デプロイ):テストを通過したビルド成果物をリリース可能な状態に保つ仕組み。「継続的デリバリー(Continuous Delivery)」は手動承認を経て本番反映、「継続的デプロイ(Continuous Deployment)」は自動で本番反映という違いがある。組み込みではファームウェアの自動生成・フラッシュ書き込み可能な成果物の保存が相当する。

なぜCI/CDに単体テストを組み込むのか

単体テストをCIパイプラインに統合することで得られる価値は大きく3つある。

① リグレッションテストの自動化による品質担保

ソフトウェアは変更のたびに既存の動作を壊すリスクを伴う。機能追加・バグ修正・リファクタリングのいずれであっても、意図しない副作用が生じる可能性はゼロではない。

CIに単体テストを組み込むと、コードがリポジトリにコミットされるたびに、蓄積された全テストケースが自動で実行される。これがリグレッションテスト(回帰テスト)の自動化に相当する。

【CIなし(手動テストのみ)の場合】

  コミット → レビュー → (誰かが気づいたときだけ)手動でテスト実行
                                ↑
              テスト実施タイミングが不定・属人的
              デグレードが後工程まで気づかれないことがある


【CIあり(テスト自動化)の場合】

  コミット → CI自動起動 → 全テスト実行 → 結果通知
                              ↑
              誰がコミットしても・何時コミットしても必ず実行される
              デグレードをコミット単位で即座に検出できる

テストケースが蓄積されるほどリグレッション検出の網が細かくなり、テスト資産が品質担保の基盤として機能するようになる

② 人手の工数低減

テストを手動で実施する場合、以下のような工数が繰り返し発生する。

  • テスト環境のセットアップ
  • テストケースに沿った手順の実行
  • 結果の記録・集計
  • レポートの作成

これらをCIが自動で行うことで、エンジニアは本来の設計・実装・レビューに集中できる。特に組み込み開発では、ビルド→転送→確認というサイクルに時間がかかりがちだが、ホスト環境での単体テストをCIに乗せることでこのサイクルを自動化・高速化できる。

テストケースが100件・1000件と増えても、CIが実行するコストはほぼ変わらない。手動では現実的でない規模のテストも、CIであれば毎回実行し続けられる。

③ ヒューマンエラーの排除

手動テストには避けられない人的ミスが伴う。

【手動テストで起きやすいヒューマンエラー】

  ・テスト手順の読み間違い・手順抜け
  ・テスト結果の記録ミス・転記ミス
  ・「前回と同じ結果だろう」という確認省略
  ・テスト実施者によるバラツキ(熟練度・体調・時間帯)
  ・「今回は急いでいるので後でやる」という先送り

CIによるテスト自動化はこれらを構造的にゼロにする。テストの実行・判定・記録はスクリプトが行うため、実施者の状態や判断に依存しない。「誰が・いつ・何回やっても同じ結果になる」という再現性が保証される。

これは品質保証の観点でも重要で、テスト結果がエビデンスとして信頼できる根拠になる。手動テストの「実施した」という記録は属人的な証拠にとどまるが、CIのログは実行環境・実行日時・結果を自動で記録した客観的なエビデンスになる。

単体テストのCI統合(実装例)

# GitHub Actions の例(Unity + CMake 構成)
name: Unit Test

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install dependencies
        run: sudo apt-get install -y gcc cmake

      - name: Build
        run: cmake -B build && cmake --build build

      - name: Run Tests
        run: cd build && ctest --output-on-failure

      - name: Upload test results
        uses: actions/upload-artifact@v4
        with:
          name: test-results
          path: build/test_results.xml  # JUnit XML をエビデンスとして保存

CIツール(Jenkins・GitLab CI・GitHub Actions等)はJUnit XML形式のテスト結果をパースしてダッシュボードに表示する機能を標準で持っており、テスト件数・成功率・失敗したテストの詳細を視覚的に確認できる。

組み込み開発でのCI構成例

[コミット / プルリクエスト]
    ↓
[静的解析]            ← cppcheck / PC-lint / clang-tidy
    ↓                    コーディング規約違反・危険なコードパターンを検出
[ホストビルド + 単体テスト]  ← Unity / GoogleTest
    ↓                    ロジックの正しさをホスト環境で高速に検証
    ↓                    → JUnit XML でテスト結果を記録
    ↓                    → gcov / lcov でカバレッジレポートを生成
[クロスコンパイル]     ← ターゲット向けファームウェアビルド
    ↓
[HILテスト(任意)]   ← 実機または評価ボード
    ↓                    実環境依存の動作確認
[ファームウェア成果物の保存]  ← バージョン管理・デプロイ

各ステージで問題が検出されると以降のステージに進まず、コミット作者に通知が届く。これにより問題の検出・フィードバック・修正のサイクルを最小化できる。

組み込みCI/CDの課題と対策

課題 対策
ハードウェア依存でホストビルドできない HALで依存を分離・モック化してロジック層をホストでテスト
クロスコンパイル環境の構築が複雑 Dockerでビルド環境をコンテナ化し再現性を確保
実機テストの自動化が難しい HILテスト装置の整備(中長期の投資として計画する)
テスト実行時間が長い テストを粒度で分類し並列実行・ステージ分割で対応
CIサーバのライセンスや運用コスト GitLab CI / GitHub Actionsのクラウド環境で初期投資を抑える

DevOpsとの関係

DevOpsは開発(Dev)と運用(Ops)の壁を取り除き、フィードバックループを高速化する文化・プラクティスの総称。組み込み開発では「運用」が製品リリース後のファームウェア更新・フィールド対応に相当する。

単体テストのCI統合はDevOpsの入口として最も費用対効果が高い施策の一つ。「テストが自動で回る状態」を作ることで、以下のような連鎖的な改善が起きる。

【CI/CDが生み出す改善の連鎖】

  単体テストのCI統合
    ↓
  コミットのたびにデグレードを検出できる
    ↓
  問題が小さいうちに修正できる → 手戻りコストの削減
    ↓
  リリース判断の根拠が客観的なテスト結果になる
    ↓
  レビュー・リリース判断の品質と速度が上がる
    ↓
  開発サイクル全体が短縮・安定する

「まずテストが自動で回る状態を作る」ことが、現場のDevOps文化を育てるための現実的な第一歩になる。


おわりに

本シリーズでは、単体テストの概念から始まり、テストレベルの役割の違い・テスト手法・テストダブル(#1)、C言語でのツール選定と比較(#2)、そしてTDDとCI/CDによるテスト自動化(本記事)までを整理した。

単体テストは銀の弾丸ではないが、早期に導入するほどリターンが大きい。組み込み開発での第一歩として現実的なのは以下の3ステップ。

  1. ハードウェア非依存なロジック関数を一つ選ぶ
  2. Unityで最初のテストを書いてみる
  3. CIに乗せて自動実行の仕組みを作る

完璧なテストカバレッジを最初から目指す必要はない。まず「テストが自動で回る状態」を作ることが、現場の文化を変えるための最初の一歩になる。

シリーズ一覧

  • 【単体テスト#1】概念・テストレベル・手法編 ― テストダブル(スタブ・モック・フェイク)まで
  • 【単体テスト#2】C言語ツール選定編 ― 商用ツール・GoogleTest・Unity 徹底比較
  • 【単体テスト#3】TDD・CI/CD編 ― テスト自動化で品質担保と工数削減を実現する(本記事)

参考・関連リンク

0
0
0

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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?