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?

【AI駆動開発#3】組込みエンジニアがPythonでUDS診断ツールを作った話 〜実ECUなしでテストするためにECUシミュレータを自作した〜

0
Posted at

2026年5月頃、Claude.ai(チャット)を使って趣味で作ったツールの開発記録です(全4回の第3回)。

はじめに

今回はテストについて書きます。TDD(テストを先に書く開発)は今回が初めてです。ただし、実際にはTDDを守りきれていません。その反省は#4で書きます。この記事では、用意したテストの中身を紹介します。


組込み開発でテストが難しい理由

組込み開発のテストの課題
  ├── 実機がないとテストできない
  ├── ハードウェアの準備に時間がかかる
  ├── 環境構築のコストが高い
  └── タイミング依存のバグを再現しにくい
        ↓
「とりあえず実機で動かす」になりやすい

今回のツールは、モックとECUシミュレータを使えば実ECUなしでテストできます。


TDD・ATDDのサイクル

ATDD(受け入れシナリオを先に定義)
「DID=F190を読んだら、VINが返ってくる」
        ↓
TDD(ユニットテストを先に書く)
  ├── Red:      テストを書く(まだ実装なし → 失敗)
  ├── Green:    最小限の実装(テストが通る)
  └── Refactor: コードを整理
        ↓
実装完了

ATDDは受け入れテスト駆動開発のことで、ユーザー視点の受け入れ条件を先にテストとして書きます。


テストのコード

ユニットテスト(TDD)

SID 0x22 ReadDataByIdentifierを例に示します。

# tests/unit/test_read_data_by_identifier.py

_RDBI = udsoncan.services.ReadDataByIdentifier  # udsoncanのサービスクラス

class TestReadDataByIdentifierRequest:

    def test_build_request_f190(self):
        """DID=F190のリクエストフレームが正しく生成される"""
        frame = ReadDataByIdentifier().build_request(did=0xF190)
        assert frame[0] == 0x22                    # SID
        assert frame[1:3] == bytes([0xF1, 0x90])   # DID

    def test_did_out_of_range_raises(self):
        """範囲外のDIDはValueErrorになる"""
        with pytest.raises(ValueError, match="DID must be in range"):
            ReadDataByIdentifier().build_request(did=0x10000)


class TestReadDataByIdentifierResponse:

    def test_parse_positive_response(self):
        """Positive Responseからデータが取り出せる"""
        resp = udsoncan.Response(
            service=_RDBI,
            code=udsoncan.Response.Code.PositiveResponse,
        )
        resp.data = bytes([0xF1, 0x90]) + b"1HGBH41JXMN109186"
        result = ReadDataByIdentifier().parse_response(
            resp.get_payload(), did=0xF190
        )
        assert result.success is True
        assert result.data == b"1HGBH41JXMN109186"

    def test_parse_nrc_returns_human_readable(self):
        """NRCコードが人間可読メッセージに変換される"""
        nrc_resp = udsoncan.Response(service=_RDBI, code=0x31)
        result = ReadDataByIdentifier().parse_response(
            nrc_resp.get_payload(), did=0xF190
        )
        assert result.success is False
        assert result.nrc_code == 0x31
        assert result.nrc_message == "Request Out Of Range"

ATDDシナリオテスト

プロトコル層をモックに差し替えて、ECUの応答を自由に設定します。

# tests/atdd/test_step1_read_did.py

class TestStep1AcceptanceReadDID:

    def setup_method(self):
        # DoIPProtocolをモックに差し替え(実通信不要)
        self.mock_protocol = MagicMock(spec=ProtocolBase)
        self.service = DiagnosticService(protocol=self.mock_protocol)

    def test_read_did_f190_returns_vin(self):
        """
        Given: ECUがDID F190に対してVINを返す
        When:  read_data_by_identifier(did=0xF190)を呼ぶ
        Then:  success=True、dataにVINが入る
        """
        resp = udsoncan.Response(
            service=_RDBI,
            code=udsoncan.Response.Code.PositiveResponse,
        )
        resp.data = bytes([0xF1, 0x90]) + b"1HGBH41JXMN109186"
        self.mock_protocol.receive.return_value = resp.get_payload()

        result = self.service.read_data_by_identifier(did=0xF190)

        assert result.success is True
        assert result.data == b"1HGBH41JXMN109186"

    def test_read_unknown_did_returns_nrc(self):
        """
        Given: ECUがNRC 0x31を返す
        When:  存在しないDIDを指定する
        Then:  NRCコードと人間可読メッセージが返る
        """
        self.mock_protocol.receive.return_value = (
            udsoncan.Response(service=_RDBI, code=0x31).get_payload()
        )
        result = self.service.read_data_by_identifier(did=0x9999)
        assert result.success is False
        assert result.nrc_message == "Request Out Of Range"

結合テスト(ECUシミュレータ使用)

TCP通信まで含めて確認するために、PythonでECUシミュレータを作りました。

[pytest]
   │
[DoIPProtocol] ──TCP── [ECUシミュレータ(別スレッド)]
                         ├── RoutingActivation応答
                         ├── UDSリクエスト処理
                         └── DIDテーブルからレスポンス生成
# tests/integration/test_integration_doip.py

@pytest.fixture(scope="module")
def ecu_simulator():
    """テスト開始時に自動起動、終了時に自動停止"""
    stop_event = threading.Event()
    threading.Thread(
        target=run_server,
        kwargs={"host": "127.0.0.1", "port": 15740,
                "stop_event": stop_event},
        daemon=True,
    ).start()
    time.sleep(0.2)
    yield {"host": "127.0.0.1", "port": 15740}
    stop_event.set()


class TestIntegrationDoIP:

    def test_read_vin_did_f190(self, ecu_simulator):
        """実TCP通信でDID F190が読み出せる"""
        protocol = DoIPProtocol(
            host=ecu_simulator["host"],
            port=ecu_simulator["port"],
        )
        protocol.connect()
        try:
            result = DiagnosticService(protocol=protocol)\
                         .read_data_by_identifier(did=0xF190)
        finally:
            protocol.disconnect()
        assert result.success is True
        assert result.data == b"SIMVIN00000000001"

テスト実行結果

$ pytest tests/ -v
tests/atdd/test_step1_read_did.py::...::test_read_did_f190_returns_vin PASSED
tests/atdd/test_step2_session.py::...::test_transition_to_extended_session PASSED
tests/integration/test_integration_doip.py::...::test_read_vin_did_f190 PASSED
tests/unit/test_read_data_by_identifier.py::...::test_build_request_f190 PASSED
...
130 passed in 3.24s

テストの3層構造

テスト 使うもの 確認すること
結合テスト ECUシミュレータ TCP通信・DoIPハンドシェイク・応答
ATDDシナリオテスト モック ユーザー視点のシナリオ
ユニットテスト なし(クラス単体) クラス・メソッド単位の動作

テストを書いてわかったこと

わかったこと 内容
設計の問題に早く気づける テストが書きにくい箇所は、設計に問題がある
AIの間違いを見つけられる ライブラリAPIの誤用は、テストの実行で見つかった
異常系を試しやすい モックでNRC応答を自由に再現できる
安心して修正できる テストでカバーしている範囲は、壊れていないことを確認できる

まとめ

  • プロトコル層をモックに差し替えて、実ECUなしでテストした
  • ECUシミュレータを作り、TCP通信を含む結合テストも自動化した
  • pytest tests/ で全テストを実行できる
  • テストが書きにくい箇所から、設計の問題に気づける

次回

#4では、開発全体を振り返ります。AIが間違えた例、TDDを守れなかった点、うまくいった点を書きます。

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?