Claude Codeでbatsのシェルスクリプトテスト — 異常系とshfmt整形
シェルスクリプトを実行して確かめるbatsのテストを、Claude Codeに書かせる手順です。異常系の洗い出しと、shfmtでの整形、hookでの自動実行までを扱います。
シェルスクリプトを実際に動かして、終了コードと出力を確かめるテストフレームワークがbats(bats-core)です。Claude Codeにスクリプトを書かせると、正常系は動いても、引数不足や存在しないディレクトリのような異常系が抜けがちです。batsのテストを先に用意し、整形はshfmtに任せる構成にすると、その抜けをClaudeが自分で見つけて直す流れを作れます。
この記事では、小さなバックアップスクリプトを題材に、異常系の洗い出しからbatsテストの書き方、shfmtでの整形、hookによる自動実行までを順に扱います。
batsは何を確かめ、ShellCheckと何が違うか
batsは、Bash 3.2以上向けのTAP互換テストフレームワークです。テストファイルはBashスクリプトに@testという記法を足したもので、中の各コマンドの終了コードが0ならテストは成功します。内部ではBashのerrexit(set -e)が有効になるため、各行が1つのアサーションとして働きます。
ShellCheckやshfmtは、スクリプトを実行せずに読むだけの道具です。batsは実際に実行して、結果を確かめます。3つの役割は重なりません。
| 道具 | 見るもの | 実行するか | 見つけやすい問題 |
|---|---|---|---|
| ShellCheck | 見るもの構文と典型的な落とし穴 | 実行するかしない | 見つけやすい問題クォート漏れ、cdの失敗の握りつぶし |
| shfmt | 見るもの書式 | 実行するかしない | 見つけやすい問題インデントや記法のばらつき、構文エラー |
| bats | 見るもの実行時の挙動 | 実行するかする | 見つけやすい問題終了コードの誤り、メッセージの欠落、後始末の漏れ |
ShellCheckの読み方とhookへの組み込みは、Claude CodeでShellCheckを使う手順で扱っています。静的解析が通っても「引数を忘れたときに何が起きるか」は分かりません。そこを埋めるのがbatsです。
題材のスクリプトと最小のテスト
題材は、ディレクトリをtar.gzに固めるだけのスクリプトです。終了コードをsysexits風に決めておくと、テスト側で期待値を書き分けられます。
#!/usr/bin/env bash
# scripts/backup.sh <src_dir> <dest_dir>
set -euo pipefail
usage() {
echo "usage: backup.sh <src_dir> <dest_dir>" >&2
exit 64
}
[ $# -eq 2 ] || usage
src=$1
dest=$2
[ -d "$src" ] || {
echo "backup.sh: no such directory: $src" >&2
exit 66
}
command -v tar >/dev/null || {
echo "backup.sh: tar not found" >&2
exit 69
}
mkdir -p "$dest"
tar -czf "$dest/backup.tar.gz" -C "$src" .
echo "created $dest/backup.tar.gz"テストはtest/backup.batsに置きます。runは、渡したコマンドを実行して終了コードを$statusに、標準出力と標準エラーを合わせた内容を$outputに入れます。run -64のように数字を付けると、その終了コードでなければテストが失敗します。
#!/usr/bin/env bats
setup() {
SCRIPT="$BATS_TEST_DIRNAME/../scripts/backup.sh"
SRC="$BATS_TEST_TMPDIR/src"
mkdir -p "$SRC"
echo hello >"$SRC/a.txt"
}
@test "creates an archive for a valid directory" {
run -0 "$SCRIPT" "$SRC" "$BATS_TEST_TMPDIR/out"
[ -f "$BATS_TEST_TMPDIR/out/backup.tar.gz" ]
}
@test "prints usage and exits 64 without arguments" {
run -64 "$SCRIPT"
[[ "$output" == usage:* ]]
}
@test "exits 66 when the source directory is missing" {
run -66 "$SCRIPT" "$BATS_TEST_TMPDIR/nothing" "$BATS_TEST_TMPDIR/out"
[[ "$output" == *"no such directory"* ]]
[ ! -e "$BATS_TEST_TMPDIR/out" ]
}$BATS_TEST_TMPDIRはテストごとに用意される一時ディレクトリで、ホームや作業ツリーを汚さずにファイルを作れます。setupは各テストの前に毎回走ります。
実行はディレクトリを指すだけです。
bats test/異常系はClaudeに先に列挙させる
Claudeに「backup.shのテストを書いて」とだけ頼むと、正常系と、思いつきやすい引数なしの1〜2件で終わりがちです。先に異常系の一覧を出させ、人間が確認してから書かせると抜けが減ります。
scripts/backup.sh のテストを test/backup.bats に書きます。
書く前に、次の観点でこのスクリプトが失敗しうる条件を表にしてください。
- 引数の数と中身(不足・過剰・空文字・スペースを含むパス)
- 入力の不在と権限(存在しない・読めない・書き込めない)
- 外部コマンドの欠如(tar が PATH に無い)
- 途中で失敗したときの後始末(中途半端な出力ファイルが残らないか)
表の各行について、期待する終了コードと標準エラーの文言を決めてから、
1行につき1つの @test を書いてください。まだ実装は直さないでください。最後の一文が効きます。テストを先に書かせて実行し、赤になることを確かめてから実装を直す順序にすると、「テストが通るようにテストを書いた」状態を避けられます。再現テストを先に書かせる進め方は、再現テストを先に書いてバグを直す手順と同じ考え方です。
Claudeが洗い出した異常系のうち、外部コマンドの欠如と権限は、そのままでは再現しにくい条件です。次の節で書き方を示します。
再現しにくい異常系の書き方
コマンドが無い環境をPATHで作る
tarが無い状況は、PATHを空のディレクトリに差し替えて作れます。シェルスクリプト自体は絶対パスのBashで起動すれば、envやbashの検索に失敗しません。
@test "exits 69 when tar is not on PATH" {
mkdir -p "$BATS_TEST_TMPDIR/empty-bin"
run -69 env PATH="$BATS_TEST_TMPDIR/empty-bin" /bin/bash "$SCRIPT" "$SRC" "$BATS_TEST_TMPDIR/out"
[[ "$output" == *"tar not found"* ]]
}外部コマンドの代わりに偽物を置きたいときも同じ手です。$BATS_TEST_TMPDIR/binに実行権限付きの小さなスクリプトを置き、PATHの先頭へ足します。
権限エラーは実行ユーザーを選ぶ
書き込み不可のディレクトリを作るchmodは、rootで実行すると効きません。CIのコンテナはrootで走る構成もあるため、条件付きでskipします。skipはテストの途中で呼べて、setupとteardownは飛ばされたテストでも走ります。
@test "fails when the destination is not writable" {
if [ "$(id -u)" -eq 0 ]; then
skip "root ignores file permissions"
fi
mkdir -p "$BATS_TEST_TMPDIR/ro"
chmod 500 "$BATS_TEST_TMPDIR/ro"
run ! "$SCRIPT" "$SRC" "$BATS_TEST_TMPDIR/ro/out"
[[ "$output" == *"Permission denied"* ]]
}run !は「0以外の終了コードで終わること」を確かめる書き方です。原因がmkdirの失敗なので、終了コードはスクリプトが決めたものではなくset -eが返すものになります。具体的な値を固定せず、!で緩めに受けているのはそのためです。
異常系テストで踏みやすい落とし穴
落とし穴
先頭に感嘆符を付けただけのコマンドは失敗を見逃す
! grep error log.txtのように、runを使わず!だけを付けた行は、テストの最後の行にあるときしか失敗が伝わりません。Bashは!の付いたコマンドでset -eを発動しないためです。失敗を確かめたいときはrun ! commandと書きます。
ほかに押さえておく点を並べます。
$outputは標準出力と標準エラーの合算です。標準エラーだけを確かめたいときはrun --separate-stderrを使うと、$outputと$stderrに分かれます。runの追加オプションは、コマンドの前に置きます。runはサブシェルで実行されます。スクリプトが変数を書き換えても、runの後ろには持ち越されません。環境変数の副作用を調べたいテストは、sourceしたうえで関数を直接呼ぶ形に変えます。- パイプは
runに直接つなげません。run cmd1 | cmd2は|が先に解釈されます。run bats_pipe cmd1 \| cmd2と書くと、パイプ全体がrunの中で動きます。 - 空行は
${lines[@]}から落ちます。空行も数えたいときはrun --keep-empty-linesを付けます。 - 失敗したテストの出力は既定では見えません。
bats --print-output-on-failure test/で、失敗時だけ$outputが表示されます。Claudeに失敗の原因を読ませるなら、このオプションを付けておきます。
shfmtで整形してからテストに回す
shfmtは、シェルスクリプトを整形する道具です。Claudeが書いたスクリプトを毎回同じ書式に揃えると、差分レビューで本質的な変更だけが目に入ります。使うオプションは3つで足ります。
| オプション | 動き |
|---|---|
-d | 動き書式が違うときに差分を出して、エラー終了する |
-w | 動き結果を標準出力でなくファイルへ書き込む |
-l | 動き書式が違うファイルの一覧を出して、エラー終了する |
CIで書式崩れを止めたいときはshfmt -d .、手元で直したいときはshfmt -l -w .が、マニュアルに載っている使い方です。Googleのシェルスタイルに近い書式にするなら、shfmt -i 2 -ci -bnが例として挙がっています。
batsのテストファイルを整形するときは、方言の指定が要ります。-ln(--language-dialect)にはbash、posix、mksh、bats、zshが指定でき、既定は自動判定です。.batsファイルは@testの記法を含むので、shfmt -w -ln bats test/backup.batsのように明示しておくと安全です。拡張子.shはposixを意味しますが、有効なshebangがあればそちらが優先されます。#!/usr/bin/env bashを書いたスクリプトでBash固有の記法が構文エラーになる心配はありません。
もう1つ使い道があります。shfmtは、bash -nより網羅的な構文チェックの代わりにもなります。構文エラーはパース時に位置付きで報告されるので、整形のついでに壊れたスクリプトも拾えます。
編集直後にshfmt、完了前にbatsを自動で走らせる
整形と実行を、Claude Codeのhookに任せます。役割分担は次のとおりです。
hookの役割分担
- 1
PostToolUseでshfmt
EditかWriteが成功した直後に、対象の.shと.batsを整形します。 - 2
Stopでbats
Claudeが応答を終えようとしたときに
bats test/を走らせ、失敗なら終了コード2で差し戻します。 - 3
結果をClaudeが読む
stderrがClaudeに返り、Claudeは失敗したテストを直してから、もう一度終わろうとします。
設定は.claude/settings.jsonに置きます。matcherはツール名に対する絞り込みで、Edit|Writeのように|でつなげます。
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/shfmt-on-edit.sh"
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/bats-on-stop.sh"
}
]
}
]
}
}整形側のスクリプトは、標準入力のJSONからtool_input.file_pathを取り出します。
#!/bin/bash
# .claude/hooks/shfmt-on-edit.sh
file=$(jq -r '.tool_input.file_path // empty')
case "$file" in
*.bats) shfmt -w -ln bats "$file" ;;
*.sh) shfmt -w "$file" ;;
esac
exit 0PostToolUseはツールがすでに実行された後に動くため、終了コード2を返しても処理は止まらず、stderrがClaudeに見えるだけです。整形は失敗してもよい作業なので、末尾でexit 0にしています。
テスト側のスクリプトは、失敗したら終了コード2とstderrで返します。
#!/bin/bash
# .claude/hooks/bats-on-stop.sh
input=$(cat)
# 差し戻しの再試行中は走らせない(無限ループ防止)
if [ "$(jq -r '.stop_hook_active' <<<"$input")" = "true" ]; then
exit 0
fi
cd "$CLAUDE_PROJECT_DIR" || exit 0
[ -d test ] || exit 0
if ! out=$(bats --print-output-on-failure test/ 2>&1); then
echo "$out" | tail -n 40 >&2
exit 2
fi
exit 0Stopのhookが終了コード2を返すと、Claudeは応答を終えられず、stderrの内容が理由として渡されます。stop_hook_activeは、Claude Codeがすでにstop hookの結果で作業を続けているときにtrueになる値で、確認しないと解消しない条件で止め続けるおそれがあります。ここでは再試行中は何もしない作りにしたため、差し戻し後の修正でまた別のテストが落ちても、そのターンでは止められません。上限もあり、stop hookが連続8回ターンを継続させると、次の差し戻しは無効になって応答が終わります。上限は環境変数CLAUDE_CODE_STOP_HOOK_BLOCK_CAPで変更できます。ループの挙動は、Stop hookのブロック回数の上限で詳しく扱っています。
テストを走らせる手順を他のhookと使い分けたい場合は、hooksでテストを自動実行する構成が参考になります。CIで失敗したテストをClaudeに直させる運用は、CIのテスト失敗を自動修正する手順にあります。
効かないケースと注意点
- Bash経由の書き込みは整形されません。
Edit|WriteのPostToolUseは、cat > script.sh <<EOFのようにBashツールが書いたファイルでは動きません。確実に整えたいなら、Claudeに「書いたらshfmt -dを実行する」と指示するか、CIのshfmt -d .で止めます。 - batsの導入は別途です。hookは
batsとshfmtがPATHにある前提で動きます。無い環境ではhookが非ブロックのエラーになり、Claudeは何事もなく先へ進みます。確実に止めたいhookには"onFailure": "block"を付けられます(Claude Code v2.1.295以降)。 - 並列実行は別の準備が要ります。
bats -j 4 test/の並列実行にはGNU parallelかrushが必要で、テスト同士がファイルや環境変数を共有していると壊れます。$BATS_TEST_TMPDIRで作業場所を分けておくと、並列にしやすくなります。 - テストを通すためにテストを緩める変更が起きえます。期待値の終了コードを実装に合わせて書き換える、という直し方です。CLAUDE.mdに「失敗したbatsのテストは、仕様が変わったと明記された場合を除き、実装側を直す」と書いておくと、その方向への誘導になります。
CLAUDE.mdへの記述は、次の程度で足ります。
## シェルスクリプト
- scripts/ 配下のスクリプトには、test/ に bats のテストを置く
- 異常系(引数不足・入力不在・権限・外部コマンド欠如)を正常系と同じ数だけ書く
- 変更後は `shfmt -d scripts test` と `bats --print-output-on-failure test/` を実行する
- 失敗したテストの期待値は、仕様変更の指示が無い限り変えないまとめ
batsは、ShellCheckやshfmtの届かない「実行したらどうなるか」を確かめる道具です。Claudeに任せるときは、異常系を表で先に洗い出させ、テストを先に書いて赤を確認してから実装を直させる順序にすると、抜けと自己都合のテスト緩和を防げます。
自動化は、PostToolUseでshfmt、Stopでbatsという分担が軽く収まります。Stopのhookは再試行中に走らせない作りと8回の上限があるため、最後の確認はCIにも置いておくのが堅実です。権限テストはrootで効かない点と、runの外の!が失敗を見逃す点を知っておくだけで、書き直しの回数が減ります。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。