0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Batsを利用したBashスクリプトのfizzbuzz

Last updated at Posted at 2025-04-18

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スクリプト

上記を実現するスクリプトなら以下の様に書けます。

fizzbuzz01.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に移します。まず関数を作り、呼び出し部分のみ変更してみます。

fizzbuzz02.bash
#! /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関数の呼び出し箇所を短くする

呼び出し部分をコンパクトになるよう書き換えます。

この書き方は好まない人もいるかもしれませんが、この程度のコードならば可読性も損なわないものと思います。

fizzbuzz03.bash
#! /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テストスクリプトから読み込めるようにします。

fizzbuzz04.bash
#! /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. テストスクリプトを書く

いよいよテストを書きます。分かり易くなる様に、なるべく小さくしました。

fizzbuzz04.bats
#! /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-supportbats-assertをロードしています。もし、/usr/local/lib以外にbats-supportbats-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を期待していることに対して、引数で渡しているものは 32なので、割り切れない結果が返ってきています。
その為、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を指しています。)

fizzbuzz05.bats
#! /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っぽく、先にテストから書いてみます。

fizzbuzz06.bats
#! /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を定義して関数コードを書きながらテストを実行するサイクルを回してみるのが良いと思います。)

fizzbuzz06.bash
#! /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を返す仕様とすることにします。

fizzbuzz07.bash
#! /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
fizzbuzz07.bats
#! /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. 作成した関数を呼び出すようにメインスクリプトを書き換える

では、メインスクリプトを作成した関数を利用するように書き換えます。

fizzbuzz08.bash
#! /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]*$ ]] ; thenfizzbuzz(正確には更に呼び出す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っぽく、テストから書き始めてみましょう。

fizzbuzz09.bats
#! /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関数と、メインロジックの関数呼び出し部分を置き換えます。

fizzbuzz09.bash
#! /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では外部コマンドの実行結果をテストできるので、メインの処理を関数化するメリットは薄いかもしれませんが、ここでは関数に移しておき、対応するテストを書きます。

fizzbuzz10.bash
#! /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 "$@"
fizzbuzz10.bats
#! /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以降のテストコードは前と変わりありません。

fizzbuzz.bats
#! /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でスクリプトを書いてきて、そこそこなレベルのものが書ける能力を持っていると思っていましたが、テストコードを取り込み始めてからスクリプト書くようになるとスクリプトの質自体が大きく変わったように思います。

私見ではこれは、小さい単位で繰り返し動作を確認できることと、テストに対応させるために機能を関数に小さく纏めるようになるためです。小さい関数に纏めることは性能的には悪くなることもあり得ますが、許容できないほどの速度低下でない限り、小さく関数にする方が有利だと私は考えます。
テストがあるということは、それがパスすれば今正しく機能しているのかを判断できることと、一つの部品/機能に閉じて問題を考えることが出来る方が圧倒的に開発/改修効率が高いと思います。

まずは使い始めてみて、最初は面倒でも繰り返し試してみて頂ければと思います。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?