0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Python×単体テスト】pytest・unittest・ctypes実践 ― 組み込み周辺ツール開発への活用

0
Posted at

本記事は組み込み開発の周辺ツール・スクリプト開発を対象とした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が必要になる。


参考・関連リンク

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?