Claude Media
Claude CodeでZephyrのTwisterを回す — 失敗ログだけ読ませるフック

Claude CodeでZephyrのTwisterを回す — 失敗ログだけ読ませるフック

ZephyrのユニットテストをTwisterでnative_simに載せて回し、twister.jsonから失敗したケースだけをPostToolUseFailureフックでClaude Codeに返す設定をまとめます。

Twisterは、Zephyrのテストを一括でビルドして実行し、結果をtwister-outにレポートとして書き出すテストランナーです。west twisterで起動します。Claude Codeに回させると、困るのは出力の量です。数十のテストスイートのビルドログが流れ、本当に見たい「どのテストが、なぜ落ちたか」は数行しかありません。この記事では、ユニットテストをnative_simで動かし、失敗したケースだけをフックでClaude Codeに返す設定を作ります。

失敗がClaude Codeに届くまでの経路

結論から言うと、Twisterの結果は2か所で絞れます。実行コマンドの出力をファイルへ逃がす段階と、フックでレポートから失敗分だけを抜く段階です。

Twisterは失敗したテストか、エラーになったテストがあると終了コード1で終わります。これはTwisterのメイン処理のソースにある挙動で、公式ドキュメントのページには終了コードの説明がありません。Claude CodeのBashツールで0以外のコードは失敗として扱われ、PostToolUseではなくPostToolUseFailureが発火します。テストがすべて通ったときはPostToolUseの側です。

手順

Twisterの結果がClaudeに届くまで

  1. 1

    Claudeが west twister を実行する

    出力はtwister-run.logなどのファイルへ向け、Bashの結果自体は短く保ちます。

  2. 2

    フックが起動する

    PostToolUseとPostToolUseFailureの両方に同じスクリプトを登録し、ifでwest twisterのときだけ動かします。

  3. 3

    スクリプトが twister.json を読む

    jqでステータスがfailedかerrorのスイートとケースだけを抜き出します。

  4. 4

    additionalContext で返す

    要約をhookSpecificOutput.additionalContextに入れ、ツール結果の隣に差し込みます。

Zephyr RTOSをwestビルドで開発する手順では、ビルドログのerror:行をstderrに出してexit 2で返す方法を扱いました。ビルドは「ログの中の数行」を拾えば足りますが、テストは「どのケースが落ちたか」という構造を持つため、ログのgrepよりレポートのJSONを読むほうが確実です。

native_simで動くユニットテストを用意する

Twisterは、testcase.yamlを持つディレクトリをテストとして拾います。ここではtests/unit/mathに、ztestを使った最小のテストを置きます。

tests/unit/math
├── CMakeLists.txt
├── prj.conf
├── testcase.yaml
└── src
    └── main.c

CMakeLists.txtは、ztestの公式サンプルと同じ形にします。

cmake_minimum_required(VERSION 3.28.0)
find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})
project(math_unit)
 
