2026年5月30日

2026年5月30日

シェルスクリプトのテストをbatsで書く方法:導入からコマンドの差し替え・CIまで

シェルスクリプトのテストは、bats(Bash Automated Testing System、現在の本流は bats-core)を使うと、Bash の文法のまま「この入力ならこの出力・終了コードになる」と書けます。この記事では、bats の導入、テストの基本形、外部コマンド(wp など)を偽物に差し替えるテスト、CI での実行までを、WordPress のバックアップスクリプトを題材に順に説明します。

batsとは何か、何をテストするのか

bats は .bats という拡張子のファイルに @test "名前" { ... } というブロックを並べ、各ブロックを独立したテストケースとして実行するツールです。ブロック内のコマンドが失敗した時点でそのテストは失敗扱いになり、最後に成功・失敗の件数が表示されます。

シェルスクリプトでテストする価値が高いのは、次のような部分です。

  • 引数の検証(必須引数が空のときにエラーで止まるか)
  • ファイル名や日付の組み立てなど、純粋な文字列処理
  • 外部コマンドに正しい引数を渡しているか(本物の wp db export を動かさずに確認する)
  • 異常時の終了コード(cron や CI から呼ばれたときに失敗を検知できるか)

逆に、本番サーバーへの接続や実データベースの書き換えをテストで直接行うのは避けます。テストは何度でも安全に流せることが前提なので、外部への副作用は後述の「差し替え」で閉じ込めます。

導入:プロジェクトに同梱する方法

bats はパッケージマネージャー(Homebrew の bats-core、npm の bats、各ディストリビューションの bats パッケージ)でも入りますが、ディストリビューションのパッケージは版が古いことがあります(2026年10月現在も、配布元によって収録バージョンに差があります)。チーム内や CI で同じ版を使うには、git のサブモジュールとしてプロジェクトに含める方法が扱いやすいです。

git submodule add https://github.com/bats-core/bats-core.git test/bats
git submodule add https://github.com/bats-core/bats-support.git test/test_helper/bats-support
git submodule add https://github.com/bats-core/bats-assert.git test/test_helper/bats-assert

./test/bats/bin/bats --version

bats-support と bats-assert は、assert_success や assert_output といった読みやすい検証用関数を提供する補助ライブラリです。なくても [ "$status" -eq 0 ] のように書けますが、失敗時に「期待値と実際の値」を並べて表示してくれるので、原因の特定が速くなります。

ディレクトリ構成は次のようにしておくと、以降の例がそのまま使えます。

project/
├── backup.sh
└── test/
    ├── backup.bats
    ├── bats/
    └── test_helper/
        ├── bats-support/
        └── bats-assert/

テストしやすいスクリプトの形にする

テスト対象のスクリプトは、処理を関数に分け、直接実行されたときだけ main を呼ぶ形にします。こうすると、テストからは source で関数だけを読み込めます。

#!/usr/bin/env bash
# backup.sh : WordPress の DB をファイルに書き出す

backup_filename() {
  local site="$1" date="$2"
  if [[ -z "$site" ]]; then
    echo "site name is required" >&2
    return 2
  fi
  printf '%s-%s.sql\n' "$site" "$date"
}

main() {
  local site="${1:-}"
  local dest="${BACKUP_DIR:-/var/backups/wp}"
  local file
  file="$(backup_filename "$site" "$(date +%Y%m%d)")" || return $?
  mkdir -p "$dest"
  wp db export "$dest/$file" --quiet
  echo "saved: $dest/$file"
}

if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
  set -euo pipefail
  main "$@"
fi

set -euo pipefail をファイル先頭ではなく実行時の分岐の中に置いているのがポイントです。先頭に書くと、テストで source した瞬間に bats 側のシェルにも -u などが効いてしまい、bats の内部処理と干渉することがあります。

基本のテストを書く

#!/usr/bin/env bats
# test/backup.bats

bats_require_minimum_version 1.5.0

setup() {
  load 'test_helper/bats-support/load'
  load 'test_helper/bats-assert/load'
  source "$BATS_TEST_DIRNAME/../backup.sh"
}

@test "ファイル名はサイト名と日付から作る" {
  run backup_filename example 20261018
  assert_success
  assert_output 'example-20261018.sql'
}

@test "サイト名が空なら終了コード2で止まる" {
  run -2 backup_filename "" 20261018
  assert_output --partial 'required'
}

