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を守れなかった点、うまくいった点を書きます。