FILE(GLOB app_sources src/*.c)
target_sources(app PRIVATE ${app_sources})

prj.confはCONFIG_ZTEST=yの1行です。テスト本体には、スイートとケースをZTEST_SUITEとZTESTで宣言します。

#include <zephyr/ztest.h>
 
static int add(int a, int b)
{
	return a + b;
}
 
ZTEST(math_suite, test_add)
{
	zassert_equal(add(2, 3), 5, "add(2, 3) must be 5");
}
 
ZTEST(math_suite, test_add_negative)
{
	zassert_equal(add(-2, 3), 1, "add(-2, 3) must be 1");
}
 
ZTEST_SUITE(math_suite, NULL, NULL, NULL, NULL, NULL);

testcase.yamlでは、実行するプラットフォームをplatform_allowでnative_simに絞ります。tagsはシナリオごとの必須項目です。

tests:
  app.unit.math:
    platform_allow:
      - native_sim
    tags:
      - unit

実行は次のコマンドです。-Tでテストを探すディレクトリを、-pでプラットフォームを、-Oで出力先を指定します。出力先を明示しておくと、フックが読むレポートのパスが固定できます。

west twister -T tests/unit -p native_sim -O twister-out \
  > twister-run.log 2>&1

Twisterは実行のたびに出力先ディレクトリを空にします。--no-cleanを付けたときだけ前回の成果物が残ります。この仕様は、後で触れる古いレポートの読み違いを防ぐ前提にもなります。

twister.jsonから失敗だけを抜くフック

出力先には、実行の主要な成果物が並びます。公式ドキュメントでは、twister.jsonが機械可読の主レポートで、選ばれたテストスイートとテストケースごとにステータスなどが入ると説明されています。個別のテストのビルドディレクトリ(twister-out/<platform>/<テストのパス>/<scenario>/)には、build.logとエミュレーター側のコンソール出力を保存するhandler.logが残ります。

twister.jsonのキー名は、公式ドキュメントのページでは列挙されていません。そこでレポートを組み立てるTwisterのコードを見ると、トップレベルのtestsuitesの各要素がname・platform・status・reasonを持ち、失敗かエラーのときはlogも持ちます。その中のtestcasesの各要素が、identifier・status・reasonを持つ構造です。ステータスの値はpassed・failed・error・skipped・filtered・not run・blockedなどの文字列です。

この構造に合わせたスクリプトが次です。失敗したスイートのlogは末尾30行だけを返します。

#!/usr/bin/env bash
# .claude/hooks/twister-summary.sh
REPORT="twister-out/twister.json"
[ -f "$REPORT" ] || exit 0
EVENT=$(jq -r '.hook_event_name')
SUMMARY=$(jq -r '
  def bad: .status == "failed" or .status == "error";
  ([.testsuites[]?.testcases[]?.status] | group_by(.)
    | map("\(.[0]): \(length)") | join(", ")) as $counts
  | "twister test cases -> \($counts)",
    (.testsuites[]? | select(bad) |
      "## \(.name) [\(.platform)] \(.status): \(.reason // "no reason")",
      (.testcases[]? | select(bad)
        | "- \(.identifier): \(.reason // "no reason")"),
      "log tail:",
      ((.log // "") | split("\n") | .[-30:] | join("\n")))
' "$REPORT")
jq -nc --arg ev "$EVENT" --arg ctx "$SUMMARY" \
  '{hookSpecificOutput: {hookEventName: $ev, additionalContext: $ctx}}'

標準入力はフックのJSONで、hook_event_nameをそのままJSON出力に折り返しています。こうすると、同じスクリプトをPostToolUseにもPostToolUseFailureにも登録できます。先頭の1行は全ケースのステータス別件数です。全部通ったときも件数が見えるので、skippedやfilteredばかりで実際にはほとんど走っていなかった、という状況に気づけます。

フィールド名に誤りがないかは、手で作った最小のtwister.jsonで確かめました。失敗1件・成功2件の入力に対し、スクリプトは次の内容をadditionalContextに入れます。実機でTwisterを動かした出力ではなく、キー名との対応を見るための例です。

twister test cases -> failed: 1, passed: 2
## app.unit.math [native_sim/native] failed: Failed
- app.unit.math.math_suite.test_add: Assertion failed
log tail:
(スイートのログの末尾30行)

フックの登録は.claude/settings.jsonに書きます。ifは権限ルールと同じ記法で、PostToolUseとPostToolUseFailureの両方で使えます。

{
  "permissions": {
    "allow": ["Bash(west twister *)"]
  },
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "if": "Bash(west twister *)",
            "command": ".claude/hooks/twister-summary.sh"
          }
        ]
      }
    ],
    "PostToolUseFailure": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "if": "Bash(west twister *)",
            "command": ".claude/hooks/twister-summary.sh"
          }
        ]
      }
    ]
  }
}

ifのBashパターンは、コマンドの形によっては絞り込めず、そのときはフックが無条件に動きます。厳密な許可・拒否に使う仕組みではありません。ここではその特性が問題になりません。twister-out/twister.jsonが無ければ冒頭のexit 0で何もせず抜けるためです。PostToolUseFailureの仕組みそのものは、PostToolUseFailureフックの解説に詳しくあります。

出力をファイルへ逃がす理由と--inline-logsの使い分け

PostToolUseFailureで返すadditionalContextは、失敗したBashの結果を置き換えず、その隣に足されます。PostToolUseならupdatedToolOutputで結果自体を差し替えられますが、失敗側にその手段はありません。つまりTwisterの標準出力をそのままBashに流すと、長いビルドログ全体とフックの要約の両方がClaudeに届きます。

そこで先ほどのコマンドは、標準出力と標準エラーをtwister-run.logへリダイレクトしていました。Bashの失敗結果はExit code 1程度の短い文字列になり、判断に使う材料はフックの要約に集まります。要約で足りないときだけ、Claudeがtwister-run.logを部分的に読めばよい形です。

一方、Twisterには-i/--inline-logsがあります。ヘルプの記述は、テスト失敗時にログへのパスではなく関連するログの中身を標準出力へ出す、というものです。フックを使わずに素早く試したい場面向けです。

くらべる

失敗ログの渡し方

--inline-logs

標準出力に直接出す

  • 設定ゼロで失敗ログが出る
  • 失敗数が増えると標準出力が膨らむ
  • 他のテストのビルドログも混ざりうる
twister.json + additionalContext

フックで絞る

  • 失敗したケースの識別子と理由が先頭に並ぶ
  • ログの長さをスクリプト側で決められる
  • jqとフックの設定が要る

テストが数本のうちは--inline-logsで足ります。スイートが増えたら、失敗箇所を構造から引けるフックのほうが、読ませる量を一定に保てます。

修正して再実行するループをCLAUDE.mdに固定する

失敗を読んだClaude Codeは、実装かテストを直して再実行します。毎回全テストをビルドし直すと遅いので、Twisterの-f/--only-failedを使います。ヘルプには、前回のTwister実行で失敗したテストだけを走らせるオプションとあります。

また--report-summaryは、直近の実行の失敗とエラーのレポートをビルドし直さずに表示します。件数を整数で渡せば、先頭からその件数だけです。フックが読み込まれていない環境や、要約を人間が確認したいときに使えます。

運用の手順は、CLAUDE.mdに書いておくとぶれません。

## Zephyrのユニットテスト
- 実行: `west twister -T tests/unit -p native_sim -O twister-out > twister-run.log 2>&1`
- 失敗時は、フックが返す要約(失敗したケースと理由)を先に読む。`twister-run.log` 全体は開かない
- 修正後の再実行: `west twister -T tests/unit -p native_sim -O twister-out -f > twister-run.log 2>&1`
- 全件の確認は、最後に `-f` なしで1回実行する

最後の1行が要です。-fは前回失敗したものだけを回すため、直したケースが通っても、他のケースへの影響は見えません。修正が終わった時点で全件を回し直す手順を、指示として残します。

テストの失敗に対する自動修正の全体像は、CIのテスト失敗を自動修正する手順にあります。実行のたびに結果が揺れるテストの切り分けは、フレーキーテストの原因調査の領分です。

詰まりやすい点

落とし穴

前回のレポートを読んで、古い失敗を報告する

Twisterは実行ごとに出力先を空にしますが、--no-cleanを付けると前回のtwister.jsonが残ります。この指定で回していると、今回の実行が途中で止まったときに古い失敗を新しい失敗として報告しかねません。フックの先頭でtwister-out/twister.jsonの更新時刻を見て、Bashの実行開始より古ければ無視する、といった判定を足すと安全です。

次の2点も、実行前に確認しておくと原因の切り分けが早くなります。

  • testcase.yamlのtagsは必須項目です。新しいテストを足したら、まず-Tで対象を絞って1回回します
  • skippedやfilteredのケースは失敗扱いになりません。フックが返す先頭の件数行でpassedが0件なら、フィルタ条件を疑います。testplan.jsonには、除外されたシナリオとその理由が残ります

まとめ

Twisterの結果は、標準出力のログを絞るより、twister.jsonの構造から失敗を引くほうが安定します。実行出力はファイルに逃がし、PostToolUseとPostToolUseFailureの両方に同じスクリプトを登録して、失敗したケースの識別子・理由・ログ末尾だけを返す形です。再実行は-f、締めの全件実行は-fなし、とCLAUDE.mdに書いておけば、ループを一定の読む量で回せます。フック全体の設計はClaude Codeのhooks解説も参考になります。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. code.claude.com/docs/en/hooks-guide
  2. code.claude.com/docs/en/hooks
  3. docs.zephyrproject.org/latest/develop/twister/index.html
  4. docs.zephyrproject.org/latest/develop/test/ztest.html
  5. github.com/zephyrproject-rtos/zephyr/blob/main/scripts/pylib/twister/…
この記事を共有:XはてブLinkedIn