setup は各テストの前に毎回実行されます。run はコマンドを実行し、終了コードを $status、出力を $output(行ごとの配列は $lines)に入れます。run 自体はコマンドが失敗してもテストを失敗させないため、その後に assert_success などで結果を検証する、という流れになります。標準エラー出力も既定では $output に混ざるので、エラーメッセージの確認もこの形で書けます。

run -2 のように期待する終了コードを run に渡す書き方は、比較的新しい bats-core(1.5.0 以降)の機能です。ファイル先頭の bats_require_minimum_version 1.5.0 は「この版以上を前提にする」という宣言で、これがないと警告が出る版があります。

実行は次のとおりです。

./test/bats/bin/bats test/

外部コマンドを偽物に差し替える

main は wp db export を呼ぶので、そのままテストすると本物のデータベースに触れてしまいます。そこで、テスト用の一時ディレクトリに同名の偽コマンド(スタブ)を置き、PATH の先頭に追加します。

@test "main は wp db export に保存先を渡す" {
  mkdir -p "$BATS_TEST_TMPDIR/bin"
  export WP_CALLS="$BATS_TEST_TMPDIR/wp_calls.log"
  cat > "$BATS_TEST_TMPDIR/bin/wp" <<'EOF'
#!/usr/bin/env bash
echo "$*" >> "$WP_CALLS"
EOF
  chmod +x "$BATS_TEST_TMPDIR/bin/wp"
  PATH="$BATS_TEST_TMPDIR/bin:$PATH"
  export BACKUP_DIR="$BATS_TEST_TMPDIR/out"

  run main example
  assert_success
  assert_output --partial "saved: $BATS_TEST_TMPDIR/out/example-"

  run cat "$WP_CALLS"
  assert_output --partial "db export $BATS_TEST_TMPDIR/out/example-"
}

BATS_TEST_TMPDIR はテストごとに用意される一時ディレクトリで、テスト終了後に片付けられます。スタブは受け取った引数をファイルに記録するだけなので、「どんな引数で呼ばれたか」を後から検証できます。同じ考え方で curl・mysqldump・rsync なども差し替えられます。

つまずきやすい点

bats 特有の落とし穴を知っておくと、「テストが通っているのに実は何も検証していない」状態を防げます。

  • run cmd | grep x と書くと、パイプは run の出力(空)に対してかかります。パイプを含めて検証したいときは run bash -c 'cmd | grep x' のように1つのコマンドとして渡します。
  • run はサブシェルで実行されるため、中で変更した変数はテスト本体に反映されません。変数の値を確かめたいときは run を使わずに関数を直接呼びます。
  • ! cmd のような否定は、テストの失敗として扱われないことがあります。「失敗すること」を確かめたいときは run ! か、run のあとに assert_failure を使います。
  • macOS 標準の古い Bash(3.2系)では、途中の行の [[ ]] が偽でもテストが失敗にならない場合があると bats のドキュメントで注意されています。CI と手元で Bash の版をそろえるか、assert_* 系の関数を使うと安全です。

失敗したときは、bats --verbose-run や --print-output-on-failure などのオプションで詳細を出せます(オプション名は版によって異なるため、bats --help で確認してください)。

CIで自動実行する

GitHub Actions なら、サブモジュールごとチェックアウトして実行するだけで動きます。

name: shell-test
on: [push, pull_request]
jobs:
  bats:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          submodules: true
      - run: ./test/bats/bin/bats --formatter tap test/

アクションのバージョンは時期によって更新されるため、利用時点の推奨版に合わせてください。bats には JUnit 形式のレポートを出す機能もあり、CI のテスト結果画面に表示させたい場合に使えます。あわせて ShellCheck による静的解析を同じジョブで流すと、テストでは拾いにくい引用符の付け忘れなども検出できます。

まとめ

  • bats は @test ブロックと run で、出力と終了コードを Bash のまま検証できる。
  • bats-core 本体と bats-support・bats-assert はサブモジュールで同梱すると版をそろえやすい。
  • スクリプトは関数に分け、直接実行時だけ main を呼ぶ形にすると source してテストできる。
  • wp などの外部コマンドは BATS_TEST_TMPDIR に置いたスタブで差し替え、本番環境に触れない。
  • パイプ・サブシェル・否定の扱いなど、bats 特有の落とし穴を押さえてから CI に組み込む。

お気軽にご相談ください

お見積りへ お問い合わせへ
▲