Bashスクリプトの効果的なテスト方法

Bashスクリプトを、strictモード、トレース、Bats、shUnit2、モックコマンド、一時ディレクトリ、ShellCheck、CI自動化でテストします。

Bashスクリプトを効果的にテストする方法

Bashスクリプトは、ファイル、サービス、デプロイ、本番データに影響を与えることがよくあります。Bashスクリプトを効果的にテストすることで、クリーンアップジョブが誤ったディレクトリを削除したり、デプロイスクリプトが失敗したコマンドをスキップしたりする前に、誤った前提を発見できます。

始めるのに大規模なフレームワークは必要ありません。防御的なシェルオプション、静的チェック、焦点を絞ったユニットテスト、一時的なテスト環境を組み合わせることで、スクリプトが大きな問題を起こし、予測可能な形で失敗するようにします。


基本:防御的コーディングとデバッグ

正式なユニットテストを実装する前に、バグに対する最初の防御層はスクリプト自体の構造にあります。厳格な動作設定を利用することで、微妙なランタイムエラーを即座の失敗に変え、デバッグを容易にできます。

必須の防御的ヘッダー

多くの本番用Bashスクリプトは、より厳しいオプションで始まります:

#!/bin/bash
# コマンドがゼロ以外のステータスで終了した場合、即座に終了する。
set -e

# 未設定の変数を置換時にエラーとして扱う。
set -u

# パイプライン内のエラーが隠蔽されるのを防ぐ。
set -o pipefail

これらを set -euo pipefail にまとめるのが一般的です。set -e には条件式、サブシェル、パイプラインでエッジケースがあるため、strictモードがテストを置き換えると想定せず、予想される失敗を明示的にチェックしてください。

トレースによる手動デバッグ

迅速なデバッグやスクリプトの実行フローを理解するために、Bashには組み込みのトレース機能があります:

  • コマンドトレース(-x): 実行されるコマンドとその引数を、先頭に + を付けて表示します。
  • 実行なし(-n): コマンドを読み込むが実行しません(構文エラーのチェックに便利)。

トレースは、スクリプトの実行時またはスクリプト内部で有効にできます:

# スクリプトをトレース付きで実行
bash -x ./my_script.sh

# スクリプト内で特定のセクションに対してトレースを有効化
echo "複雑な操作を開始..."
set -x # トレースを有効化
complex_function_call arg1 arg2
set +x # トレースを無効化
echo "操作が終了しました。"

正式なユニットテストフレームワークの採用

複雑なロジックには手動デバッグは持続不可能です。正式なユニットテストフレームワークを使用すると、繰り返し可能なテストケースを定義し、期待される結果をアサートし、検証プロセスを自動化できます。

1. Bats(Bash自動テストシステム)

Batsは、おそらくBashテストで最も人気があり、最も簡単なフレームワークです。使い慣れたBash構文を使用してテストを記述でき、アサーションがシンプルで読みやすくなります。

Batsの主な機能:

  • テストはBashライクな構文で記述されます。
  • シンプルな run コマンドを使用して、対象のスクリプト/関数を実行します。
  • $status$output$lines などの組み込みアサーション変数を提供します。

例:シンプルな関数のテスト

calculate_sum 関数を含むスクリプト(calculator.sh)を想像してください。

calculator.sh の抜粋:

