
こんにちは、ソフトウェアエンジニアの渡邉(匠)です。「カミナシ 設備保全」の開発に携わっています。
Claude CodeのSkills(以下スキル)を使い、約2週間で40,000行超のAPIシナリオテストを書き切りました。最初のスキルは粗削りでしたが、テストを量産する中で繰り返し改善した結果、後半は「スキル実行 → レビュー → マージ」のサイクルだけで回せるようになりました。
この記事では、スキルをどう設計し、どう育てたかを中心にお伝えします。
背景
APIの動作保証にシナリオテストツール runn を使っていました。 サービス成長に伴うAPIの増加により、当初のテスト構成では運用が回らなくなってきました。1ファイルあたりのステップ数が膨らみ、どこで何を検証しているのか判別が難しい状態に。テストの追加・修正が心理的にも技術的にもハードルの高い「辛い作業」へと変わっていました。
そこで、テストを以下の2種類に整理し直すことにしました。
- API単位テスト: 1ファイル = 1API。正常系・異常系を網羅的にカバーする
- ユースケースシナリオ: 複数APIにまたがる業務フローのE2Eテスト
対象APIは数十個以上。正常系・異常系を含めると数百パターンに及ぶテストをClaude Codeで生成しました。
テスト生成の仕組み
テスト種別ごとにスキルを用意しましたが、もっとも多用したAPI単位テストのフローを紹介します。
[Step 1] 情報収集
API定義やバリデーションロジックなど、関連コードを読み込む
↓
[Step 2] テストケース洗い出し(ホワイトボックス)
コードからエラーハンドリングを網羅的に抽出し、
正常系・バリデーション・権限・存在チェックなどのケース一覧にまとめる
↓
[Step 3] ユーザー承認
テストケース一覧をMarkdownで提示し、過不足を確認してもらう
↓
[Step 4] サブエージェントに実装を委譲
API単位で分割して渡す
テストファイル生成 → 実行 → エラー修正を自律的に繰り返す
↓
[Step 5] セルフレビュー
チェックリスト(ファイル形式・テスト構造・命名規則)で品質確認
ここで重要なのはStep 4の委譲単位です。「サービス全体を一度に」ではなく「API単位で分割して」サブエージェントに渡すルールにしました。一度に渡すとコンテキストが膨れ、テストの品質が落ちるためです。
スキルを育てる
スキルは最初から完成していたわけではなく、テストの量産を通じて段階的に改善していきました。
Phase 1: 基本構成を固める
最初の数APIで、テストの基本構造と命名規則を決めました。この時点のスキルはシンプルで、生成フローの大枠だけを定義したものです。
Phase 2: runnスキルの切り出し
複数サービスのテストを書いていく中で、Claudeが繰り返し同じrunnの記法ミスをするパターンが見えてきました。テスト生成スキルの中にrunn記法の注意点を書き足していたのですが、量が膨れてきたので、runnの記法ナレッジだけを独立スキルとして切り出しました。
Phase 3: サブエージェントの導入と構造の再設計
1セッションで情報収集から実装・実行まですべてをこなす構成にしていたところ、コンテキストが溢れて途中で止まるケースが頻発しました。
そこでスキルの役割を分離しました。
- スキル本体: 情報収集 → テストケース設計 → ユーザー承認
- サブエージェント: ファイル生成 → テスト実行 → エラー修正
それぞれが小さなコンテキストで動くため、テスト品質を維持しつつ、一度に生成できる量が増えました。
同時にスキルファイルの見通しが悪くなったため、以下の整理もおこないました。
- テンプレートやチェックリストを参照ファイルに外出し
- スキル本体(SKILL.md)はフローの記述に集中
ぶつかった壁
runnの記法をClaudeが知らない
runnはYAMLベースでテストを書くことができる柔軟なツールですが、独特の記法がLLMにとって馴染みが薄く、頻繁にミスが発生しました。
bindとincludeの実行順序
# ❌ NG: bindはincludeの後に評価されるので、faker値をincludeに渡せない steps: create: bind: name: '{{ faker.LetterN(10) }}' include: path: ./service/Create.yaml vars: name: '{{ name }}' # ← この時点でnameは未定義 # ⭕ OK: 別ステップで先にbindする steps: prepare: bind: name: '{{ faker.LetterN(10) }}' create: include: path: ./service/Create.yaml vars: name: '{{ name }}' # ← prepareで定義済み
testフィールドのアサーション記法
runnの test: では expr-lang の記法が使えますが、Claudeはこれを知らず、存在しない関数を書いてしまうケースが多発しました。
# ❌ NG: .startsWith()や.endsWith()はexpr-langに存在しない test: | current.res.body.name.startsWith("テスト") && current.res.body.file.endsWith(".csv") # ⭕ OK: expr-langの関数を使う test: | hasPrefix(current.res.body.name, "テスト") && hasSuffix(current.res.body.file, ".csv")
こうした「LLMが知らないrunn固有の記法」をNGパターン/OKパターンとしてrunnのスキルに蓄積していきました。
最大の壁: runnのエラー出力をClaudeが読めない
これがもっとも苦労した課題です。
runnのテスト失敗時、assertionの条件式はツリー構造で展開されます。&& や || で条件を組むと、条件ごとに二分木のようにネストが深くなり、どの項目で失敗したかの判別が非常に難しくなります。
Condition: res.body.item.id != nil && res.body.item.name == "foo" && res.body.item.status == "active" && res.body.item.note == nil │ ├── ... && ... && ... == "active" => false │ ├── ... && ... == "foo" => true │ │ ├── res.body.item.id != nil => true │ │ └── res.body.item.name == "foo" => true │ └── res.body.item.status == "active" => false ← ここが原因 │ ├── res.body.item.status => "draft" │ └── "active" └── res.body.item.note == nil => [not evaluated]
人なら status == "active" が false だとわかります。しかしClaudeにとっては、ツリーの中から => false の葉を探す作業になります。実際のテストでは条件が6〜10個になることも多く、ネストはさらに深くなります。
結果として、Claudeがツリーを読み解けずに、テストを何度も再実行する悪循環に陥ることがありました。
解決策: assertionをステップに分離する
テスト設計側で工夫しました。1つの test: に全条件を詰め込むのではなく、フィールドごとに独立したステップに分離します。
# ❌ Before: 1つのtestに全条件を&&で連結 test_1: test: | current.res.body.item.id != nil && current.res.body.item.name == "foo" && current.res.body.item.status == "active" && current.res.body.item.note == nil
# ⭕ After: フィールドごとにverifyステップを分離 test_1: bind: test_1_res: current.res test: | current.res.status == 200 verify_1_id: desc: "test_1: id" test: test_1_res.body.item.id != nil verify_1_name: desc: "test_1: name" test: test_1_res.body.item.name == "foo" verify_1_status: desc: "test_1: status" test: test_1_res.body.item.status == "active" verify_1_note_nil: desc: "test_1: note == nil" test: test_1_res.body.item.note == nil
この設計により、3つの問題が一度に解消しました。
- エラー出力がシンプルになる: 1ステップ = 1条件なので、ツリーのネストが発生しない
- ステップ名で失敗箇所がわかる:
verify_1_status ... FAILと表示され、一目瞭然 - Claudeが自律的に修正できる: 失敗したverifyステップ名から修正箇所を正確に特定できる
人がテストを手動で作成する場合、記述量が増えるため、あえてアサーションを分割しない選択をとることが多いと思います。しかし、LLMにテストを書かせることを考えると、記述の煩雑さ以上に修正とデバッグを自律的に実行できることに大きな価値が生まれました。
振り返り
うまくいったこと
- 段階的改善: 最初から完璧を目指さず、数サービス実装してからパターンを抽出するサイクルが効果的だった
- 委譲単位の制御: API単位でサブエージェントに渡すことで、コンテキスト溢れを防ぎテスト品質を維持できた
- 暗黙知の明文化: Claudeが間違えるたびに「なぜそれがダメなのか」を言語化してスキルに書き足していった。Claudeが間違える=ルールが暗黙的だった、ということでもあり、LLMが「暗黙知の検出器」として機能した。結果としてスキルは単なる指示書ではなく、テスト設計のナレッジベースになった
実践して見えた次への学び
- 初期設計のコスト: 最初の数サービスのテストの実装は試行錯誤が多かった。とはいえ実際にやってみないと見えないパターンも多い
- サブエージェントのモデル選択: Sonnetを使っていたが、スキルの指示に従わないケースや同じミスを繰り返すケースがあり、チェックリスト強化やプロンプト調整で対処が必要だった
まとめ
約2週間でYAMLの行数ベースで40,000行超のAPIシナリオテストを、Claude Code + Skillsで生成しました。数十個のAPIに対して、API単位テスト・シナリオテストあわせて100ファイル以上です。
この取り組みから得たポイントは4つです。
- スキルは最初から完成形を目指さず、実装しながら段階的に改善する
- LLMが間違えるポイントをNGパターン/OKパターンとしてスキルに蓄積する
- LLMにとって読みにくいエラー出力は、テスト設計側の工夫で回避する
- スキルの改善プロセスは、暗黙知を形式知に変換するプロセスそのもの
テストの量産だけでなく、テスト設計のナレッジが明文化されたことが、この取り組みの最大の成果でした。
最後に、カミナシではソフトウェアエンジニアを募集しています。ご興味をお持ちの方は、採用ページからのご連絡お待ちしております。