BATSを使ったBashスクリプトのテスト入門
Java、Ruby、Pythonなどの言語でアプリケーションを開発しているソフトウェア開発者は、ソフトウェアの品質を長期にわたって維持するための高度なライブラリを利用できます。構造化された環境で一連の実行を通じてアプリケーションを検証するテストを作成し、ソフトウェアのあらゆる側面が期待どおりに動作することを確認します。
こうしたテストは、継続的インテグレーション(CI)システムで自動化されるとさらに強力になります。ソースリポジトリへのプッシュごとにテストが実行され、テストが失敗すれば開発者は即座に通知を受け取れます。この迅速なフィードバックにより、開発者はアプリケーションの機能的な完全性に対して高い信頼を持てるようになります。
Bash Automated Testing System(BATS)は、Bashスクリプトやライブラリを書く開発者が、Java、Ruby、Pythonなどの開発者と同じプラクティスをBashコードにも適用できるようにするフレームワークです。
BATSのインストール
BATSのGitHubページにはインストール手順が記載されています。より強力なアサーションを提供したり、BATSが使用するTAP(Test Anything Protocol)出力形式の上書きを可能にしたりする、2つのBATSヘルパーライブラリも存在します。これらは標準的な場所にインストールし、すべてのスクリプトから読み込むことができます。
一方で、テスト対象のスクリプトやライブラリごとのGitリポジトリに、BATS本体とヘルパーライブラリの完全なセットを含めてしまう方が便利な場合もあります。これはgit submoduleの仕組みを使えば実現できます。
以下のコマンドを実行すると、BATSとそのヘルパーライブラリがGitリポジトリ内のtestディレクトリにインストールされます。
git submodule init
git submodule add https://github.com/sstephenson/bats test/libs/bats
git submodule add https://github.com/ztombol/bats-assert test/libs/bats-assert
git submodule add https://github.com/ztombol/bats-support test/libs/bats-support
git add .
git commit -m 'installed bats'
Gitリポジトリのクローンと同時にサブモジュールも取得したい場合は、git cloneコマンドに--recurse-submodulesフラグを付けてください。
各BATSテストスクリプトはbats実行ファイルによって実行される必要があります。BATSをソースコードリポジトリのtest/libsディレクトリにインストールした場合は、次のようにテストを呼び出せます。
./test/libs/bats/bin/bats <テストスクリプトへのパス>
あるいは、各BATSテストスクリプトの先頭に以下を記述します。
#!/usr/bin/env ./test/libs/bats/bin/bats
load 'libs/bats-support/load'
load 'libs/bats-assert/load'
そしてchmod +x <テストスクリプトへのパス>で実行権限を付与します。これにより、a)./test/libs/batsにインストールされたBATSでスクリプトを実行可能になり、b)ヘルパーライブラリが読み込まれるようになります。BATSテストスクリプトは通常testディレクトリに格納され、テスト対象のスクリプト名に.bats拡張子を付けた名前が付けられます。たとえばbin/buildをテストするBATSスクリプトはtest/build.batsという名前にします。
また、正規表現をBATSに渡すことで、複数のBATSテストファイルをまとめて実行することもできます(例:./test/lib/bats/bin/bats test/*.bats)。
BATSでテストしやすいライブラリ・スクリプトの構成
Bashスクリプトやライブラリは、内部の動作をBATSから効率的に確認できるように構成されている必要があります。一般に、呼び出されたり実行されたりしたときに大量のコマンドを一度に実行するようなライブラリ関数やシェルスクリプトは、効率的なBATSテストには向いていません。
たとえばbuild.shのような典型的なスクリプトは、多くの場合、巨大なコードの塊になっています。この塊をライブラリ内の1つの関数に入れている人さえいるかもしれません。しかし、大きなコードの塊をBATSテストで実行しても、遭遇しうるすべての失敗パターンを個別のテストケースとしてカバーすることは不可能です。十分なカバレッジでこのコードをテストする唯一の方法は、それを多数の小さく再利用可能な、そして何よりも独立してテスト可能な関数に分割することです。
ライブラリに関数を追加するのは簡単です。さらに嬉しいことに、分割によって生まれた関数の中には、単体でも驚くほど有用になるものがあります。ライブラリの機能を小さな関数群に分解できたら、BATSテスト内でそのライブラリをsourceで読み込み、他のコマンドと同じように各関数を実行してテストできます。
Bashスクリプトも同様に複数の関数に分割し、スクリプト実行時にメイン部分からそれらの関数を呼び出す形にする必要があります。さらに、BATSによるBashスクリプトのテストを格段に容易にする非常に有用なテクニックがあります。それは、スクリプトのメイン部分で実行されるすべてのコードをrun_mainのような名前の関数に移動し、スクリプトの末尾に以下を追加するというものです。
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]
then
run_main
fi
この小さな追加コードには特別な働きがあります。スクリプトを直接実行した場合と、sourceで環境に読み込んだ場合とで、挙動が変わるようになるのです。これにより、ライブラリと同じ方法、つまりsourceで読み込んで個々の関数をテストするという形で、スクリプト自体もテストできるようになります。たとえば、BATSでのテスト性を高めるためにリファクタリングしたbuild.shがこちらです。
テストの作成と実行
前述のとおり、BATSはTAP準拠のテストフレームワークであり、その構文と出力はJUnit、RSpec、Jestなど他のTAP準拠テストスイートを使ったことのある人には馴染み深いものです。テストは個別のテストスクリプトに整理され、テストスクリプトは1つ以上の説明的な@testブロックで構成されます。各@testブロックでは、テスト環境を準備する一連のコマンドを実行し、テスト対象のコマンドを実行し、その終了ステータスと出力についてアサーションを行います。多くのアサーション関数はbats、bats-assert、bats-supportライブラリから提供されており、BATSテストスクリプトの冒頭で環境に読み込まれます。典型的なBATSテストブロックは次のようになります。
@test "requires CI_COMMIT_REF_SLUG environment variable" {
unset CI_COMMIT_REF_SLUG
assert_empty "${CI_COMMIT_REF_SLUG}"
run some_command
assert_failure
assert_output --partial "CI_COMMIT_REF_SLUG"
}
BATSスクリプトにsetup関数やteardown関数が含まれている場合、それらは各テストブロックの実行前後に自動的に実行されます。これにより、環境変数やテスト用ファイルの作成など、1つまたはすべてのテストに必要な準備を行い、各テストの実行後に後片付けすることができます。Build.batsは、新しく整形したbuild.shスクリプトの完全なBATSテストです(このテスト内のmock_dockerコマンドについては、後述のモック/スタブのセクションで説明します)。
テストスクリプトが実行されるとき、BATSはexecを使って各@testブロックを独立したサブプロセスとして実行します。これにより、ある@testブロック内でエクスポートした環境変数や関数が、他の@testブロックに影響を与えたり、現在のシェルセッションを汚染したりすることがありません。テスト実行の出力は標準形式であり、人間が読めるだけでなく、TAPコンシューマーによってプログラム的に解析・加工することも可能です。以下はCI_COMMIT_REF_SLUGテストブロックが失敗したときの出力例です。
✗ requires CI_COMMIT_REF_SLUG environment variable
(from function `assert_output' in file test/libs/bats-assert/src/assert.bash, line 231,
in test file test/ci_deploy.bats, line 26)
`assert_output --partial "CI_COMMIT_REF_SLUG"' failed
-- output does not contain substring --
substring (1 lines):
CI_COMMIT_REF_SLUG
output (3 lines):
./bin/deploy.sh: join_string_by: command not found
oc error
Could not login
--
** Did not delete , as test failed **
1 test, 1 failure
成功したテストの出力は次のようになります。
✓ requires CI_COMMIT_REF_SLUG environment variable
ヘルパーライブラリ
他のシェルスクリプトやライブラリと同様に、BATSテストスクリプトにもヘルパーライブラリを含められます。共通コードをテスト間で共有したり、機能を拡張したりするためです。bats-assertやbats-supportといったヘルパーライブラリ自体も、BATSでテストすることさえ可能です。
ライブラリはBATSスクリプトと同じtestディレクトリに置いてもよいですが、testディレクトリ内のファイル数が扱いにくくなってきたらtest/libsディレクトリに配置しましょう。BATSにはload関数が用意されており、テスト対象スクリプト(今回の例ではtest)からの相対パスでBashファイルを指定すると、そのファイルをsourceしてくれます。ファイル名は.bashという接尾辞で終わる必要がありますが、load関数に渡すパスには接尾辞を含めてはいけません。build.batsは、インタプリタのマジックラインの直下に以下のコードを置くことで、bats-assertとbats-supportのライブラリ、小さなhelpers.bashライブラリ、そしてdocker_mock.bashライブラリ(後述)を読み込んでいます。
load 'libs/bats-support/load'
load 'libs/bats-assert/load'
load 'helpers'
load 'docker_mock'
テスト入力のスタブ化と外部呼び出しのモック化
Bashスクリプトやライブラリの大半は、実行時に関数や実行可能ファイルを呼び出します。多くの場合、それらの関数や実行ファイルの終了ステータスや出力(stdout、stderr)に基づいて特定の振る舞いをするようプログラムされています。こうしたスクリプトを適切にテストするには、特定のテスト中に特定の振る舞いをするよう設計された偽物のコマンドを作成する必要があることがよくあります。これを「スタブ化(stubbing)」と呼びます。また、テスト対象のプログラムが特定のコマンドを呼び出すこと、あるいは特定の引数で特定のコマンドを呼び出すことを確認するために、プログラムを監視する必要がある場合もあります。これを「モック化(mocking)」と呼びます。詳細については、どのテストシステムにも応用できるRuby RSpecにおけるモックとスタブに関する優れた議論を参照してください。
Bashシェルには、BATSテストスクリプト内でモックやスタブを実現するためのテクニックが用意されています。いずれも、元の関数や実行ファイルをオーバーライドする関数をエクスポートするために、Bashのexportコマンドに-fフラグを付けて使います。これはテスト対象のプログラムを実行する前に行う必要があります。以下はcat実行ファイルをオーバーライドする簡単な例です。
function cat() { echo "THIS WOULD CAT ${*}" }
export -f cat
同じ方法で関数もオーバーライドできます。テスト対象のスクリプトやライブラリ内の関数をオーバーライドする必要がある場合は、関数をスタブ化またはモック化する前に、必ずテスト対象のスクリプトやライブラリをsourceしてください。そうしないと、sourceのタイミングで実際の関数がスタブ/モックを上書きしてしまいます。また、テスト対象のコマンドを実行する前に必ずスタブ/モックを設定してください。以下はbuild.batsの例で、build.shに定義されたraise関数をモックし、login関数が特定のエラーメッセージを発生させることを確認しています。
@test ".login raises on oc error" {
source ${profile_script}
function raise() { echo "${1} raised"; }
export -f raise
run login
assert_failure
assert_output -p "Could not login raised"
}
通常、テスト後にスタブ/モック関数をunsetする必要はありません。exportの効果は、現在の@testブロックのexec中のサブプロセスに限定されるためです。ただし、BATSのassert*関数が内部的に使用するコマンド(cat、sedなど)をモック/スタブ化することは可能です。その場合、アサーションコマンドを実行する前にモック/スタブ関数をunsetしないと、正しく動作しません。以下はbuild.batsの例で、sedをモックし、build_deployable関数を実行した後、アサーションを実行する前にsedをunsetしています。
@test ".build_deployable prints information, runs docker build on a modified Dockerfile.production and publish_image when its not a dry_run" {
local expected_dockerfile='Dockerfile.production'
local application='application'
local environment='environment'
local expected_original_base_image="${application}"
local expected_candidate_image="${application}-candidate:${environment}"
local expected_deployable_image="${application}:${environment}"
source ${profile_script}
mock_docker build --build-arg OAUTH_CLIENT_ID --build-arg OAUTH_REDIRECT --build-arg DDS_API_BASE_URL -t "${expected_deployable_image}" -
function publish_image() { echo "publish_image ${*}"; }
export -f publish_image
function sed() {
echo "sed ${*}" >&2;
echo "FROM application-candidate:environment";
}
export -f sed
run build_deployable "${application}" "${environment}"
assert_success
unset sed
assert_output --regexp "sed.*${expected_dockerfile}"
assert_output -p "Building ${expected_original_base_image} deployable ${expected_deployable_image} FROM ${expected_candidate_image}"
assert_output -p "FROM ${expected_candidate_image} piped"
assert_output -p "build --build-arg OAUTH_CLIENT_ID --build-arg OAUTH_REDIRECT --build-arg DDS_API_BASE_URL -t ${expected_deployable_image} -"
assert_output -p "publish_image ${expected_deployable_image}"
}
同じコマンド(例:foo)が、テスト対象の同じ関数内で異なる引数で複数回呼び出されることもあります。こうした状況では、次の一連の関数を作成する必要があります。
- mock_foo:期待される引数を入力として受け取り、TMPファイルに保存する
- foo:コマンドのモック版。保存された期待引数のリストに基づいて各呼び出しを処理する。export -fでエクスポートが必要
- cleanup_foo:TMPファイルを削除する。teardown関数で使用する。削除前に@testブロックが成功したかどうかを確認することもできる
この機能はさまざまなテストで再利用されることが多いため、他のライブラリと同じようにロードできるヘルパーライブラリとして作成しておくのが理にかなっています。
良い例がdocker_mock.bashです。これはbuild.batsに読み込まれ、Docker実行ファイルを呼び出す関数をテストする任意のテストブロックで使用されます。docker_mockを使った典型的なテストブロックは次のようになります。
@test ".publish_image fails if docker push fails" {
setup_publish
local expected_image="image"
local expected_publishable_image="${CI_REGISTRY_IMAGE}/${expected_image}"
source ${profile_script}
mock_docker tag "${expected_image}" "${expected_publishable_image}"
mock_docker push "${expected_publishable_image}" and_fail
run publish_image "${expected_image}"
assert_failure
assert_output -p "tagging ${expected_image} as ${expected_publishable_image}"
assert_output -p "tag ${expected_image} ${expected_publishable_image}"
assert_output -p "pushing image to gitlab registry"
assert_output -p "push ${expected_publishable_image}"
}
このテストでは、Dockerが異なる引数で2回呼び出されるという期待値を設定し、2回目のDocker呼び出しが失敗するようにしています。そのうえでテスト対象のコマンドを実行し、終了ステータスとDockerへの期待される呼び出しを検証します。
mock_docker.bashが導入するBATSの機能のひとつに、${BATS_TMPDIR}環境変数があります。BATSは起動時にこの変数を設定し、テストやヘルパーが標準的な場所でTMPファイルを作成・削除できるようにします。mock_docker.bashライブラリは、テストが失敗した場合に保存済みのモックファイルを削除しませんが、その場所を出力するので、確認して削除できます。このディレクトリから古いモックファイルを定期的に掃除する必要があるかもしれません。
モック/スタブに関する注意点がひとつあります。build.batsのテストは、「自分が所有していないものをモックするな!」というテストの原則を意図的に破っています。この原則によれば、docker、cat、sedなど、テスト開発者が書いていないコマンドへの呼び出しは、専用のラッパーライブラリで包むべきであり、それらを使用するスクリプトのテストではラッパーライブラリをモックすべきです。そして、ラッパーライブラリ自体は外部コマンドをモックせずにテストします。
これは良い助言であり、無視すれば代償を払うことになります。Docker CLI APIが変更された場合、テストスクリプトはその変更を検出できず、テスト対象のbuild.shスクリプトが新しいバージョンのDockerで本番環境で実行されるまで顕在化しない偽陽性(false positive)につながります。テスト開発者はこの基準をどの程度厳密に守るかを判断する必要がありますが、その決定に伴うトレードオフを理解しておくべきです。
まとめ
どんなソフトウェア開発プロジェクトにテスト体制を導入する場合でも、a)コードとテストの開発・保守に必要な時間と組織的負荷の増加と、b)アプリケーションの生涯にわたる完全性に対する開発者の信頼の向上との間でトレードオフが生じます。すべてのスクリプトやライブラリにテスト体制が適切とは限りません。
一般的に、以下の条件のうち1つ以上に当てはまるスクリプトやライブラリは、BATSでテストすべきです。
- バージョン管理下に置く価値がある
- 重要なプロセスで使用され、長期間にわたって安定して動作することが求められる
- 機能の追加・削除・変更のために定期的に修正が必要となる
- 他の人によって使用される
1つ以上のBashスクリプトやライブラリにテストの規律を適用すると決めたなら、BATSは他のソフトウェア開発環境で利用可能な包括的なテスト機能を提供してくれます。
謝辞:BATSテストを紹介してくれたDarrin Mannに感謝します。
-
BashスクリプトとImageMagickで画像処理を自動化する方法
ライターの仕事は言葉だけではありません。特にテクニカルライティングでは、技術やプロセスを伝えるために大量のスクリーンショットを扱うことがよくあります。さらに、公開プラットフォームごとに画像フォーマットやファイルサイズなど、異なる要件が課されることも少なくありません。 ITコンサルタント兼システムエンジニアとして、私はクライアントへの納品物として数多くの技術文書を作成してきました。当時はMicrosoft Word(.doc)形式が求められるのが一般的でした。文書はコンテンツを追加するたびにあっという間に肥大化していきます。初期のスクリーンショットはビットマップ(.bmp)形式が主流で、ファイル
-
パズルを解きながらBashをマスターできる一冊――『Bash it out』レビュー
コンピューターは私にとって趣味であり、同時に仕事でもあります。自宅のアパートには10台ほどのマシンがあり、Macも含めてすべてLinuxが動いています。マシンのアップグレードもスキルの向上も好きな私にとって、Sylvain Leroux(シルヴァン・ルルー)氏の著書『Bash it out』を見つけたときは、迷わず購入しました。日頃からDebian Linuxでコマンドラインを多用している私にとって、Bashの知識を深める絶好の機会に思えたのです。しかも序文で著者自身がDebian Linuxを使っていると書かれており、私のお気に入りディストリビューションのひとつだけに、思わず笑みがこぼれまし