calculate_sum() {
  if [[ $# -ne 2 ]]; then
    echo "エラー:2つの引数が必要です" >&2
    return 1
  fi
  echo $(( $1 + $2 ))
}

test/calculator.bats

#!/usr/bin/env bats

# テスト対象の関数を含むスクリプトをソースする。
# BATS_TEST_DIRNAME はこのテストファイルを含むディレクトリを指す。
source "$BATS_TEST_DIRNAME/../calculator.sh"

@test "有効な入力は正しい合計を返すべき" {
  run calculate_sum 10 5
  # 関数が成功ステータス(0)を返したことをアサート
  [ "$status" -eq 0 ]
  # 出力が期待と一致することをアサート
  [ "$output" = "15" ]
}

@test "入力が不足している場合はエラーステータス(1)を返すべき" {
  run calculate_sum 5
  [ "$status" -ne 0 ]
  [ "$status" -eq 1 ]
  # 最近のbats-coreバージョンでは、`run` を使用するとstderrが利用可能です。
  # [ "$stderr" = "エラー:2つの引数が必要です" ] 
}

テストを実行するには:

bats test/calculator.bats

2. ShUnit2

ShUnit2はxUnitスタイルのテストに従っており、PythonやJavaなどの言語から来た開発者にとって親しみやすいものです。フレームワークファイルをソースする必要があり、厳格な命名規則(setUptearDowntest_...)に従います。

ShUnit2の主な機能:

  • クリーンアップのためのセットアップとティアダウンルーチンをサポートします。
  • 豊富な組み込みアサーション関数(例:assertTrueassertEquals)を提供します。

ShUnit2の構造

#!/bin/bash
# shUnit2をソースする。インストールに応じてパスを調整してください。
. /usr/local/share/shunit2/shunit2

# 変数/フィクスチャを定義

setUp() {
  # 各テストの前に実行するコード
  TEMP_FILE=$(mktemp)
}

tearDown() {
  # 各テストの後に実行するコード(クリーンアップ)
  rm -f "$TEMP_FILE"
}

test_basic_addition() {
  local result
  # テスト対象の関数を呼び出す
  result=$(my_script_function 1 2)
  
  # アサーション関数を使用
  assertEquals "3" "$result"
}

# shUnit2パッケージが最後に明示的なソースを期待する場合は、
# 上部ではなくテスト関数の後にソースしてください。

Bashスクリプトテストのベストプラクティス

効果的なテストはフレームワークを実行するだけではありません。コンポーネントの注意深い分離と環境依存関係の管理が必要です。

1. 入力、出力、エラーの処理

テストでは、標準ストリーム(stdout、stderr)と最終的な終了コードを検証する必要があります。これはBashで成功または失敗を知らせる主要なメカニズムです。

  • 終了コード: 成功の場合は status -eq 0、解析失敗やファイル欠落などのエラー条件の場合はゼロ以外の値をテストします。
  • 標準出力(stdout): これは通常、主要なデータ出力です。Batsの $output を使用するか、ShUnit2で出力をキャプチャして正しさをアサートします。
  • 標準エラー出力(stderr): エラー、警告、デバッグメッセージはここにルーティングする必要があります。重要なのは、本番スクリプトが成功実行中は stderr に何も出力しないようにすることです。

2. 依存関係の分離(モッキング)

ユニットテストはあなたのコードをテストすべきであり、外部システムツール(curlkubectlgitなど)をテストするものではありません。スクリプトが外部コマンドに依存している場合、テスト中はそのコマンドをモックする必要があります。

方法: 実際の依存関係と同じ名前のモック実行可能ファイルを含む一時ディレクトリを作成します。テストを実行する前にこのディレクトリを $PATH の先頭に追加し、スクリプトが実際のツールではなくモックを呼び出すようにします。

モックの例:

#!/bin/bash
# ファイル:/tmp/mock_bin/curl

if [[ "$1" == "--version" ]]; then
  echo "モック Curl 7.6"
  exit 0
else
  # 成功したAPIレスポンスをシミュレート
  echo '{"status": "ok"}'
  exit 0
fi

テストのセットアップで:

export PATH="/tmp/mock_bin:$PATH"

3. 一時環境を使用した統合テスト

統合テストは、スクリプトがファイルシステムやオペレーティングシステムと正しく相互作用することを検証します。一時ディレクトリを使用して、システムを汚染したり、他のテストに干渉したりするのを防ぎます。

mktemp の使用

mktemp -d コマンドは、安全で一意の一時ディレクトリを作成します。テスト実行中は、すべてのファイル操作(作成、変更、クリーンアップ)をこのディレクトリ内で実行する必要があります。

setUp() {
  # このテスト実行のための一時ディレクトリを作成
  TEST_ROOT=$(mktemp -d)
  cd "$TEST_ROOT"
}

tearDown() {
  # 一時ディレクトリをクリーンアップ
  cd - >/dev/null
  rm -rf "$TEST_ROOT"
}

@test "スクリプトは必要なログファイルを作成するべき" {
  run my_script_that_writes_logs
  
  # 期待されるファイルが一時ディレクトリに存在することをアサート
  [ -f "./log/script.log" ]
}

4. 移植性のテスト

Bashの実装はわずかに異なります(例:GNU Bash vs. macOS/BSD Bash)。移植性が重要な場合は、さまざまなターゲット環境(Dockerコンテナを使用するなど)でテストスイートを実行し、ユーティリティコマンドやパラメータ展開の微妙な違いを発見します。

テストをワークフローに統合する

テストは後付けであってはなりません。テストスイートをバージョン管理とCI/CD(継続的インテグレーション/継続的デプロイ)パイプラインに組み込みます。

  1. バージョン管理: テストディレクトリ(例:test/)をソーススクリプトと一緒に保存します。
  2. Pre-Commitフック: shellcheck(静的解析ツール)やフォーマッタなどのツールを使用して、コミット前にコード品質を確保します。
  3. CI自動化: CIサーバー(GitHub Actions、GitLab CI、Jenkins)を設定して、プッシュのたびにBatsまたはShUnit2テストスイートを自動的に実行します。いずれかのテストがゼロ以外のステータスを返した場合、ビルドを失敗させます。

警告: shellcheck のような静的解析ツールは、ユニットテストの優れた補完ツールです。テストが見逃す可能性のある一般的なミス、移植性の問題、セキュリティの脆弱性を発見します。テスト前のルーチンの一部として常に shellcheck を実行してください。

結論

shellcheckset -euo pipefail から始め、次に入力を解析したり、ファイルを選択したり、外部ツールを呼び出したり、元に戻せない変更を行ったりするスクリプトの部分にテストを追加します。モックされた依存関係と一時ディレクトリを備えた小さなBatsスイートは、リスクのあるスクリプトを自信を持って変更できる自動化に変えるのに十分です。