この記事の狙い
Bash スクリプトを手元で即チェックできる、最小限のテスト手法(入出力比較と終了コードの検証)を身につけます。
フレームワークは必須ではありません。まずはコピペで動く最小テストから始め、必要があれば bats-core へ広げます。
前提と対象
- Bash 4 以上。Linux / macOS いずれも可(GNU ユーザーランド想定、BSD 系は
sed/mktempの差異に注意)。 diff,printf,mktempを使用します。あればshellcheck/shfmtも活用します。- POSIX 互換よりも、実務での再現性と簡便さを優先します。
TL;DR(最小実装・コピペ可)
下は「1引数を大文字化して出力し、引数が空なら失敗する」スクリプトと、その最小テストです。
# scripts/upper.sh
#!/usr/bin/env bash
set -Eeuo pipefail
usage() { printf 'Usage: %s STRING\n' "${0##*/}" >&2; }
main() {
local s="${1-}"
[[ -n "$s" ]] || { usage; exit 2; }
printf '%s\n' "$s" | tr '[:lower:]' '[:upper:]'
}
main "$@"
# test/run.sh (最小テスト:入出力比較+期待 exit)
#!/usr/bin/env bash
set -Eeuo pipefail
proj_root="$(cd "$(dirname "${BASH_SOURCE[0]}")"/.. && pwd)"
script="$proj_root/scripts/upper.sh"
pass() { printf 'PASS %s\n' "$1"; }
fail() { printf 'FAIL %s\n' "$1"; exit 1; }
# 1) 正常系:出力比較
actual="$( "$script" 'hello' )" || { fail "normal run exited nonzero"; }
expected='HELLO'
diff <(printf '%s\n' "$expected") <(printf '%s\n' "$actual") >/dev/null || {
printf '--- expected\n+++ actual\n'; diff -u <(printf '%s\n' "$expected") <(printf '%s\n' "$actual") || true
fail "stdout mismatch"
}
pass "stdout equals expected"
# 2) 異常系:引数不足 → 2 で終了し、エラーメッセージは標準エラーへ
set +e
out="$("$script" 2>err.txt)"; status=$?
set -e
[ "$status" -eq 2 ] || fail "exit code should be 2, got $status"
grep -q 'Usage:' err.txt || fail "stderr should contain Usage:"
pass "invalid args exit code & stderr"
使い方:
chmod +x scripts/upper.sh test/run.sh
test/run.sh
設計の要点(原則)
テストは大事なところだけを最小コストで担保します。
要点は次の3つに絞ります。
- 期待する出力を固定して
diff比較。差分は人間にわかる形式(-u)で出す。 - 終了コードを仕様化して検証(成功=0、想定エラー=2 等)。
- 標準出力/標準エラーを分離して検査(使い分けは設計の質にも直結)。
ステップ実装(分解解説)
段階1:最小の骨格
- スクリプトは
set -Eeuo pipefailを基本に、失敗を早く露出させます。 usageを用意し、引数不足は 2 で終了など「失敗の定義」を明文化。
段階2:入出力比較
- 期待出力はその場生成(プロセス置換
<( … ))でよい。ファイル固定でも可。 - 差分は
diff -uで出すとレビューが速い。
段階3:終了コード検証
set +e一時解除で非ゼロ終了を捕捉し、直後にset -e復帰。- 成功/失敗の境界ケースを1つずつでもいいから固定化。
段階4:stderr と stdout の切り分け
2>err.txtのようにエラー出力を別ファイルに。- スクリプト側はユーザー向けメッセージは stderr に送る(
>&2)。
失敗しやすい点(アンチパターン)
- 標準出力と標準エラーを混在:パイプ先のコマンドが誤って解析する。→ メッセージは stderr。
- 終了コードが常に 0:失敗が検知できない。→ 失敗の定義とコードを記事内で宣言。
- 環境依存の比較:末尾改行・ロケール差でズレる。→
printfとLC_ALL=Cを活用。
テストと品質
“最小品質”コマンド
# 静的解析(opt-in)
command -v shellcheck >/dev/null && shellcheck scripts/upper.sh || echo "shellcheck not found"
# フォーマット差分チェック(導入推奨)
command -v shfmt >/dev/null && shfmt -d . || echo "shfmt not found"
bats-core(任意)の最小ケース
# test/upper.bats
#!/usr/bin/env bats
setup() { script="$BATS_TEST_DIRNAME/../scripts/upper.sh"; }
@test "prints uppercased string" {
run "$script" hello
[ "$status" -eq 0 ]
[ "$output" = "HELLO" ]
}
@test "requires an argument" {
run "$script"
[ "$status" -eq 2 ]
[[ "$error" =~ Usage: ]]
}
実行:
bats test/upper.bats
GitHub Actions 最小ジョブ(任意)
# .github/workflows/shell.yml
name: shell
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Lint & format
run: |
sudo apt-get update && sudo apt-get install -y shellcheck shfmt
shellcheck scripts/*.sh
shfmt -d .
- name: Minimal tests
run: bash test/run.sh
運用設計(実務)
- テストは高速であることが正義。実行時間が伸びると回されなくなる。
- バッチなら**代表入力(スモーク)**だけでも良いので毎回回す。
- 失敗時のアーティファクト(
actual.txt,err.txt)をそのまま残すと現場解析が速い。
互換性と移植性
- macOS/BSD 系は
mktempやsed -iの挙動差に注意。本文ではmktemp -dとプロセス置換に寄せて差分を最小化。 - BusyBox 環境(Alpine など)ではツール挙動が軽量化されている場合あり。CI で 1 ジョブ回すと安心。
セキュリティと安全設計
- 期待出力の生成時もクォート徹底(
"${var}")。 - テスト内の一時ファイルは
mktempを使い、trap で削除。 - 外部コマンド実行は意図した引数のみ(テストの中で
evalを使わない)。
パフォーマンスの勘所(短く)
- 大きな入力の比較は
cmp -s(高速)で十分な場面も多い。 - ただし失敗時は
diff -uの人間可読性を優先。
参考実装(拡張版・コピペ可)
「期待ファイル」を置く型。失敗時に差分を保存します。
# test/run_ext.sh
#!/usr/bin/env bash
set -Eeuo pipefail
root="$(cd "$(dirname "${BASH_SOURCE[0]}")"/.. && pwd)"
script="$root/scripts/upper.sh"
tmpdir="$(mktemp -d)"; trap 'rm -rf "$tmpdir"' EXIT
run_case() {
local name="$1" input="$2" expect_file="$3" expect_status="${4-0}"
local act="$tmpdir/$name.actual" dif="$tmpdir/$name.diff"
set +e
out="$("$script" $input 2>"$tmpdir/$name.err")"; status=$?
set -e
[ "$status" -eq "$expect_status" ] || { printf 'FAIL %s: status %s\n' "$name" "$status"; exit 1; }
printf '%s\n' "$out" >"$act"
if ! diff -u "$expect_file" "$act" >"$dif"; then
printf 'FAIL %s: see %s\n' "$name" "$dif"; exit 1;
fi
printf 'PASS %s\n' "$name"
}
run_case ok "'hello'" "$root/test/fixtures/HELLO.txt" 0
run_case noarg "" "$root/test/fixtures/EMPTY.txt" 2
よくある質問(Q&A)
Q1. 何をテストすれば十分ですか?
A. 「代表入力に対する出力」と「代表的な失敗の exit」をまず1件ずつ。追加はバグを踏んだら同種を 1 ケース増やす運用で十分に強くなります。
Q2. set -e がテストを壊すことは?
A. 非ゼロを捕捉したい区間だけ set +e → 検証 → set -e で復帰します。記事のサンプル通り区間限定で扱えば安全です。
Q3. bats-core は必須ですか?
A. 必須ではありません。最小テストで習慣化 → 規模が増えたら bats へ、が推奨です。
