はじめに
表題の通り、シェルスクリプトで自動販売機を作ってみたいと思います。
仕様はざっくり以下の通りとします。
- 硬貨を投入できる
- 投入金額がいくらか知ることができる
- 飲み物が何種類か用意されている
- 投入金額が足りていれば飲み物を買える
- 投入金額が不足していれば飲み物を買えない
- 投入されたお金は返却できる
環境
- Ubuntu on WSL2 (Windows 11)
- GNU bash, version 5.1.16(1)-release (x86_64-pc-linux-gnu)
- Docker
事前準備
作業ディレクトリを作成する
適当な場所にディレクトリを作成して、そこで作業します。
mkdir -vp vending-machine && cd $_
テストフレームワークを用意する
今回はシェルスクリプトの BDD フレームワークである ShellSpec を Docker で利用したいと思います。
docker pull shellspec/shellspec:latest
初期化を行います。
docker run -it --rm -u $(id -u):$(id -g) -v "$PWD:/src" shellspec/shellspec --init
$ docker run -it --rm -u $(id -u):$(id -g) -v "$PWD:/src" shellspec/shellspec --init
create /src/.shellspec
create /src/spec/spec_helper.sh
テストを実行します。
$ docker run -it --rm -u $(id -u):$(id -g) -v "$PWD:/src" shellspec/shellspec
Running: /bin/sh [sh]
Finished in 0.07 seconds (user 0.05 seconds, sys 0.00 seconds)
0 examples, 0 failures
正常に動作しました
コマンドが長いので shellspec
というエイリアスを作って呼び出せるようにしてみましょう。
alias shellspec='docker run -it --rm -u $(id -u):$(id -g) -v "$PWD:/src" shellspec/shellspec'
書き終わったらプロファイルをロードしなおして shellspec
で実行してみます。
$ shellspec
Running: /bin/sh [sh]
Finished in 0.04 seconds (user 0.04 seconds, sys 0.00 seconds)
0 examples, 0 failures
正常に動作しました
実装開始
前置きが長くなりましたが、実装していきたいと思います。
テストケースは spec/vending_machine_spec.sh
に書いていきます。
預り金を照会する
初期状態で自動販売機に預けているお金は 0 円なので、deposit
を実行したら 0
を返してくるように作ってみたいと思います。
# shellcheck shell=sh
Describe "vending_machine.sh"
Include lib/vending_machine.sh
It "initially deposits 0 yen"
When call deposit
The output should eq 0
End
End
テストを実行します。
$ shellspec
/bin/sh: .: line 35: can't open 'lib/vending_machine.sh': No such file or directory
Unexpected output to stderr occurred at line 3-4 in 'spec/vending_machine_spec.sh'
Running: /bin/sh [sh]
Finished in 0.19 seconds (user 0.06 seconds, sys 0.00 seconds)
0 examples, 0 failures, aborted by an unexpected error
Aborted with status code [executor: 2] [reporter: 1] [error handler: 102]
Fatal error occurred, terminated with exit status 102.
lib/vending_machine.sh
を作っていませんでした。作ってみます。
# shellcheck shell=sh
$ shellspec
Running: /bin/sh [sh]
F
Examples:
1) vending_machine.sh initially deposits 0 yen
When call deposit
1.1) The output should eq 0
expected: 0
got: ""
# spec/vending_machine_spec.sh:7
1.2) WARNING: It exits with status non-zero but not found expectation
status: 127
# spec/vending_machine_spec.sh:5-8
1.3) WARNING: There was output to stderr but not found expectation
stderr: /bin/sh: deposit: not found
# spec/vending_machine_spec.sh:5-8
Finished in 0.07 seconds (user 0.04 seconds, sys 0.01 seconds)
1 example, 1 failure
Failure examples / Errors: (Listed here affect your suite's status)
shellspec spec/vending_machine_spec.sh:5 # 1) vending_machine.sh initially deposits 0 yen FAILED
1.3) で deposit
が見つからないと言われたので作ります。
deposit() {
:
}
$ shellspec
Running: /bin/sh [sh]
F
Examples:
1) vending_machine.sh initially deposits 0 yen
When call deposit
1.1) The output should eq 0
expected: 0
got: ""
# spec/vending_machine_spec.sh:7
Finished in 0.07 seconds (user 0.06 seconds, sys 0.00 seconds)
1 example, 1 failure
Failure examples / Errors: (Listed here affect your suite's status)
shellspec spec/vending_machine_spec.sh:5 # 1) vending_machine.sh initially deposits 0 yen FAILED
1.1) で出力結果が異なると言われたので、deposit
関数を以下のように修正します。
deposit() {
echo 0
}
$ shellspec
Running: /bin/sh [sh]
.
Finished in 0.06 seconds (user 0.05 seconds, sys 0.00 seconds)
1 example, 0 failures
おめでとうございます
エンジニア界隈では、テストが一つ通るたびに「おめでとうございます」と言い、拍手やクラッカー等でテストの成功を祝う風習があります。よろしくお願いします。
100 円玉を 1 枚投入する
次に 100 円玉を 1 枚投入してみましょう。投入するメソッドは insert
とします。
テストファイルの Describe
句に以下のコードを追加します。
It "insert 100 yen, then deposit is 100 yen"
insert 100
When call deposit
The output should eq 100
End
$ shellspec
Running: /bin/sh [sh]
.F
Examples:
1) vending_machine.sh insert 100 yen, then deposit is 100 yen
1.1) Example aborted (exit status: 127)
/bin/sh: insert: not found
# spec/vending_machine_spec.sh:11-15
Finished in 0.07 seconds (user 0.06 seconds, sys 0.00 seconds)
2 examples, 1 failure
Failure examples / Errors: (Listed here affect your suite's status)
shellspec spec/vending_machine_spec.sh:11 # 1) vending_machine.sh insert 100 yen, then deposit is 100 yen FAILED
insert
メソッドを作成します。
insert() {
:
}
$ shellspec
Running: /bin/sh [sh]
.F
Examples:
1) vending_machine.sh insert 100 yen, then deposit is 100 yen
When call deposit
1.1) The output should eq 100
expected: 100
got: 0
# spec/vending_machine_spec.sh:14
Finished in 0.07 seconds (user 0.05 seconds, sys 0.00 seconds)
2 examples, 1 failure
Failure examples / Errors: (Listed here affect your suite's status)
shellspec spec/vending_machine_spec.sh:11 # 1) vending_machine.sh insert 100 yen, then deposit is 100 yen FAILED
insert
したら預り金を増やす処理を書きます。
コードを以下のように変更します。
# shellcheck shell=sh
_deposit=0
deposit() {
echo $_deposit
}
insert() {
money=$1
_deposit=$((_deposit + money))
}
$ shellspec
Running: /bin/sh [sh]
..
Finished in 0.06 seconds (user 0.06 seconds, sys 0.00 seconds)
2 examples, 0 failures
おめでとうございます
180 円分の硬貨を投入する
ペットボトルのコーラを飲みたいんですが、最近自販機のコーラは 180 円 ととても高価 なので、 仕方なく 180 円入れてみたいと思います。
It "insert a 100 yen coin, a 50 yen coin, and three 10 yen coins -> then deposit is 180 yen"
insert 100
insert 50
insert 10
insert 10
insert 10
When call deposit
The output should eq 180
End
$ shellspec
Running: /bin/sh [sh]
...
Finished in 0.06 seconds (user 0.06 seconds, sys 0.00 seconds)
3 examples, 0 failures
おめでとうございます
コーラを買う
お待たせいたしました。180 円を入れてコーラを買ってみたいと思います。
180 円入れて 180 円のコーラを buy cola
で買うと cola
がリターンされ、預り金は 0 円になるはずです。
It "insert 180 yen -> buy cola (180 yen) -> returns cola and deposit is 0 yen"
insert 100
insert 50
insert 10
insert 10
insert 10
When call buy cola
The output should eq "cola"
When call deposit
The output should eq 0
End
$ shellspec
Running: /bin/sh [sh]
...F
Examples:
1) vending_machine.sh insert 180 yen -> buy cola (180 yen) -> returns cola and deposit is 0 yen
When call buy cola
1.1) The output should eq cola
expected: "cola"
got: ""
# spec/vending_machine_spec.sh:34
1.2) When call deposit
Evaluation has already been executed. Only one Evaluation allow per Example.
(Use 'parameterized example' if you want a loop)
# spec/vending_machine_spec.sh:35
1.3) The output should eq 0
expected: 0
got: ""
# spec/vending_machine_spec.sh:36
1.4) WARNING: It exits with status non-zero but not found expectation
status: 127
# spec/vending_machine_spec.sh:27-37
1.5) WARNING: There was output to stderr but not found expectation
stderr: /bin/sh: buy: not found
# spec/vending_machine_spec.sh:27-37
Finished in 0.07 seconds (user 0.06 seconds, sys 0.00 seconds)
4 examples, 1 failure
Failure examples / Errors: (Listed here affect your suite's status)
shellspec spec/vending_machine_spec.sh:27 # 1) vending_machine.sh insert 180 yen -> buy cola (180 yen) -> returns cola and deposit is 0 yen FAILED
1.2) で Evaluation は 1 つまでだと言われました。どうやら When を 2 つ以上書いてはいけないようです。
テストケースを「コーラを買うまで」と「コーラを買った後に預り金を確認するまで」の 2 つに分けます。
Context "buy cola"
insert 100
insert 50
insert 10
insert 10
insert 10
It "returns cola"
When call buy cola
The output should eq "cola"
End
It "deposit is 0 yen"
buy cola
When call deposit
The output should eq 0
End
End
$ shellspec
Running: /bin/sh [sh]
...FF
Examples:
1) vending_machine.sh buy cola returns cola
When call buy cola
1.1) The output should eq cola
expected: "cola"
got: ""
# spec/vending_machine_spec.sh:35
1.2) WARNING: It exits with status non-zero but not found expectation
status: 127
# spec/vending_machine_spec.sh:33-36
1.3) WARNING: There was output to stderr but not found expectation
stderr: /bin/sh: buy: not found
# spec/vending_machine_spec.sh:33-36
2) vending_machine.sh buy cola deposit is 0 yen
2.1) Example aborted (exit status: 127)
/bin/sh: buy: not found
# spec/vending_machine_spec.sh:37-41
Finished in 0.08 seconds (user 0.06 seconds, sys 0.00 seconds)
5 examples, 2 failures
Failure examples / Errors: (Listed here affect your suite's status)
shellspec spec/vending_machine_spec.sh:33 # 1) vending_machine.sh buy cola returns cola FAILED
shellspec spec/vending_machine_spec.sh:37 # 2) vending_machine.sh buy cola deposit is 0 yen FAILED
先ほどのエラーが消えました
実装してみます。とりあえず buy
メソッドが呼ばれたら預り金を 180 円減らして cola を返してみます。
buy() {
kind=$1
_deposit=$((_deposit - 180))
echo "cola"
}
$ shellspec
Running: /bin/sh [sh]
...cola
..
Finished in 0.10 seconds (user 0.08 seconds, sys 0.01 seconds)
5 examples, 0 failures
水を買う
別の飲み物も買ってみましょう。
120 円入れて 120 円の水を買います。
Context "buy water"
insert 100
insert 10
insert 10
It "returns water"
When call buy water
The output should eq "water"
End
It "deposit is 0 yen"
buy water
When call deposit
The output should eq 0
End
End
$ shellspec
Running: /bin/sh [sh]
...cola
..cola
FF
Examples:
1) vending_machine.sh buy water returns water
When call buy water
1.1) The output should eq water
expected: "water"
got: "cola"
# spec/vending_machine_spec.sh:50
2) vending_machine.sh buy water deposit is 0 yen
When call deposit
2.1) The output should eq 0
expected: 0
got: "-60"
# spec/vending_machine_spec.sh:55
Finished in 0.09 seconds (user 0.07 seconds, sys 0.01 seconds)
7 examples, 2 failures
Failure examples / Errors: (Listed here affect your suite's status)
shellspec spec/vending_machine_spec.sh:48 # 1) vending_machine.sh buy water returns water FAILED
shellspec spec/vending_machine_spec.sh:52 # 2) vending_machine.sh buy water deposit is 0 yen FAILED
コーラが出てきたうえに預り金が -60 円となってしまいました。
buy
メソッドの実装を修正しましょう。
buy() {
kind=$1
if [ "$kind" = "cola" ]; then
price=180
elif [ "$kind" = "water" ]; then
price=120
fi
_deposit=$((_deposit - price))
echo "$kind"
}
$ shellspec
Running: /bin/sh [sh]
...cola
..water
..
Finished in 0.14 seconds (user 0.12 seconds, sys 0.00 seconds)
7 examples, 0 failures
おめでとうございます
飲み物を買うテストケースをパラメータ化する
コーラ・水を買うテストケースを別々に作りましたので、パラメタライズしてみましょう。
Describe "buy something with enough money"
Parameters
# description coins kind expected_deposit
"buy cola" 100,50,10,10,10 cola 0
"buy water" 100,10,10 water 0
End
insert_coins() {
_coins=$1
for coin in $(echo "$_coins" | sed -e 's/,/ /g'); do
insert "$coin"
done
}
It "$1: returns $3"
insert_coins "$2"
When call buy "$3"
The output should eq "$3"
End
It "$1: deposit is $4 yen"
insert_coins "$2"
buy "$3"
When call deposit
The output should eq "$4"
End
End
$ shellspec
Running: /bin/sh [sh]
....cola
.water
..
Finished in 0.11 seconds (user 0.06 seconds, sys 0.01 seconds)
7 examples, 0 failures
成功しました。おめでとうございます
自販機に入れた金額が飲み物の値段より多い場合
次も水を 120 円で買うのですが、200 円投入してみます。購入後の預り金は 80 円となるはずです。
パラメータに以下の行を追加します。
"insert 200 yen and buy water" 100,100 water 80
$ shellspec
Running: /bin/sh [sh]
.....cola
.water
.water
..
Finished in 0.12 seconds (user 0.11 seconds, sys 0.00 seconds)
9 examples, 0 failures
おめでとうございます
自販機に入れた金額が飲み物の値段より少ない場合
次は投入金額が足りない状態で飲み物を買います。
この場合は自販機が NOT ENOUGH MONEY!!
と叫ぶことにします。このときの預り金は変化しません。
テストコードに以下の行を追加します。なお、insert_coins
メソッドの定義はファイル冒頭に移動しました。
Describe "buy something with not-enough money"
Parameters
# description coins kind expected_deposit
"insert 100 yen and buy cola" 100 cola 100
"insert 110 yen and buy water" 100,10 water 110
End
It "$1: returns $3"
insert_coins "$2"
When call buy "$3"
The output should eq "NOT ENOUGH MONEY!!"
End
It "$1: deposit is $4 yen"
insert_coins "$2"
buy "$3"
When call deposit
The output should eq "$4"
End
End
$ shellspec
Running: /bin/sh [sh]
.....cola
.water
.water
..Fcola
Fwater
FF
Examples:
1) vending_machine.sh buy something with not-enough money insert 100 yen and buy cola: returns cola
When call buy cola
1.1) The output should eq NOT ENOUGH MONEY!!
expected: "NOT ENOUGH MONEY!!"
got: "cola"
# spec/vending_machine_spec.sh:65
2) vending_machine.sh buy something with not-enough money insert 110 yen and buy water: returns water
When call buy water
2.1) The output should eq NOT ENOUGH MONEY!!
expected: "NOT ENOUGH MONEY!!"
got: "water"
# spec/vending_machine_spec.sh:65
3) vending_machine.sh buy something with not-enough money insert 100 yen and buy cola: deposit is 100 yen
When call deposit
3.1) The output should eq 100
expected: 100
got: "-80"
# spec/vending_machine_spec.sh:71
4) vending_machine.sh buy something with not-enough money insert 110 yen and buy water: deposit is 110 yen
When call deposit
4.1) The output should eq 110
expected: 110
got: "-10"
# spec/vending_machine_spec.sh:71
Finished in 0.15 seconds (user 0.12 seconds, sys 0.02 seconds)
13 examples, 4 failures
Failure examples / Errors: (Listed here affect your suite's status)
shellspec spec/vending_machine_spec.sh:62 # 1) vending_machine.sh buy something with not-enough money insert 100 yen and buy cola: returns cola FAILED
shellspec spec/vending_machine_spec.sh:62 # 2) vending_machine.sh buy something with not-enough money insert 110 yen and buy water: returns water FAILED
shellspec spec/vending_machine_spec.sh:67 # 3) vending_machine.sh buy something with not-enough money insert 100 yen and buy cola: deposit is 100 yen FAILED
shellspec spec/vending_machine_spec.sh:67 # 4) vending_machine.sh buy something with not-enough money insert 110 yen and buy water: deposit is 110 yen FAILED
飲み物が普通に買えてしまいました。buy
メソッドを以下のように修正します。
buy() {
kind=$1
if [ "$kind" = "cola" ]; then
price=180
elif [ "$kind" = "water" ]; then
price=120
fi
if [ $_deposit -lt "$price" ]; then
echo "NOT ENOUGH MONEY!!"
return
fi
_deposit=$((_deposit - price))
echo "$kind"
}
$ shellspec
Running: /bin/sh [sh]
.....cola
.water
.water
...NOT ENOUGH MONEY!!
.NOT ENOUGH MONEY!!
..
Finished in 0.18 seconds (user 0.13 seconds, sys 0.01 seconds)
13 examples, 0 failures
おめでとうございます
預り金を返却する
最後に、自販機に残った預り金を返却する機能を実装します。
今回は 0 円、20 円、880 円の 3 ケース用意します。
テストコードに以下の行を追加します。
私の環境ではデバイスを取り外す eject
コマンドがすでに存在しますので、自販機から返却するメソッドは eject_
メソッドとします。
Describe "eject money"
Parameters
# description coins deposit
"0 yen" "" 0
"20 yen" 10,10 20
"110 yen" 100,10 110
End
It "$1: eject, then gets $3 yen"
insert_coins "$2"
When call eject_
The output should eq "$3"
End
End
出力が長いので抜粋版です。
$ shellspec
Running: /bin/sh [sh]
.....cola
.water
.water
...NOT ENOUGH MONEY!!
.NOT ENOUGH MONEY!!
..FFF
Examples:
1) vending_machine.sh eject money 0 yen: eject, then gets 0 yen
When call eject_
1.1) The output should eq 0
expected: 0
got: ""
# spec/vending_machine_spec.sh:86
1.2) WARNING: It exits with status non-zero but not found expectation
status: 127
# spec/vending_machine_spec.sh:83-87
1.3) WARNING: There was output to stderr but not found expectation
stderr: /bin/sh: eject_: not found
# spec/vending_machine_spec.sh:83-87
eject_
メソッドを実装します。
eject_() {
return_money=$_deposit
_deposit=0
echo $return_money
}
$ shellspec
Running: /bin/sh [sh]
.....cola
.water
.water
...NOT ENOUGH MONEY!!
.NOT ENOUGH MONEY!!
.....
Finished in 0.15 seconds (user 0.12 seconds, sys 0.01 seconds)
16 examples, 0 failures
おめでとうございます
飲み物を買ったのち、余ったお金を返却する
ここまでの総まとめとして、お金を入れて飲み物を買い、余ったお金を返却するテストケースを書きます。
ケースは以下の通りとします。
- 120 円を入れて 120 円の水を買い、お金を返却する
- 200 円を入れて 180 円のコーラを買い、お金を返却する
- 500 円を入れて 180 円のコーラと 120 円の水を買い、お金を返却する
テストコードに以下の行を追加します。
- 飲み物を複数買うメソッド
buy_something() {
_order=$1
for kind in $(echo "$_order" | sed -e 's/,/ /g'); do
buy "$kind"
done
}
- テストケース
Describe "insert coins, buy something, then eject money"
Parameters
# description coins kinds change
"insert 120 yen, buy a water" 100,10,10 water "water 0"
"insert 200 yen, buy a cola" 100,100 cola "cola 20"
"insert 500 yen, buy a cola and a water" 500 cola,water "cola water 200"
End
It "$1"
insert_coins "$2"
When call echo "$( (
buy_something "$3"
eject_
) | tr '\n' ' ' | sed -e 's/ $//')"
The output should eq "$4"
End
End
$ shellspec
Running: /bin/sh [sh]
.....cola
.water
.water
...NOT ENOUGH MONEY!!
.NOT ENOUGH MONEY!!
........
Finished in 0.19 seconds (user 0.15 seconds, sys 0.00 seconds)
19 examples, 0 failures
おめでとうございます
所感
シェルで TDD ってできるんですね。
TDD 楽しいのでみなさんもやりましょう。
おまけ
完成品
# shellcheck shell=sh
Describe "vending_machine.sh"
Include lib/vending_machine.sh
insert_coins() {
_coins=$1
for coin in $(echo "$_coins" | sed -e 's/,/ /g'); do
insert "$coin"
done
}
buy_something() {
_order=$1
for kind in $(echo "$_order" | sed -e 's/,/ /g'); do
buy "$kind"
done
}
It "initially deposits 0 yen"
When call deposit
The output should eq 0
End
It "insert 100 yen, then deposit is 100 yen"
insert 100
When call deposit
The output should eq 100
End
It "insert a 100 yen coin, a 50 yen coin, and three 10 yen coins -> then deposit is 180 yen"
insert 100
insert 50
insert 10
insert 10
insert 10
When call deposit
The output should eq 180
End
Describe "buy something with enough money"
Parameters
# description coins kind expected_deposit
"buy cola" 100,50,10,10,10 cola 0
"buy water" 100,10,10 water 0
"insert 200 yen and buy water" 100,100 water 80
End
It "$1: returns $3"
insert_coins "$2"
When call buy "$3"
The output should eq "$3"
End
It "$1: deposit is $4 yen"
insert_coins "$2"
buy "$3"
When call deposit
The output should eq "$4"
End
End
Describe "buy something with not-enough money"
Parameters
# description coins kind expected_deposit
"insert 100 yen and buy cola" 100 cola 100
"insert 110 yen and buy water" 100,10 water 110
End
It "$1: returns $3"
insert_coins "$2"
When call buy "$3"
The output should eq "NOT ENOUGH MONEY!!"
End
It "$1: deposit is $4 yen"
insert_coins "$2"
buy "$3"
When call deposit
The output should eq "$4"
End
End
Describe "eject money"
Parameters
# description coins deposit
"0 yen" "" 0
"20 yen" 10,10 20
"110 yen" 100,10 110
End
It "$1: eject, then gets $3 yen"
insert_coins "$2"
When call eject_
The output should eq "$3"
End
End
Describe "insert coins, buy something, then eject money"
Parameters
# description coins kinds change
"insert 120 yen, buy a water" 100,10,10 water "water 0"
"insert 200 yen, buy a cola" 100,100 cola "cola 20"
"insert 500 yen, buy a cola and a water" 500 cola,water "cola water 200"
End
It "$1"
insert_coins "$2"
When call echo "$( (
buy_something "$3"
eject_
) | tr '\n' ' ' | sed -e 's/ $//')"
The output should eq "$4"
End
End
End
# shellcheck shell=sh
_deposit=0
deposit() {
echo $_deposit
}
insert() {
money=$1
_deposit=$((_deposit + money))
}
buy() {
kind=$1
if [ "$kind" = "cola" ]; then
price=180
elif [ "$kind" = "water" ]; then
price=120
fi
if [ $_deposit -lt "$price" ]; then
echo "NOT ENOUGH MONEY!!"
return
fi
_deposit=$((_deposit - price))
echo "$kind"
}
eject_() {
return_money=$_deposit
_deposit=0
echo $return_money
}