Tags
Here’s one for the programmers in my audience. At a recent software crafters meetup, someone brought up the fizzbuzz coding exercise, and how funny it would be to code in bash. Examples of solutions in bash were easy to find, but I didn’t see any that included unit tests. So I tried a test-driven (TDD) solution for fizzbuzz in bash. Here’s how it went.
I updated Bats using homebrew on my Mac. There is now a GitHub organization serving as a home for Bats. I uninstalled an older Bats version I already had, made sure to remove the old tap (“kaos/shell”), and reinstalled from its new home using the brew instructions:
$ brew install bats-core
...
==> Installing bats-core
==> Pouring bats-core--1.11.0.all.bottle.tar.gz
Error: The `brew link` step did not complete successfully
The formula built, but is not symlinked into /usr/local
...
Pay attention to those errors (the “Error:” label was in red on my terminal, but it was buried in a large amount of other log output). I needed to follow the instructions in the error output before I had the new bats-core in my path:
brew link --overwrite bats-core
I also wanted to use the bats-assert library, which depends on bats-support, so I ran:
$ brew tap bats-core/bats-core
$ brew install bats-support
$ brew install bats-assert
The fizzbuzz exercise asks for 100 lines of output, printing out the numbers 1 to 100, with these modifications: if the number is divisible by 3, print “fizz” instead of the number. If the number is divisible by 5, print “buzz”, and if it’s divisible by both 3 and 5, print “fizzbuzz”. Rather than try to process 100 lines of output in each test, I planned to write a function to return one number in the sequence. I started with this test in a test subdirectory:
#!/usr/bin/env bats
load '/usr/local/lib/bats-support/load.bash'
load '/usr/local/lib/bats-assert/load.bash'
setup() {
source "../fizzbuzz.bash"
}
@test "fizzbuzz 1 returns 1" {
run fizzbuzz 1
assert_success
assert_output 1
}
I created a dummy function in fizzbuzz.bash so the test could run:
#!/usr/bin/env bash
function fizzbuzz {
:
}
And now the test does its job:
✗ fizzbuzz 1 returns 1
(from function assert_success' in file /usr/local/lib/bats-assert/src/assert_success.bash, line 42, in test file fizzbuzz-test.bash, line 12) assert_success' failed
-- command failed --
status : 1
output :
--
1 test, 1 failure
Getting the test to pass was easy enough:
function fizzbuzz {
echo 1
}
Not shown below is the satisfying green color on the last line showing 0 failures (it was red before):
$ ./fizzbuzz-test.bash
fizzbuzz-test.bash
✓ fizzbuzz 1 returns 1
1 test, 0 failures
Next I added a test to the test script to triangulate and force a more useful implementation:
@test "fizzbuzz 2 returns 2" {
run fizzbuzz 2
assert_success
assert_output 2
}
The first test passes, and the new one fails as expected. A simple change to the function makes both tests happy:
function fizzbuzz {
local num="$1"
echo "$num"
}
Now finally a test that makes it interesting:
@test "fizzbuzz 3 returns fizz" {
run fizzbuzz 3
assert_success
assert_output fizz
}
Some simplistic logic gets the test to pass:
function fizzbuzz {
local num="$1"
if [[ "$num" = 3 ]]; then
echo fizz
return
fi
echo "$num"
}
So we triangulate again:
@test "fizzbuzz 6 returns fizz" {
run fizzbuzz 6
assert_success
assert_output fizz
}
And that drives a full solution for the “fizz” part of the problem:
function fizzbuzz {
local num="$1"
if [[ $((num % 3)) = 0 ]]; then
echo fizz
return
fi
echo "$num"
}
On to the “buzz” part of the challenge:
@test "fizzbuzz 5 returns buzz" {
run fizzbuzz 5
assert_success
assert_output buzz
}
Here’s what the test output looks like now:
$ ./fizzbuzz-test.bash
fizzbuzz-test.bash
✓ fizzbuzz 1 returns 1
✓ fizzbuzz 2 returns 2
✓ fizzbuzz 3 returns fizz
✓ fizzbuzz 6 returns fizz
✗ fizzbuzz 5 returns buzz
(from function assert_output' in file /usr/local/lib/bats-assert/src/assert_output.bash, line 194, in test file fizzbuzz-test.bash, line 37) assert_output buzz' failed
-- output differs --
expected : buzz
actual : 5
--
5 tests, 1 failure
Again I did a simple implementation that would require triangulation to complete:
function fizzbuzz {
local num="$1"
if [[ $((num % 3)) = 0 ]]; then
echo fizz
elif [[ $num = 5 ]]; then
echo buzz
else
echo "$num"
fi
}
One more test to triangulate:
@test "fizzbuzz 10 returns buzz" {
run fizzbuzz 10
assert_success
assert_output buzz
}
A little tweak makes the test pass:
function fizzbuzz {
local num="$1"
if [[ $((num % 3)) = 0 ]]; then
echo fizz
elif [[ $((num % 5 )) = 0 ]]; then
echo buzz
else
echo "$num"
fi
}
Now finally, one more test for the “fizzbuzz” output:
@test "fizzbuzz 15 returns fizzbuzz" {
run fizzbuzz 15
assert_success
assert_output fizzbuzz
}
My solution was unsatisfying, but it worked:
function fizzbuzz {
local num=$1
if [[ $((num % 3)) != 0 && $((num % 5)) != 0 ]]; then
echo "$num"
return
fi
if [[ $((num % 3)) = 0 ]]; then
echo -n fizz
fi
if [[ $((num % 5)) = 0 ]]; then
echo -n buzz
fi
echo
}
I was happier after a refactor to remove the redundant logic:
function fizzbuzz {
local num="$1"
local output=""
if [[ $((num % 3)) = 0 ]]; then
output=fizz
fi
if [[ $((num % 5)) = 0 ]]; then
output="${output}buzz"
fi
if [[ -z "$output" ]]; then
output="$num"
fi
echo $output
}
There are far more concise solutions out there, but I like the readability of my solution. Now, as for getting 100 lines of output when running the scripts directly, I tacked this to the end of my script. This allowed it to meet this requirement while still not affecting how the unit tests work with the fizzbuzz function at all:
if [ "${BASH_SOURCE[0]}" -ef "$0" ]
then
for i in $(seq 1 100); do
fizzbuzz "$i"
done
fi
I tested this chunk of code manually rather than try to automate a test for it.
$ ./fizzbuzz.bash
1
2
fizz
4
buzz
fizz
7
...
And there you have it, a testable fizzbuzz in bash. Some people add a few extra rules to continue the fizzbuzz exercise, and I’m confident that the tests would help support any further additions to the code.
Further reading: I first wrote about the Bats unit test framework in “Going bats with bash unit testing“.
Pingback: Five for Friday – June 7, 2024 – Tooth of the Weasel