iOS のテストが CI で落ちたとき、いちばん時間を溶かすのは「なぜ落ちたか」ではなく「どこを見れば落ちた理由が書いてあるか」を探す数分だと感じています。Xcode の GUI でテストナビゲータを開けば失敗箇所は分かりますが、CI のログには大量の行が流れ、手元には .xcresult というバンドルだけが残ります。このバンドルは中身が見えず、ダブルクリックで Xcode が開くのを待つしかありません。
私は壁紙系や癒し系の iOS / Android アプリを 2014 年から個人で開発していて、App Store と Google Play を合わせた累計ダウンロードは 5,000 万を超えました。2026 年 5 月からは 6 アプリを並行で StoreKit 2 へ移行しています。移行のたびに課金まわりのテストがまとめて落ち、.xcresult を一つずつ Xcode で開いて確認する作業に半日近く奪われたことがありました。この「失敗の中身を取り出す」工程こそ、Claude Code に任せて一気に短縮できる部分です。
ところが厄介なことに、Xcode 16 で xcresulttool のレガシー JSON 出力が非推奨になり、これまで多くの記事やスクリプトが前提にしていた xcresulttool get --format json がそのままでは使えなくなりました。ここでは新しい test-results 系のサブコマンドを起点に、失敗テストだけを抜き出して Claude Code に渡し、原因の仮説と修正候補までを返させる実装を組み立てていきます。
.xcresult の中に何が入っているのか
.xcresult は単なるログファイルではなく、テスト結果・コードカバレッジ・スクリーンショットなどの添付ファイル・ビルド診断をまとめた SQLite ベースのバンドルです。Finder では一つのファイルに見えますが、実体はディレクトリで、中を直接読むことは想定されていません。読み出しには xcrun xcresulttool を使うのが公式の方法です。
ここで最初の落とし穴があります。Xcode 15 までは次のコマンドでルートオブジェクトを取得し、そこから ID を辿ってテスト結果を掘っていく方式が一般的でした。
# Xcode 15 までの旧方式(Xcode 16 で非推奨の警告が出る)
xcrun xcresulttool get --path Result.xcresult --format json
このコマンドは Xcode 16 でも当面動きますが、--legacy を付けないと非推奨の警告が出るようになり、出力構造も将来的に保証されません。何より、このルート JSON は階層が深く、テスト一覧にたどり着くまでに actions → actionResult → testsRef の id を取り出して再度 get を呼ぶ、という多段の参照が必要でした。スクリプトが壊れやすく、Claude Code に渡すにも前処理が重すぎます。
旧方式と新方式の違いを Before / After で見る
Xcode 16 では、テスト結果に特化した test-results サブコマンドが追加されました。これが今回の主役です。多段参照なしに、テストの集計と個別結果をフラットな JSON で直接取得できます。
# Before: ルートを取得して id を辿る(参照を2〜3回繰り返す)
ROOT=$(xcrun xcresulttool get --legacy --path Result.xcresult --format json)
TESTS_REF=$(echo "$ROOT" | jq -r '.actions._values[0].actionResult.testsRef.id._value')
xcrun xcresulttool get --legacy --path Result.xcresult --id "$TESTS_REF" --format json
# → さらにネストを辿らないと個別テストのアサーション文に届かない
# After: テスト結果を直接フラットに取得(Xcode 16+)
# サマリ(全体の合否・件数・実行時間)
xcrun xcresulttool get test-results summary --path Result.xcresult
# 個別テストの一覧(失敗メッセージ・ファイル行番号まで含む)
xcrun xcresulttool get test-results tests --path Result.xcresult
test-results tests の出力は、テストプラン → テストバンドル → テストスイート → 個別テストという木構造を持ち、各ノードに result(Passed / Failed など)が入っています。失敗したテストのノードには、アサーションの失敗メッセージとソースの参照(ファイルと行)が含まれます。旧方式で何度も get を呼んでいた部分が、実質 1 コマンドで済むようになったのが大きな違いです。
失敗だけを最小 JSON に整形する
Claude Code は長い JSON でも読めますが、テスト全体の木をそのまま渡すと、合格したテストの情報までトークンを消費してしまいます。私は「失敗ノードだけを、テスト名・失敗メッセージ・ファイル行に絞った配列」へ変換してから渡すようにしています。トークンを節約できるだけでなく、Claude Code の注意が本当に見るべき箇所に集中します。
test-results tests の木を再帰的に降りて、result が Failed のリーフだけを拾う jq を用意します。
#!/usr/bin/env bash
# extract-failures.sh — .xcresult から失敗テストだけを最小 JSON で出力する
set -euo pipefail
XCRESULT="${1:?usage: extract-failures.sh <path-to-.xcresult>}"
xcrun xcresulttool get test-results tests --path "$XCRESULT" \
| jq '
# テストノードを再帰的に平坦化するための関数
def leaves:
if has("children") and (.children | length > 0)
then .children[] | leaves
else . end;
[ .testNodes[]? | leaves ]
| map(select(.result == "Failed"))
| map({
name: .name,
identifier: (.nodeIdentifier // .name),
# 子ノードに格納される失敗詳細(メッセージ・ソース行)を集約
failures: [ (.children[]? | select(.nodeType == "Failure Message")
| .name) ]
})
'
実際の test-results tests のキー名は Xcode のマイナーバージョンで揺れることがあります。私の手元(Xcode 16 系)では失敗の詳細が Failure Message という nodeType の子ノードに入っていましたが、環境によっては result がトップに来る構造もあります。そこで、まず生の出力を一度 jq 'keys' や jq '.testNodes[0]' で確認し、構造を見てから抽出スクリプトを微調整するのが安全です。ここを決め打ちにすると、ある日 Xcode を更新した瞬間に空配列が返るようになって気づけません。
このスクリプトの出力は、おおむね次のような短い JSON になります。
[
{
"name": "testPurchaseRestoresEntitlement()",
"identifier": "StoreKitTests/testPurchaseRestoresEntitlement()",
"failures": [
"XCTAssertEqual failed: (\"none\") is not equal to (\"pro\") - 復元後に entitlement が pro になっていない (StoreKitTests.swift:84)"
]
}
]
ここまで絞れば、Claude Code に渡す情報量は数百トークンに収まります。合格テストが 200 件あっても、失敗が 3 件なら 3 件分しか渡りません。
Claude Code に原因と修正候補を出させるスラッシュコマンド
整形した失敗 JSON を Claude Code に渡し、原因の仮説と該当コードの修正候補を返させます。私は毎回同じ指示を打ち込むのが面倒なので、プロジェクト直下の .claude/commands/ にスラッシュコマンドとして保存しています。
---
description: .xcresult の失敗テストを解析し、原因仮説と修正候補を提示する
allowed-tools: Bash(./scripts/extract-failures.sh:*), Read, Grep
---
直近のテスト失敗を解析してください。
まず失敗テストを取得します:
!`./scripts/extract-failures.sh "$1"`
上の JSON の各失敗について、次の順で進めてください。
1. 失敗メッセージから、何が期待値で何が実測値だったかを 1 行で要約する
2. ファイル行番号を頼りに該当テストと、テストが叩いている実装コードを Read で開く
3. 「フレークの可能性が高い失敗」か「実バグの可能性が高い失敗」かを判定し、根拠を述べる
4. 実バグと判断した場合のみ、最小の修正パッチ案を diff 形式で提示する
確証のない推測は「仮説」と明示し、断定しないでください。
$1 には .xcresult のパスを渡します。! で始まる行は Claude Code がコマンドを実行し、その出力をプロンプトに埋め込む記法です。これで /triage-tests ./build/Result.xcresult と打つだけで、失敗の要約から修正候補までが一度に返ってきます。
ここで意識しているのは、Claude Code に「フレークか実バグか」を最初に切り分けさせることです。StoreKit のテストはサンドボックスのレスポンス遅延で稀に落ちることがあり、それを実バグとして修正しに行くと、本来正しいコードを壊しかねません。私はこの判定を入れてから、修正を当てる前の手戻りが目に見えて減りました。複数アプリを一人で回す前提なら、修正提案より先にこの切り分けを必ず挟むことを強くお勧めします。確証のない失敗に手を入れるコストは、判定を一段かませるコストよりずっと高いというのが、個人開発を続けてきた私の実感です。
CI で失敗したときに自動でトリアージする
ローカルでは手動でスラッシュコマンドを叩けますが、CI(Xcode Cloud や GitHub Actions)でテストが落ちたときは、その場に人がいません。私は CI のテストステップに -resultBundlePath を必ず指定して .xcresult を確実に残し、失敗時だけ Claude Code のヘッドレス実行でトリアージを走らせています。
# CI のテスト実行(結果バンドルのパスを固定する)
xcodebuild test \
-scheme MyWallpaperApp \
-destination 'platform=iOS Simulator,name=iPhone 16,OS=latest' \
-resultBundlePath "$PWD/build/Result.xcresult" \
| xcbeautify
# 直前のテストが失敗したときだけトリアージを実行
if [ "${PIPESTATUS[0]}" -ne 0 ]; then
./scripts/extract-failures.sh "$PWD/build/Result.xcresult" > build/failures.json
claude -p "build/failures.json の各失敗について、フレークか実バグかを判定し要約せよ" \
--allowedTools "Read,Grep" \
> build/triage.md
fi
claude -p は Claude Code の非対話(ヘッドレス)実行です。出力を triage.md に落としておけば、CI のアーティファクトとして添付したり、Slack に流したりできます。朝、通知を見れば「夜間 CI で落ちた 2 件のうち 1 件はフレーク、1 件は復元処理の実バグ」という粒度で把握できる状態になります。
さらに踏み込むなら、Claude Code の Stop フックを使い、ローカルでテストエージェントが停止したタイミングで自動的に .xcresult を解析させる構成も組めます。フックは ~/.claude/settings.json か プロジェクトの .claude/settings.json に定義します。
{
"hooks": {
"Stop": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "test -f build/Result.xcresult && ./scripts/extract-failures.sh build/Result.xcresult > build/failures.json || true"
}
]
}
]
}
}
このフックは「停止のたびに失敗を抽出してファイルに残す」だけの軽いものです。重い解析をフックに詰め込むと停止が遅くなるので、抽出までをフックで行い、解析は人が triage.md を見たいときに走らせる、という分担にしています。
ハマりやすいポイントと、私の回避策
実運用でつまずいた点をいくつか共有します。どれも一度はまると原因が見えにくいものです。
第一に、-resultBundlePath に既存のパスを指定するとエラーになります。.xcresult は上書きできず、同じパスが残っていると xcodebuild が失敗します。CI ではテスト実行前に rm -rf build/Result.xcresult を必ず入れています。ローカルでも、前回の結果が残ったまま新しい結果を期待して空振りする、というのを何度かやりました。
第二に、複数の -destination を指定して並列実行すると、.xcresult 内に複数のテスト実行が束ねられます。test-results summary は全体を合算しますが、どのシミュレータで落ちたかを知りたい場合は tests 側のノードに含まれるデバイス情報まで見る必要があります。私は CI ではまず単一 destination に固定し、再現性を確保してから並列化するようにしています。
第三に、jq の抽出を構造決め打ちで書くと、Xcode のマイナーアップデートで静かに壊れます。前述の通り、生 JSON の構造を一度確認してからスクリプトを書くのが結局いちばん速い、というのが個人開発で何度もアップデートを経た結論です。extract-failures.sh の冒頭に、失敗が 0 件で返ったら「本当にテストが全部通ったのか、抽出が壊れたのか」を疑うコメントを書き残しています。
第四に、Claude Code に渡す前のトークン削減を怠ると、添付スクリーンショットのメタデータまで JSON に混ざってコストが膨らみます。.xcresult には UI テストのスクリーンショットが添付されることがあり、これらの参照情報は失敗解析にはほぼ不要です。失敗ノードのテキストだけに絞る今回の整形は、トークンと注意の両方を節約する意味があります。
次の一歩
まずは手元の落ちているテストで xcrun xcresulttool get test-results tests --path <あなたの.xcresult> を一度だけ実行し、出力構造を自分の目で確認してください。そのうえで extract-failures.sh のキー名を自分の Xcode に合わせて微調整すれば、その日から失敗解析が 1 コマンドになります。構造を一度見ておくことが、将来のアップデートで壊れたときに最短で気づくための保険になります。
同じように複数アプリを一人で回している方の役に立てば嬉しいです。お読みいただきありがとうございました。