Batsを利用したBashスクリプトのfizzbuzz
1. はじめに
Bats(bats-core)を使ったBashスクリプトの作成/更新ついて紹介します。
Bashスクリプトを書き始めた方、永くスクリプトは書いているもののテストは使ったことがない方向けの記事です。
Bashスクリプトをテストと併せて書くことによって、開発効率とコードの質が向上します。いままでテストは書いてなかった方は是非、試してみて頂ければと思います。
尚、関連情報として以下の2つの記事も投稿していますので適宜ご覧ください。
- Batsのインストール手順メモ(RHEL9.x)でインストール方法を例示しています。インストールに際し、ダウンロードに必要なgitコマンドをローカルインストールメディアからインストールする方法も記載しています。
- bats-assertの使用例で、アサーションの実用的な使いかたを記載しました。
ここではBashのFizzBuzzスクリプトを徐々にBatsのテストを書きながら更新していきます。スクリプトコードを載せてステップバイステップで記載していますので、実際に試しながら進めて頂ければと思います。
この記事の前提情報などは4-1. はじめの補足に纏めましたので、適宜ご参照ください。
1-1. 前提環境
使用環境は以下の通りです。
お作法的に環境を示しましたが、Bashが利用できて相当古いバージョン(v3.2未満)でない限り、動作には支障ありません。(実際、CentOS 6.0 GNU bash, version 4.1.2(1)-release
でも動作しました。)
Name | Value |
---|---|
OS | RHEL 9.4 |
Bash | GNU bash, version 5.1.8(1)-release |
Bats: bats-core | v1.11.1 (2024-11-30) |
Bats: bats-assert | v0.3.0 (2016-03-22) |
Bats: bats-support | v0.3.0 (2016-11-29) |
Batsの各コンポーネントは、2025年3月現在の最新を使っています。
bats-assert、bats-supportは永らく更新されておらずv.1にすらなっていませんが、利用に際し支障はありません。
1-2. Batsコンポーネントのインストール先
ここでは Batsのコンポーネントはそれぞれ以下にインストールした環境を使用しています。
Name | Destination |
---|---|
bats-core | /usr/local |
bats-assert | /usr/local/lib |
bats-support | /usr/local/lib |
bats-coreがBatsのメインコンポーネントです。これ自体もBashスクリプトで実装されているようです。
bats-assertと、bats-supportはBats用のライブラリで、使用することによって、簡潔なアサーションによるテストが書けるようになり、テスト失敗時の期待値と実際の結果を表示してくれるようになります。
bats-coreはインストール時に、bats-core/install.sh /usr/local
のようにディレクトリを指定します。
bats-assertとbats-supportはファイルを配備するだけで、/usr/local/lib
に置いています。
適宜、ご自身の環境に合わせて変更ください。
2. スクリプトファイルの構成と仕様
同じディレクトリにBashスクリプトとBatsテストスクリプトが配備され、カレントディレクトリであると想定します。
尚、順次更新する都度、別のファイルとするためにファイル名に枝番を付与しています。変更内容は適宜、ファイルの比較で確認してみてください。
File name | Description |
---|---|
fizzbuzznn.bash | メインスクリプト |
fizzbuzznn.bats | テストスクリプト |
fizzbuzznn.bashが、実際に利用/実装/開発/作成/変更するためのスクリプトです。本体、メイン、Bashスクリプトなどとここでは言います。
fizzbuzznn.batsは、メインスクリプトの機能をテストするためのスクリプトです。実体としてはBatsもBashスクリプトで実装されていますが、ここではBatsスクリプトや、テストコードといった言い方で呼び分けます。
例示するメインスクリプトはFizzBuzzを扱うものとします。これは、1つ以上の引数(期待する値は1以上の自然数)を、一行ごと以下のフォーマットで標準出力へ出力するものとします。
元の引数の値: 計算結果
扱えない値は Error
と出力し、それ以外はFizzBuzzの計算結果を出力します。
以下に実行結果としての具体例を挙げます。
$ ./fizzbuzz.bash 0 1 2 3 4 5 a 6 14 15 16
0: Error
1: 1
2: 2
3: Fizz
4: 4
5: Buzz
a: Error
6: Fizz
14: 14
15: FizzBuzz
16: 16
3. スクリプトの作成と更新
ここから少しずつ、スクリプトを書き直しながら進めていきます。
スクリプトファイル名は都度新しい名前に(枝番号を付与)していますので適宜、変更前後を比較して確認してみてください。
3-1. 最初のBashスクリプト
上記を実現するスクリプトなら以下の様に書けます。
#! /usr/bin/env bash
for item in "$@" ; do
echo -n "$item: "
if [[ "$item" =~ ^[1-9][0-9]*$ ]] ; then
mid=$( echo $(( $item % 15 )) )
if [ $mid -eq 0 ] ; then
echo FizzBuzz
continue
fi
mid=$( echo $(( $item % 3 )) )
if [ $mid -eq 0 ] ; then
echo Fizz
continue
fi
mid=$( echo $(( $item % 5 )) )
if [ $mid -eq 0 ] ; then
echo Buzz
continue
fi
echo $item
else
echo Error
fi
done
3-2. is_divisible
関数を作る
割り算の余りを計算する処理と、その結果を判断する処理は、明かに同じようなロジックになっています。これを、割り切れるかどうかの関数is_divisible
に移します。まず関数を作り、呼び出し部分のみ変更してみます。
#! /usr/bin/env bash
function is_divisible() {
mid=$(( $1 % $2 ))
test $mid -eq 0 && return 0
return 1
}
for item in "$@" ; do
echo -n "$item: "
if [[ "$item" =~ ^[1-9][0-9]*$ ]] ; then
is_divisible $item 15
if [ $? -eq 0 ] ; then
echo FizzBuzz
continue
fi
is_divisible $item 3
if [ $? -eq 0 ] ; then
echo Fizz
continue
fi
is_divisible $item 5
if [ $? -eq 0 ] ; then
echo Buzz
continue
fi
echo $item
else
echo Error
fi
done
3-3. is_divisible
関数の呼び出し箇所を短くする
呼び出し部分をコンパクトになるよう書き換えます。
この書き方は好まない人もいるかもしれませんが、この程度のコードならば可読性も損なわないものと思います。
#! /usr/bin/env bash
function is_divisible() {
mid=$(( $1 % $2 ))
test $mid -eq 0 && return 0
return 1
}
for item in "$@" ; do
echo -n "$item: "
if [[ "$item" =~ ^[1-9][0-9]*$ ]] ; then
is_divisible $item 15 && echo FizzBuzz && continue
is_divisible $item 3 && echo Fizz && continue
is_divisible $item 5 && echo Buzz && continue
echo $item
else
echo Error
fi
done
3-4. Batsテストを書き始める
3-4.1 エントリーポイントガード
これで、1つis_divisible
の関数ができました。
これはテストしやすい関数です。
いよいよテストを書いていきたいと思いますが、まずメインスクリプトに手を入れます。
メインスクリプトがBatsテストスクリプトから読み込めるようにします。
#! /usr/bin/env bash
function is_divisible() {
mid=$(( $1 % $2 ))
test $mid -eq 0 && return 0
return 1
}
# Exit if sourced, run if executed directly
test "${BASH_SOURCE[0]}" != "$0" && return 0
for item in "$@" ; do
echo -n "$item: "
if [[ "$item" =~ ^[1-9][0-9]*$ ]] ; then
is_divisible $item 15 && echo FizzBuzz && continue
is_divisible $item 3 && echo Fizz && continue
is_divisible $item 5 && echo Buzz && continue
echo $item
else
echo Error
fi
done
test "${BASH_SOURCE[0]}" != "$0" && return 0
を関数とメインの処理の間に挟みました。これで他のスクリプトから読み込まれた場合は、ここで読み込みが終わり、実行された場合は後続の処理も実行されるようになります。
簡単に説明すると"${BASH_SOURCE[i]}"
配列はシェル関数名が定義されているソースファイル名が格納されるスタックでインデックス0は最新のものがセットされます。
これと、$0(シェルスクリプト名)が一致するかを判断することで、一致しない場合は今回のBatsからの読み込みの様にリターンし、後続の処理は実行しないようにしています。
直接Bashスクリプトが実行された場合は一致する為、後続のロジックも実行されます。
3-4-2. テストスクリプトを書く
いよいよテストを書きます。分かり易くなる様に、なるべく小さくしました。
#! /usr/bin/env bats
BATSLIB=/usr/local/lib
load ${BATSLIB}/bats-support/load
load ${BATSLIB}/bats-assert/load
TARGET=./fizzbuzz04.bash
load $TARGET
@test "is_divisible" {
run is_divisible 2 2
assert_success
}
シバン行はインストールしたbats-coreのコマンド/usr/local/bin/bats
が実行されます。
$BATSLIB
を起点にして、配備したbats-support
とbats-assert
をロードしています。もし、/usr/local/lib
以外にbats-support
とbats-assert
を配備している場合はここを書き換えて下さい。
最後の数行の@test~
がテストの部分です。
まず、run
を使ってis_divisible
関数に2
2
を引数として実行します。
次のassert_success
が、関数が成功したか(戻り値が0か)をチェックしています。
3-4-3. テストを実行してみる
fizzbuzz04.bats
に実行権を付与して実行してみます。下記の様にテストが成功するはずです。
$ chmod +x ./fizzbuzz04.bats
$ ./fizzbuzz04.bats
fizzbuzz04.bats
✓ is_divisible
1 test, 0 failures
$ echo $?
0
敢えて失敗させてみます。
ここでは、fizzbuzz04.bats
を元に失敗させる別ファイルとしてfizzbuzz04_err.bats
を生成して確認しています。
$ sed '/run /s/2 2/3 2/' ./fizzbuzz04.bats > ./fizzbuzz04_err.bats
$ diff ./fizzbuzz04.bats ./fizzbuzz04_err.bats
11c11
< run is_divisible 2 2
---
> run is_divisible 3 2
$ chmod +x ./fizzbuzz04_err.bats
$ ./fizzbuzz04_err.bats
fizzbuzz04_err.bats
✗ is_divisible
(in test file fizzbuzz04_err.bats, line 12)
`assert_success' failed
-- command failed --
status : 1
output :
--
1 test, 1 failure
$ echo $?
1
ここで、テストコードはassert_success
で成功、つまり関数の戻り値 == 0
を期待していることに対して、引数で渡しているものは 3
と 2
なので、割り切れない結果が返ってきています。
その為、Batsはstatus : 1
(戻り値が1だった)、output :
(標準出力/標準エラー出力は空文字だった)と表示されテストが失敗します。Batsスクリプト自体も$? == 1
の結果となっています。
また、現時点ではテストコードが1つだけなので自明ですが、in test file fizzbuzz04_err.bats, line 12
と12行目のテストが失敗していることに注目して下さい。テストが多くなってきてテスト失敗時、どのテストが失敗したかはこれで特定できます。
ここで改めてis_divisible
関数は戻り値によって結果を返す作りにしたことに留意して下さい。詳細は後述しますが、Batsのテストはシェルという性質上、PerlやPythonなどのテストと異なり、標準出力/標準エラー出力を返すか、戻り値を返すかによって関数の作りと呼び出し側の結果の扱い方が重要になります。少し気に留めておいて頂ければと思います。
3-5. テストのバリエーションを増やす(is_divisible
)
では、テストのバリエーションを増やします。テストコードの長さによりますが、実行(入力)と期待結果は横に並んでいた方が可読性が高いように思いますので、横に並べる様に書いています。
FizzBuzzとしては3
, 5
, 15
で割り切れるか否かがポイントになるのでそのバリエーションのテストを混ぜてみます。
(ここでは、Bashスクリプトは更新していませんので、fizzbuzz04.bash
を指しています。)
#! /usr/bin/env bats
BATSLIB=/usr/local/lib
load ${BATSLIB}/bats-support/load
load ${BATSLIB}/bats-assert/load
TARGET=./fizzbuzz04.bash
load $TARGET
@test "is_divisible" {
run is_divisible 1 3; assert_failure 1
run is_divisible 1 5; assert_failure 1
run is_divisible 1 15; assert_failure 1
run is_divisible 3 3; assert_success
run is_divisible 3 5; assert_failure 1
run is_divisible 3 15; assert_failure 1
run is_divisible 5 3; assert_failure 1
run is_divisible 5 5; assert_success
run is_divisible 5 15; assert_failure 1
run is_divisible 15 3; assert_success
run is_divisible 15 5; assert_success
run is_divisible 15 15; assert_success
}
assert_failureは引数を指定しない場合、$?≠0
か判断します。指定すると、実際の値までチェックします。
今回の関数is_divisible
は割り切れるかどうかを判断するので、assert_failure
の引数は無くても構いませんが、想定通りの値を返しているかを確認するために引数ありでテストを書いています。
尚、0割りや対象外値の考慮はこの関数は含まれていません。これは現時点ではメインスクリプトの以下のif文で弾いている為です。
if [[ "$item" =~ ^[1-9][0-9]*$ ]] ; then
テストはなるべく少しずつ追加し、実行をしてみながら育てるのお勧めです。また、最初に追加して動いた場合(テストが成功した場合)、敢えて違う値の引数や、期待結果値に変えて動かしてみることを強く推奨します。
今度は期待通りエラーになったかを見るわけです。これで、成功させるコードとテストが正しく機能しているかを確認できます。場合によっては想定と異なり、そもそもそのロジックを通っていない場合を見つけることができます。
テストは過剰に増やすべきではありませんが、必要なバリエーションか悩むならテストに含めてしまった方が良いと思います。
過剰に増やすとは、例えば、極端な例を言えば扱える数値、文字列、記号を全部含めるというのは明かにやりすぎです。ですが、テストケースを厳選して絞るよりも合った方が良さそうなものはテストに入れてしまった方が良いと思います。
実際に実行してみて頂くと分かりますが、テストは基本的に一瞬で完了します。
下手に悩んだり、テストが含まれていないことを不安に思うくらいならば必要そうなものはテストとして書いた方が良いと思います。
3-6. fizzbuzz
関数を作る
では次に、FizzBuzzのロジックを関数として作成し、テストに対応させてみます。関数名はそのままfizzbuzz
とします。
関数をどう扱うか次第ですが、今回は受けた引数からFizz
, Buzz
, FizzBuzz
、或いは、該当しない場合を戻り値で判断できるようにします。
尚、メイン側の呼び出し処理の方はまだ手を入れません。
引数 | 戻り値 | 標準出力に出力する文字列(メインスクリプト) |
---|---|---|
3の倍数 | 1 | Fizz |
5の倍数 | 2 | Buzz |
15の倍数 | 3 | FizzBuzz |
その他の自然数 | 0 | 元の文字列 |
自然数以外 | 63 | Error |
これが利用できれば、メインスクリプトは関数の戻り値に応じて、標準出力に書き出す文字列を判断できるようになります。
(なお、その他エラーを63
の返り値としているのは深い意味はなく、単に4以上の何か、というだけです。)
それではまず、TDDっぽく、先にテストから書いてみます。
#! /usr/bin/env bats
BATSLIB=/usr/local/lib
load ${BATSLIB}/bats-support/load
load ${BATSLIB}/bats-assert/load
TARGET=./fizzbuzz06.bash
load $TARGET
@test "is_divisible" {
run is_divisible 1 3; assert_failure 1
run is_divisible 1 5; assert_failure 1
run is_divisible 1 15; assert_failure 1
run is_divisible 3 3; assert_success
run is_divisible 3 5; assert_failure 1
run is_divisible 3 15; assert_failure 1
run is_divisible 5 3; assert_failure 1
run is_divisible 5 5; assert_success
run is_divisible 5 15; assert_failure 1
run is_divisible 15 3; assert_success
run is_divisible 15 5; assert_success
run is_divisible 15 15; assert_success
}
@test "fizzbuzz" {
run fizzbuzz 1; assert_equal $status 0
run fizzbuzz 3; assert_equal $status 1
run fizzbuzz 5; assert_equal $status 2
run fizzbuzz 15; assert_equal $status 3
run fizzbuzz 015; assert_equal $status 63
run fizzbuzz -1; assert_equal $status 63
run fizzbuzz 0; assert_equal $status 63
run fizzbuzz ; assert_equal $status 63
run fizzbuzz a; assert_equal $status 63
run fizzbuzz ''; assert_equal $status 63
}
後ろにfizzbuzz
関数のテストコードを追記しました。
この状態でTDDっぽく実行してみます。まだ、fizzbuzz06.bash
がないので、fizzbuzz04.bash
をコピーしています。
これは盛大にこけます。(まだ本体側にfizzbuzz
関数を作っていないので当然です)
$ cp -p fizzbuzz0{4,6}.bash
$ chmod +x fizzbuzz06.bats
$ ./fizzbuzz06.bats
fizzbuzz06.bats
✓ is_divisible
✗ fizzbuzz
(in test file fizzbuzz06.bats, line 26)
`run fizzbuzz 1; assert_equal $status 0' failed
-- values do not equal --
expected : 0
actual : 127
--
2 tests, 1 failure
The following warnings were encountered during tests:
BW01: `run`'s command `fizzbuzz 1` exited with code 127, indicating 'Command not found'. Use run's return code checks, e.g. `run -127`, to fix this message.
(from function `run' in file /usr/local/lib/bats-core/test_functions.bash, line 297,
in test file fizzbuzz06.bats, line 26)
では、メインスクリプト(fizzbuzz06.bash
)側にfizzbuzz
関数を追加してみます。(ここでは説明の都合上、いきなり完成形を入れていますが、TDDとして書くならば、関数fizzbuzz
を定義して関数コードを書きながらテストを実行するサイクルを回してみるのが良いと思います。)
#! /usr/bin/env bash
function is_divisible() {
mid=$(( $1 % $2 ))
test $mid -eq 0 && return 0
return 1
}
function fizzbuzz() {
if [[ "$1" =~ ^[1-9][0-9]*$ ]] ; then
pass
else
return 63
fi
is_divisible $1 15 && return 3
is_divisible $1 3 && return 1
is_divisible $1 5 && return 2
return 0
}
# Exit if sourced, run if executed directly
test "${BASH_SOURCE[0]}" != "$0" && return 0
for item in "$@" ; do
echo -n "$item: "
if [[ "$item" =~ ^[1-9][0-9]*$ ]] ; then
is_divisible $item 15 && echo FizzBuzz && continue
is_divisible $item 3 && echo Fizz && continue
is_divisible $item 5 && echo Buzz && continue
echo $item
else
echo Error
fi
done
fizzbuzz06.bash
に対応したfizzbuzz06.bats
を実行すればテストは通るはずです。
$ ./fizzbuzz06.bats
fizzbuzz06.bats
✓ is_divisible
✓ fizzbuzz
2 tests, 0 failures
ここで一つのポイントはテストの戻り値の判断をassert_success
(0)とassert_failure
(>0)ではなく、assert_equal
を使って書いていることです。
assert_failure
で書くことも出来ますが、このfizzbuzz
関数はgrep
コマンドの様に戻り値がBool的に成功と失敗だけではなく種類を示します。戻り値がどうだったか?を表現するためにassert_equal $status $?
の様に書きました。
3-7. is_natural_number
関数を作る
fizzbuzz06.bash
に追加したfizzbuzz
関数では、最初に計算対象外となる値はif [[ "$1" =~ ^[1-9][0-9]*$ ]] ; then
で弾いています。
これをもう少し、見やすくなるように変えてみます。
これもロジックを見たときに、対象外文字列かどうかを判断しやすい表現の方が分かり易いので関数化(is_natural_number
関数を作成)します。その為のテストも書いておきます。
is_natural_number
は1以上の自然数(計算対象)なら、戻り値==0、それ以外は戻り値==1を返す仕様とすることにします。
#! /usr/bin/env bash
function is_natural_number() {
[[ "${1}" =~ ^[1-9][0-9]*$ ]] && return 0
return 1
}
function is_divisible() {
mid=$(( $1 % $2 ))
test $mid -eq 0 && return 0
return 1
}
function fizzbuzz() {
if [[ "$1" =~ ^[1-9][0-9]*$ ]] ; then
pass
else
return 63
fi
is_divisible $1 15 && return 3
is_divisible $1 3 && return 1
is_divisible $1 5 && return 2
return 0
}
# Exit if sourced, run if executed directly
test "${BASH_SOURCE[0]}" != "$0" && return 0
for item in "$@" ; do
echo -n "$item: "
if [[ "$item" =~ ^[1-9][0-9]*$ ]] ; then
is_divisible $item 15 && echo FizzBuzz && continue
is_divisible $item 3 && echo Fizz && continue
is_divisible $item 5 && echo Buzz && continue
echo $item
else
echo Error
fi
done
#! /usr/bin/env bats
BATSLIB=/usr/local/lib
load ${BATSLIB}/bats-support/load
load ${BATSLIB}/bats-assert/load
TARGET=./fizzbuzz07.bash
load $TARGET
@test "is_natural_number" {
run is_natural_number 1; assert_success
run is_natural_number 3; assert_success
run is_natural_number 5; assert_success
run is_natural_number 15; assert_success
run is_natural_number 015; assert_failure 1
run is_natural_number -1; assert_failure 1
run is_natural_number 0; assert_failure 1
run is_natural_number ; assert_failure 1
run is_natural_number a; assert_failure 1
run is_natural_number ''; assert_failure 1
}
@test "is_divisible" {
run is_divisible 1 3; assert_failure 1
run is_divisible 1 5; assert_failure 1
run is_divisible 1 15; assert_failure 1
run is_divisible 3 3; assert_success
run is_divisible 3 5; assert_failure 1
run is_divisible 3 15; assert_failure 1
run is_divisible 5 3; assert_failure 1
run is_divisible 5 5; assert_success
run is_divisible 5 15; assert_failure 1
run is_divisible 15 3; assert_success
run is_divisible 15 5; assert_success
run is_divisible 15 15; assert_success
}
@test "fizzbuzz" {
run fizzbuzz 1; assert_equal $status 0
run fizzbuzz 3; assert_equal $status 1
run fizzbuzz 5; assert_equal $status 2
run fizzbuzz 15; assert_equal $status 3
run fizzbuzz 015; assert_equal $status 63
run fizzbuzz -1; assert_equal $status 63
run fizzbuzz 0; assert_equal $status 63
run fizzbuzz ; assert_equal $status 63
run fizzbuzz a; assert_equal $status 63
run fizzbuzz ''; assert_equal $status 63
}
テストの実行結果です。
$ ./fizzbuzz07.bats
fizzbuzz07.bats
✓ is_natural_number
✓ is_divisible
✓ fizzbuzz
3 tests, 0 failures
3-8. 作成した関数を呼び出すようにメインスクリプトを書き換える
では、メインスクリプトを作成した関数を利用するように書き換えます。
#! /usr/bin/env bash
function is_natural_number() {
[[ "${1}" =~ ^[1-9][0-9]*$ ]] && return 0
return 1
}
function is_divisible() {
mid=$(( $1 % $2 ))
test $mid -eq 0 && return 0
return 1
}
function fizzbuzz() {
is_natural_number "$1" || return 63
is_divisible $1 15 && return 3
is_divisible $1 3 && return 1
is_divisible $1 5 && return 2
return 0
}
# Exit if sourced, run if executed directly
test "${BASH_SOURCE[0]}" != "$0" && return 0
for item in "$@" ; do
echo -n "$item: "
fizzbuzz "$item"
case $? in
0 ) echo $item ;;
1 ) echo Fizz ;;
2 ) echo Buzz ;;
3 ) echo FizzBuzz ;;
* ) echo Error ;;
esac
done
前のコードとメイン部分を比べると以下の様になります。
$ sdiff -w 140 fizzbuzz0[78].bash | tail -16
# Exit if sourced, run if executed directly # Exit if sourced, run if executed directly
test "${BASH_SOURCE[0]}" != "$0" && return 0 test "${BASH_SOURCE[0]}" != "$0" && return 0
for item in "$@" ; do for item in "$@" ; do
echo -n "$item: " echo -n "$item: "
| fizzbuzz "$item"
if [[ "$item" =~ ^[1-9][0-9]*$ ]] ; then | case $? in
is_divisible $item 15 && echo FizzBuzz && continue | 0 ) echo $item ;;
is_divisible $item 3 && echo Fizz && continue | 1 ) echo Fizz ;;
is_divisible $item 5 && echo Buzz && continue | 2 ) echo Buzz ;;
echo $item | 3 ) echo FizzBuzz ;;
else | * ) echo Error ;;
echo Error | esac
fi <
<
done done
メイン処理でチェックしていた自然数か否かのガードif [[ "$item" =~ ^[1-9][0-9]*$ ]] ; then
をfizzbuzz
(正確には更に呼び出すis_natural_number
)に移しました。
そして、メイン処理で呼び出して判断していたis_divisible
部分を、作成したfizzbuzz
関数に移譲することで、メイン関数はfizzbuzzを呼び出し、その結果で判断するだけのロジックになりました。
これで、メインスクリプトの主処理は見通しが良くなったのではないでしょうか。
3-9. fizzbuzz
関数の変更(戻り値から標準出力へ)
後はメインロジックをメイン関数にしたいところですが、Batsでテストを書く上で、標準出力を扱う例を挙げたいので、fizzbuzz
関数を戻り値を扱うコードから、標準出力を扱う様に書き換えてみます。
fizzbuzz
関数の更新前後の仕様は以下の様にします。
引数の値 | 以前の関数の戻り値 | 関数の戻り値 | 関数の標準出力 | 最終的に標準出力に出力する文字列 |
---|---|---|---|---|
15の倍数 | 3 | 0 | FizzBuzz |
FizzBuzz |
3の倍数 | 1 | 0 | Fizz |
Fizz |
5の倍数 | 2 | 0 | Buzz |
Buzz |
その他の自然数 | 0 | 0 | 元の文字列 | 元の文字列 |
自然数以外 | 63 | 1 | `` (空文字) | Error |
関数で扱えない文字はそのまま標準出力へError
と出力しても構わないのですが、例示する関係と、メインロジックが呼び出した関数がエラーの場合を判断できるようにしておいた方が実際のコードでは必要になることもありますので、このようにしています。
今回は、TDDっぽく、テストから書き始めてみましょう。
#! /usr/bin/env bats
BATSLIB=/usr/local/lib
load ${BATSLIB}/bats-support/load
load ${BATSLIB}/bats-assert/load
TARGET=./fizzbuzz09.bash
load $TARGET
@test "is_natural_number" {
run is_natural_number 1; assert_success
run is_natural_number 3; assert_success
run is_natural_number 5; assert_success
run is_natural_number 15; assert_success
run is_natural_number 015; assert_failure 1
run is_natural_number -1; assert_failure 1
run is_natural_number 0; assert_failure 1
run is_natural_number ; assert_failure 1
run is_natural_number a; assert_failure 1
run is_natural_number ''; assert_failure 1
}
@test "is_divisible" {
run is_divisible 1 3; assert_failure 1
run is_divisible 1 5; assert_failure 1
run is_divisible 1 15; assert_failure 1
run is_divisible 3 3; assert_success
run is_divisible 3 5; assert_failure 1
run is_divisible 3 15; assert_failure 1
run is_divisible 5 3; assert_failure 1
run is_divisible 5 5; assert_success
run is_divisible 5 15; assert_failure 1
run is_divisible 15 3; assert_success
run is_divisible 15 5; assert_success
run is_divisible 15 15; assert_success
}
@test "fizzbuzz" {
run fizzbuzz 1; assert_success; assert_output 1
run fizzbuzz 3; assert_success; assert_output Fizz
run fizzbuzz 5; assert_success; assert_output Buzz
run fizzbuzz 15; assert_success; assert_output FizzBuzz
run fizzbuzz 015; assert_failure 1; assert_output ''
run fizzbuzz -1; assert_failure 1; assert_output ''
run fizzbuzz 0; assert_failure 1; assert_output ''
run fizzbuzz ; assert_failure 1; assert_output ''
run fizzbuzz a; assert_failure 1; assert_output ''
run fizzbuzz ''; assert_failure 1; assert_output ''
}
元のfizzbuzz関数のテストから比較的簡単に置き換えることができました。
関数のテストとして、戻り値とassert_output
を使って標準出力をテストする様にしました。
戻り値と出力はどちらを先に書いてもよいですが、縦に並んでいる方が見やすくなることを念頭に置き、出力がFizzBuzz
と永くなるケースがあるので最初に戻り値、後に出力の様に書いています。
assert_outputはこの関数は、指定された期待出力と実際の出力が一致するかテストできます。これはassert_success
, assert_failure
と同じように使用頻度が高いものです。
尚、オプション指定をして--partial
で部分一致、--regexp
で正規表現マッチを利用できます。
assert_outputの使用上の注意点としては、標準エラー出力も拾うことと、関数が複数の行を出力する場合に工夫が必要になること、また、複数の行を出力する場合、空行のテストができないことがあります。
assert_output
は${lines[@]}
に保持される出力結果をチェックする動作をしますが、空行は${lines[@]}
に含まれないというBatsのバグがあるようです。
Warning: Due to a bug in Bats, empty lines are discarded from
${lines[@]}
, causing line indices to change and preventing testing for empty lines.
標準出力以外に標準エラーが含まれることにも注意して下さい。いずれのストリームへの書き出しなのかをテストしたい場合は、一時的にファイルに書き出してからチェックするなどの工夫が必要になります。
なるべく作成する関数は、単一行で出力を複雑にしないよう、シンプルな形に留める方が良いかと思います。
メインスクリプトのfizzbuzz
関数と、メインロジックの関数呼び出し部分を置き換えます。
#! /usr/bin/env bash
function is_natural_number() {
[[ "${1}" =~ ^[1-9][0-9]*$ ]] && return 0
return 1
}
function is_divisible() {
mid=$(( $1 % $2 ))
test $mid -eq 0 && return 0
return 1
}
function fizzbuzz() {
is_natural_number "$1" || return 1
declare -i num=$1
if is_divisible $num 15 ; then
echo FizzBuzz
elif is_divisible $num 3 ; then
echo Fizz
elif is_divisible $num 5 ; then
echo Buzz
else
echo $num
fi
return 0
}
# Exit if sourced, run if executed directly
test "${BASH_SOURCE[0]}" != "$0" && return 0
for item in "$@" ; do
echo -n "$item: "
fizzbuzz "$item"
if [ $? -ne 0 ] ; then
echo Error
fi
done
3-10. main
関数の作成
最後に、メインロジック自体を関数に移します。
Perl, Pythonなどでは、メイン処理を関数にしておくと、全てではなくとも主処理がテストできるようになります。
Batsでは外部コマンドの実行結果をテストできるので、メインの処理を関数化するメリットは薄いかもしれませんが、ここでは関数に移しておき、対応するテストを書きます。
#! /usr/bin/env bash
function is_natural_number() {
[[ "${1}" =~ ^[1-9][0-9]*$ ]] && return 0
return 1
}
function is_divisible() {
mid=$(( $1 % $2 ))
test $mid -eq 0 && return 0
return 1
}
function fizzbuzz() {
is_natural_number "$1" || return 1
declare -i num=$1
if is_divisible $num 15 ; then
echo FizzBuzz
elif is_divisible $num 3 ; then
echo Fizz
elif is_divisible $num 5 ; then
echo Buzz
else
echo $num
fi
return 0
}
function main() {
for item in "$@" ; do
echo -n "$item: "
fizzbuzz "$item"
if [ $? -ne 0 ] ; then
echo Error
fi
done
}
# Exit if sourced, run if executed directly
test "${BASH_SOURCE[0]}" != "$0" && return 0
main "$@"
#! /usr/bin/env bats
BATSLIB=/usr/local/lib
load ${BATSLIB}/bats-support/load
load ${BATSLIB}/bats-assert/load
TARGET=./fizzbuzz10.bash
load $TARGET
@test "is_natural_number" {
run is_natural_number 1; assert_success
run is_natural_number 3; assert_success
run is_natural_number 5; assert_success
run is_natural_number 15; assert_success
run is_natural_number 015; assert_failure 1
run is_natural_number -1; assert_failure 1
run is_natural_number 0; assert_failure 1
run is_natural_number ; assert_failure 1
run is_natural_number a; assert_failure 1
run is_natural_number ''; assert_failure 1
}
@test "is_divisible" {
run is_divisible 1 3; assert_failure 1
run is_divisible 1 5; assert_failure 1
run is_divisible 1 15; assert_failure 1
run is_divisible 3 3; assert_success
run is_divisible 3 5; assert_failure 1
run is_divisible 3 15; assert_failure 1
run is_divisible 5 3; assert_failure 1
run is_divisible 5 5; assert_success
run is_divisible 5 15; assert_failure 1
run is_divisible 15 3; assert_success
run is_divisible 15 5; assert_success
run is_divisible 15 15; assert_success
}
@test "fizzbuzz" {
run fizzbuzz 1; assert_success; assert_output 1
run fizzbuzz 3; assert_success; assert_output Fizz
run fizzbuzz 5; assert_success; assert_output Buzz
run fizzbuzz 15; assert_success; assert_output FizzBuzz
run fizzbuzz 015; assert_failure 1; assert_output ''
run fizzbuzz -1; assert_failure 1; assert_output ''
run fizzbuzz 0; assert_failure 1; assert_output ''
run fizzbuzz ; assert_failure 1; assert_output ''
run fizzbuzz a; assert_failure 1; assert_output ''
run fizzbuzz ''; assert_failure 1; assert_output ''
}
@test "main" {
run main {0..15}
assert_success
assert_equal "${lines[0]}" '0: Error'
assert_equal "${lines[1]}" '1: 1'
assert_equal "${lines[2]}" '2: 2'
assert_equal "${lines[3]}" '3: Fizz'
assert_equal "${lines[4]}" '4: 4'
assert_equal "${lines[5]}" '5: Buzz'
assert_equal "${lines[6]}" '6: Fizz'
assert_equal "${lines[7]}" '7: 7'
assert_equal "${lines[8]}" '8: 8'
assert_equal "${lines[9]}" '9: Fizz'
assert_equal "${lines[10]}" '10: Buzz'
assert_equal "${lines[11]}" '11: 11'
assert_equal "${lines[12]}" '12: Fizz'
assert_equal "${lines[13]}" '13: 13'
assert_equal "${lines[14]}" '14: 14'
assert_equal "${lines[15]}" '15: FizzBuzz'
run main 0 1 ' 2 ' 3 e 5
assert_success
assert_equal "${lines[0]}" '0: Error'
assert_equal "${lines[1]}" '1: 1'
assert_equal "${lines[2]}" ' 2 : Error'
assert_equal "${lines[3]}" '3: Fizz'
assert_equal "${lines[4]}" 'e: Error'
assert_equal "${lines[5]}" '5: Buzz'
}
main
関数のテストは今迄のテストとは毛色を変えて、複数の引数を受けて、複数行の標準出力結果が期待どおりかを確認します。0~15のセットと、0~5の間で途中エラー(無効文字)混入のセット、この2種類をテストしたいとします。
最初のセットではBashのブレース展開(Brace Expansion){0..15}
を使用してmain
関数を呼び出した後に、戻り値が0であることを確認して、各行を直接${lines[i]}
をチェックします。
次は、引数の中にエラーとなる2
, e
を混ぜて無効文字列が含まれている場合でも期待する結果が得られるかチェックしています。
main
関数は処理対象の引数に無効文字がある場合、戻り値を0以外で返してもよさそうですが、ここでは実装していません。
これで一通りのテストが書けました。
テストと一緒にメインスクリプトを作っていくことも効果的ですが、後々、スクリプトを書き換える場合に特に強力に作用します。
テストを実行すれば、期待結果が確実に確認できるので、安心してコードを直せることは非常に重要なことだと思います。コードを修正する度、テストを実行して破壊してしまっていないかチェックし修正することをお勧めします。
3-11. BatsスクリプトがBashスクリプトを動的に読み込むように変更
最後にBatsスクリプトに少し手を入れてみたいと思います。
これまで、Batsスクリプトは以下の様にメインスクリプトを直接指定して読み込んでいました。
$ fgrep TARGET fizzbuzz10.bats
TARGET=./fizzbuzz10.bash
load $TARGET
メインスクリプト名と1:1で拡張子のみ.bash
, .bats
と変えるルールとし、また、今迄はメインスクリプトとBatsスクリプトを同一のディレクトリパスに配置し、カレントディレクトリから実行する前提としていました。
これを、Batsスクリプトから、自分の名前を持つ.bash
スクリプトを自動的にロードできるようにします。また、Batsスクリプトから見て、同じディレクトリパスに.bash
スクリプトファイルがあるか、または、一段上のディレクトリパスにある場合に動作するようにします。
これは、例えば以下のような階層の場合でも動作するようにしたい、ということです。
/home/foo/fizzbuzz.bash
/home/foo/tests/fizzbuzz.bats
以下で、fizzbuzz.bats
というファイルを作ります。
なお、実際の実行に際しては、メインスクリプトは上記のfizzbuzz10.bashと変わりありませんので、ファイル名をfizzbuzz.bash
にリネームかコピーか、シンボリックリンクを貼るなど、対応下さい。
Batsスクリプトで$TARGET
変数へセットするBashスクリプトでのパスをtarget_path
関数で取得するようにしています。
load $TARGET
以降のテストコードは前と変わりありません。
#! /usr/bin/env bats
target_path() {
local test_file_path=${1-${BATS_TEST_FILENAME}}
local dname="${test_file_path%/*}" # dirname
local fname="${test_file_path##*/}" # basename
local target="${fname/.bats/.bash}"
test $fname == $target && return 1
test -f ${dname}/${target} && { echo "${dname}/${target}"; return 0; }
test -f ${dname}/../${target} && { echo "${dname}/../${target}"; return 0; }
return 2
}
BATSLIB=/usr/local/lib
load ${BATSLIB}/bats-support/load
load ${BATSLIB}/bats-assert/load
TARGET=$(target_path $BATS_TEST_FILENAME)
load $TARGET
@test "is_natural_number" {
run is_natural_number 1; assert_success
run is_natural_number 3; assert_success
run is_natural_number 5; assert_success
run is_natural_number 15; assert_success
run is_natural_number 015; assert_failure 1
run is_natural_number -1; assert_failure 1
run is_natural_number 0; assert_failure 1
run is_natural_number ; assert_failure 1
run is_natural_number a; assert_failure 1
run is_natural_number ''; assert_failure 1
}
@test "is_divisible" {
run is_divisible 1 3; assert_failure 1
run is_divisible 1 5; assert_failure 1
run is_divisible 1 15; assert_failure 1
run is_divisible 3 3; assert_success
run is_divisible 3 5; assert_failure 1
run is_divisible 3 15; assert_failure 1
run is_divisible 5 3; assert_failure 1
run is_divisible 5 5; assert_success
run is_divisible 5 15; assert_failure 1
run is_divisible 15 3; assert_success
run is_divisible 15 5; assert_success
run is_divisible 15 15; assert_success
}
@test "fizzbuzz" {
run fizzbuzz 1; assert_success; assert_output 1
run fizzbuzz 3; assert_success; assert_output Fizz
run fizzbuzz 5; assert_success; assert_output Buzz
run fizzbuzz 15; assert_success; assert_output FizzBuzz
run fizzbuzz 015; assert_failure 1; assert_output ''
run fizzbuzz -1; assert_failure 1; assert_output ''
run fizzbuzz 0; assert_failure 1; assert_output ''
run fizzbuzz ; assert_failure 1; assert_output ''
run fizzbuzz a; assert_failure 1; assert_output ''
run fizzbuzz ''; assert_failure 1; assert_output ''
}
@test "main" {
run main {0..15}
assert_success
assert_equal "${lines[0]}" '0: Error'
assert_equal "${lines[1]}" '1: 1'
assert_equal "${lines[2]}" '2: 2'
assert_equal "${lines[3]}" '3: Fizz'
assert_equal "${lines[4]}" '4: 4'
assert_equal "${lines[5]}" '5: Buzz'
assert_equal "${lines[6]}" '6: Fizz'
assert_equal "${lines[7]}" '7: 7'
assert_equal "${lines[8]}" '8: 8'
assert_equal "${lines[9]}" '9: Fizz'
assert_equal "${lines[10]}" '10: Buzz'
assert_equal "${lines[11]}" '11: 11'
assert_equal "${lines[12]}" '12: Fizz'
assert_equal "${lines[13]}" '13: 13'
assert_equal "${lines[14]}" '14: 14'
assert_equal "${lines[15]}" '15: FizzBuzz'
run main 0 1 ' 2 ' 3 e 5
assert_success
assert_equal "${lines[0]}" '0: Error'
assert_equal "${lines[1]}" '1: 1'
assert_equal "${lines[2]}" ' 2 : Error'
assert_equal "${lines[3]}" '3: Fizz'
assert_equal "${lines[4]}" 'e: Error'
assert_equal "${lines[5]}" '5: Buzz'
}
4. 補足
4-1. はじめの補足
ITインフラの仕事をしているとスクリプトが必要になることが多くありますが、「テストを書く」ということをしない人が多い印象を受けます。
幾つかの理由が想像出来ますが、実際にどう使うのかがイメージできないことが多くあるように思います。
この記事では、具体的にBatsを使用してどのようにBashスクリプトとかみ合わせることができるかをひとつの例として挙げてみたいと思います。
4-1-1. 想定する対象読者
業務でBashのシェルスクリプトを書くものの、テストを書いたことがない、Batsは使ったことがない、また、実際にどう使うのか良く分からない方を想定しています。
Linuxの基本的な使用方法、考え方やBashスクリプト自体の書き方はある程度理解していることを前提としています。
ここではスクリプトの書き方自体や、Batsのインストール方法に関しては触れません。
Batsのインストールに関してはやってみれば簡単で、bats-core:Installationで説明されています。また、手順メモを載せておきましたので適宜ご参照ください。
尚、完全な初心者向けではありませんが、幾つか考え方として説明が必要と思われるところは触れるようにしました。
この記事の内容は、Bashスクリプトと対応するBatsの書き方に関するチュートリアルのような形をとっています。(ステップバイステップでコードを更新していく関係上、かなり長い記事になっていることは、ご了承頂ければと思います。)
出来れば、実際にコードを動かして試しながら進めて頂ければと思います。
4-2. 終わりに
此処までお読みいただいた方、お疲れ様でした。
これで終わりです。有難うございました。
一般的なプログラミング言語ではテストを書く、ということが常識になっているように思います。一方で、BashスクリプトでもBatsや他のテストフレームワークでテストを書けますが、実際にどのように使うのかが情報が少ないように思います。
私も永らくBashスクリプトを書いてきましたが、テストを書かずにいました。そしてBatsの存在は知ったものの具体的にどのように自分が実際に作り上げたいBashスクリプトに対応するテストを書いたらいいのか良く分かりませんでした。
最初に悩んだのはメインのBashスクリプトと、独立したBatsテストスクリプトをどのように使い易く書けるのか?でした。
また、使い始めてから悩んだのがBashがシェルという性質上、戻り値と標準出力/標準エラー出力を扱うところです。
Batsを使い始めるにあたり、お役に立てる情報が提示できたのであれば幸いです。
もし、テストを書く、ということ自体が初めてであればなぜ、回りくどいようにわざわざテストを書くのか、そのメリットを感じてもらえたら本当に嬉しく思います。
尚、Bashスクリプトを書くと、外部コマンドを実行したり、ファイルを扱ったりする関係でどのようにテストを書けば良いか、書けない状況に遭遇することもあると思います。
これはBashがシェルという特性というか役割であるために、仕方のないところもあります。
ですが、工夫の仕方によってはテストを書くことができるケースもあります。幾つかのTipsを記載しましたので適宜ご参照下さい。
Batsを併用してBashスクリプトを書くようになると、スクリプトの書き方がかなり変わってくると思います。
私も、永らくUnixのBourne Shellから始め、後にプラットフォームがLinuxになりBashでスクリプトを書いてきて、そこそこなレベルのものが書ける能力を持っていると思っていましたが、テストコードを取り込み始めてからスクリプト書くようになるとスクリプトの質自体が大きく変わったように思います。
私見ではこれは、小さい単位で繰り返し動作を確認できることと、テストに対応させるために機能を関数に小さく纏めるようになるためです。小さい関数に纏めることは性能的には悪くなることもあり得ますが、許容できないほどの速度低下でない限り、小さく関数にする方が有利だと私は考えます。
テストがあるということは、それがパスすれば今正しく機能しているのかを判断できることと、一つの部品/機能に閉じて問題を考えることが出来る方が圧倒的に開発/改修効率が高いと思います。
まずは使い始めてみて、最初は面倒でも繰り返し試してみて頂ければと思います。