最小テスト入門|入出力比較・期待 exit

テスト&品質

この記事の狙い

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:失敗が検知できない。→ 失敗の定義とコードを記事内で宣言
  • 環境依存の比較:末尾改行・ロケール差でズレる。→ printfLC_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 系は mktempsed -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 へ、が推奨です。

参考リンク

Bash玄

はじめまして!Bash玄です。

エンジニアとしてシステム運用に携わる中で、手作業の多さに限界を感じ、Bashスクリプトを活用して業務を効率化したのがきっかけで、この道に入りました。「手作業は負け」「スクリプトはシンプルに」をモットーに、誰でも実践できるBashスクリプトの書き方を発信しています。

このサイトでは、Bashの基礎から実践的なスクリプト作成まで、初心者でもわかりやすく解説しています。少しでも「Bashって便利だな」と思ってもらえたら嬉しいです!

# 好きなこと
- シンプルなコードを書くこと
- コマンドラインを快適にカスタマイズすること
- 自動化で時間を生み出すこと

# このサイトを読んでほしい人
- Bashに興味があるけど、何から始めればいいかわからない人
- 定型業務を自動化したい人
- 効率よくターミナルを使いこなしたい人

Bashの世界に一歩踏み出して、一緒に「Bash道」を極めていきましょう!

Bash玄をフォローする