本記事は組み込み開発の周辺ツール・スクリプト開発を対象としたPython単体テストの実践ガイドです。
C言語組み込みファームウェア自体の単体テストについては、以下のシリーズを参照してください。
- 【単体テスト#1】概念・テストレベル・手法編 ― テストダブル(スタブ・モック・フェイク)まで
- 【単体テスト#2】C言語ツール選定編 ― 商用ツール・GoogleTest・Unity 徹底比較
- 【単体テスト#3】TDD・CI/CD編 ― テスト自動化で品質担保と工数削減を実現する
はじめに
組み込み開発の現場では、ファームウェアそのものに加えて、さまざまなPythonスクリプトが使われている。ビルドスクリプト・テスト自動化ツール・シリアル通信ログの解析スクリプト・CANデータのパーサ・CI制御ロジック・設定ファイルの生成ツールなど、挙げればきりがない。
これらの「周辺ツール」はファームウェアと同様に品質が求められるにもかかわらず、テストが後回しにされがちな領域でもある。Pythonには充実したテストフレームワークが揃っており、導入コストも低い。本記事ではPythonでの単体テストの実践を、組み込み開発の文脈に合わせて整理する。
また後半では、ctypesを使ってホスト向けにビルドしたCライブラリをPythonからテストする手法も紹介する。PythonのテストランナーでCのロジックを検証する、という使い方も選択肢の一つとして知っておくと幅が広がる。
1. unittest ― 標準ライブラリのテストフレームワーク
概要
unittest はPythonに標準で組み込まれているテストフレームワーク。追加インストール不要で使え、JavaのJUnitをモデルにした設計になっている。
基本的な使い方
# calc.py(テスト対象)
def add(a: int, b: int) -> int:
return a + b
def divide(a: float, b: float) -> float:
if b == 0:
raise ValueError("Division by zero")
return a / b
# test_calc.py
import unittest
from calc import add, divide
class TestAdd(unittest.TestCase):
def test_add_positive(self):
self.assertEqual(add(2, 3), 5)
def test_add_negative(self):
self.assertEqual(add(-1, -1), -2)
def test_add_zero(self):
self.assertEqual(add(0, 0), 0)
class TestDivide(unittest.TestCase):
def test_divide_normal(self):
self.assertAlmostEqual(divide(10.0, 3.0), 3.333, places=3)
def test_divide_by_zero(self):
with self.assertRaises(ValueError):
divide(1.0, 0.0)
if __name__ == '__main__':
unittest.main()
実行:
python -m unittest test_calc.py
# または
python -m unittest discover # カレント以下のtest_*.pyを自動検出
setUp / tearDown
テストの前後に共通処理を差し込む仕組み。ファイル生成・シリアルポートのオープン・DBの初期化など、テスト間で共有するリソースの管理に使う。
class TestFileParser(unittest.TestCase):
def setUp(self):
# 各テスト前に実行:テスト用ファイルを作成
self.test_file = "test_input.csv"
with open(self.test_file, "w") as f:
f.write("id,value\n1,100\n2,200\n")
def tearDown(self):
# 各テスト後に実行:テストファイルを削除
import os
if os.path.exists(self.test_file):
os.remove(self.test_file)
def test_parse_row_count(self):
from csv_parser import parse
result = parse(self.test_file)
self.assertEqual(len(result), 2)
主なアサーションメソッド
| メソッド | 検証内容 |
|---|---|
assertEqual(a, b) |
a == b |
assertNotEqual(a, b) |
a != b |
assertTrue(x) |
bool(x) is True |
assertFalse(x) |
bool(x) is False |
assertIsNone(x) |
x is None |
assertRaises(exc, func) |
funcがexcを送出する |
assertAlmostEqual(a, b) |
aとbがほぼ等しい(浮動小数点向け) |
assertIn(a, b) |
a in b |
2. pytest ― 現場で最もよく使われるフレームワーク
概要
pytestは現在Pythonエコシステムで最も広く使われているテストフレームワーク。unittest との互換性を保ちながら、よりシンプルな記法で書ける。組み込み開発の周辺ツール開発でもpytestを選ぶのが現実的な第一選択肢といえる。
pip install pytest
基本的な使い方
# test_calc.py(pytestスタイル)
from calc import add, divide
import pytest
# クラス不要・assertで直接検証できる
def test_add_positive():
assert add(2, 3) == 5
def test_add_negative():
assert add(-1, -1) == -2
def test_divide_by_zero():
with pytest.raises(ValueError):
divide(1.0, 0.0)
実行:
pytest # カレント以下を自動検出
pytest test_calc.py # ファイル指定
pytest -v # 詳細出力
pytest -v -k "divide" # テスト名でフィルタ
フィクスチャ(fixture)
pytestのフィクスチャはunittestのsetUp/tearDownに相当するが、より柔軟で再利用しやすい設計になっている。@pytest.fixture デコレータで定義し、テスト関数の引数に指定するだけで自動的に注入される。
import pytest
import os
@pytest.fixture
def temp_csv(tmp_path):
"""テスト用CSVファイルを生成するフィクスチャ"""
csv_file = tmp_path / "test_input.csv"
csv_file.write_text("id,value\n1,100\n2,200\n")
return str(csv_file)
def test_parse_row_count(temp_csv):
from csv_parser import parse
result = parse(temp_csv)
assert len(result) == 2
def test_parse_first_value(temp_csv):
from csv_parser import parse
result = parse(temp_csv)
assert result[0]["value"] == "100"
tmp_path はpytestが標準で提供するフィクスチャで、テストごとに一時ディレクトリを自動生成・自動削除する。ファイルI/Oを伴うテストでよく使う。
パラメータ化テスト
同じロジックを複数の入力で検証したい場合、@pytest.mark.parametrize を使うことでテストコードの重複を避けられる。
import pytest
from calc import add
@pytest.mark.parametrize("a, b, expected", [
(2, 3, 5),
(-1, -1, -2),
(0, 0, 0),
(100, -50, 50),
])
def test_add(a, b, expected):
assert add(a, b) == expected
実行すると4件のテストとして個別に実行・レポートされる。境界値分析のテストケースを網羅するときにも重宝する。
pytest -v
# test_calc.py::test_add[2-3-5] PASSED
# test_calc.py::test_add[-1--1--2] PASSED
# test_calc.py::test_add[0-0-0] PASSED
# test_calc.py::test_add[100--50-50] PASSED
モック(unittest.mock)
外部依存(ファイルI/O・シリアル通信・ネットワーク・時刻取得など)を切り離してテストするには、unittest.mock を使う。pytestからも問題なく使用できる。
from unittest.mock import patch, MagicMock
from serial_logger import read_sensor_value # シリアルポートから読む関数
def test_read_sensor_value_normal():
# serial.Serialをモックに差し替え
with patch("serial_logger.serial.Serial") as mock_serial:
mock_instance = MagicMock()
mock_instance.readline.return_value = b"TEMP:85.3\r\n"
mock_serial.return_value.__enter__.return_value = mock_instance
result = read_sensor_value("/dev/ttyUSB0")
assert result == 85.3
mock_instance.readline.assert_called_once()
組み込み周辺ツールではシリアル通信・GPIO制御・ファイルシステム操作などの外部依存が多いため、モックを使いこなすことでCI上でも安定してテストを実行できる。
カバレッジ計測
pip install pytest-cov
pytest --cov=calc --cov-report=term-missing
# または HTMLレポート生成
pytest --cov=calc --cov-report=html
--cov-report=term-missing でカバーできていない行番号がターミナルに表示される。
JUnit XML出力(CI連携)
pytest --junitxml=test_results.xml
GitHub Actions・GitLab CI・Jenkinsはこの形式を標準でパースできるため、CI上でのテスト結果の可視化がそのまま使える。
3. ctypes ― PythonからCライブラリを呼び出してテストする
概要と用途
ctypesはPython標準ライブラリに含まれており、コンパイル済みのCライブラリ(.so / .dll)をPythonから直接呼び出せる。
これを使うと「C言語で書いたロジック関数をPythonのpytestでテストする」という構成が可能になる。
Cソース(ロジック関数)
↓ ホスト向けgccで共有ライブラリとしてビルド(.so)
libcalc.so
↓ ctypesで読み込む
Pythonテストコード(pytest)
↓ 実行
テスト結果
これはあくまでホスト向けgccでビルドした共有ライブラリのテストであり、クロスコンパイルしたARMバイナリを実行するものではない点に注意。
共有ライブラリのビルド
// calc.c
#include "calc.h"
int add(int a, int b) {
return a + b;
}
int judge_temperature(int temp) {
if (temp < -40 || temp > 125) return 1; // ERROR
return 0; // OK
}
# ホスト向けの共有ライブラリとしてビルド
gcc -shared -fPIC -o libcalc.so calc.c
Pythonからの呼び出しとテスト
# test_calc_c.py
import ctypes
import pytest
import os
# 共有ライブラリのロード
lib = ctypes.CDLL(os.path.join(os.path.dirname(__file__), "libcalc.so"))
# 関数のシグネチャを定義
lib.add.restype = ctypes.c_int
lib.add.argtypes = [ctypes.c_int, ctypes.c_int]
lib.judge_temperature.restype = ctypes.c_int
lib.judge_temperature.argtypes = [ctypes.c_int]
# テスト
def test_add_positive():
assert lib.add(2, 3) == 5
def test_add_negative():
assert lib.add(-1, -1) == -2
@pytest.mark.parametrize("temp, expected", [
(-40, 0), # 下限(正常)
(125, 0), # 上限(正常)
(-41, 1), # 下限超え(異常)
(126, 1), # 上限超え(異常)
(0, 0), # 中間値(正常)
])
def test_judge_temperature(temp, expected):
assert lib.judge_temperature(temp) == expected
ctypesの主な型対応
| C型 | ctypes型 |
|---|---|
int |
ctypes.c_int |
unsigned int |
ctypes.c_uint |
long |
ctypes.c_long |
float |
ctypes.c_float |
double |
ctypes.c_double |
char * |
ctypes.c_char_p |
void * |
ctypes.c_void_p |
uint8_t |
ctypes.c_uint8 |
uint16_t |
ctypes.c_uint16 |
uint32_t |
ctypes.c_uint32 |
構造体の扱い
C側の構造体を渡す場合は ctypes.Structure を使って対応する型を定義する。
// C側
typedef struct {
int id;
float value;
int status;
} SensorData;
int process_sensor_data(SensorData *data);
# Python側
import ctypes
class SensorData(ctypes.Structure):
_fields_ = [
("id", ctypes.c_int),
("value", ctypes.c_float),
("status", ctypes.c_int),
]
lib.process_sensor_data.restype = ctypes.c_int
lib.process_sensor_data.argtypes = [ctypes.POINTER(SensorData)]
def test_process_sensor_data_normal():
data = SensorData(id=1, value=85.3, status=0)
# argtypes に POINTER を指定した場合、byref(data) は pointer(data) と同様に扱われる
result = lib.process_sensor_data(ctypes.byref(data))
assert result == 0
ctypesアプローチのメリット・デメリット
| 観点 | 内容 |
|---|---|
| メリット | pytestの豊富な機能(パラメータ化・フィクスチャ・カバレッジ)をCコードのテストに活用できる |
| メリット | テストコードをPythonで書けるため、C++の知識がなくてもテストを書ける |
| メリット | CI統合がシンプル(pytest実行だけで完結) |
| デメリット | ホスト向けgccでビルドした共有ライブラリのテストであり、クロスコンパイラが生成する実コードの検証にはならない |
| デメリット | 型定義をPython側でも書く必要があり、C側の変更に追従する手間がかかる |
| デメリット | 複雑な依存関係(ハードウェア依存・グローバル変数・割り込み)があるCコードはctypesだけでは対処しにくい |
ハードウェア非依存なアルゴリズム・変換・判定ロジックに絞って使うのが現実的な活用範囲。
4. unittest vs pytest ― どちらを選ぶか
| 観点 | unittest | pytest |
|---|---|---|
| 追加インストール | 不要(標準ライブラリ) | 必要(pip install pytest) |
| テストの記法 | クラスベース・assertメソッド | 関数ベース・assert文 |
| パラメータ化 | やや記述が冗長 |
@pytest.mark.parametrize でシンプル |
| フィクスチャ | setUp/tearDown |
@pytest.fixture(再利用・スコープ制御が強力) |
| CI出力(JUnit XML) | 追加設定が必要 |
--junitxml オプション一発 |
| カバレッジ | coverage.py と組み合わせ | pytest-covで一体化 |
| 既存コードとの互換性 | pytestからも実行可能 | ― |
組み込み周辺ツールの新規開発であればpytest一択でよい。既存のunittestベースのコードがある場合も、pytestはそのまま実行できるため移行コストは低い。
5. CI/CDへの組み込み
Pythonのテストはコマンド一発で実行できるため、CIへの統合は非常に容易。
# GitHub Actions の例
name: Python Unit Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install dependencies
run: pip install pytest pytest-cov
- name: Run tests
run: pytest --junitxml=test_results.xml --cov=src --cov-report=xml # --cov=. は仮想環境も含むため対象を絞る
- name: Upload test results
uses: actions/upload-artifact@v4
with:
name: test-results
path: test_results.xml
ctypesを使ったCライブラリのテストを含める場合は、ビルドステップを追加する。
- name: Build C library
run: gcc -shared -fPIC -o libcalc.so src/calc.c
- name: Run tests
run: pytest --junitxml=test_results.xml
おわりに
Pythonの単体テストは、組み込み開発の周辺ツール開発において品質を担保する実践的な手段になる。特に以下の領域では積極的に活用したい。
- ビルドスクリプト・自動化ツールのロジック検証
- シリアル通信ログのパーサ・データ変換スクリプト
- CANフレーム解析・設定ファイル生成ツール
- CI制御ロジック・テスト結果の集計スクリプト
ctypesを使えばCのロジック関数をPythonからテストする構成も取れる。ただしホスト向けgccでビルドした共有ライブラリのテストである点は認識しておく必要があり、クロスコンパイルしたARMバイナリの実コード検証には別途商用ツールやQEMUが必要になる。