Writing shell scripts is very much like programming. Some scripts require little time investment; whereas, other complex scripts may require thought, planning and a larger commitment. From this perspective, it makes sense to take a test-driven approach and unit test our shell scripts.
To get the most out of this tutorial, you need to be familiar with the command line interface (CLI); you may want to check out the The Command Line is Your Best Friend tutorial if you need a refresher. You also need a basic understanding of Bash-like shell scripting. Finally, you may want to familiarize yourself with the test-driven development (TDD) concepts and unit testing in general; be sure to check out these Test-Driven PHP tutorials to get the basic idea.
Prepare the Programming Environment
First, you need a text editor to write your shell scripts and unit tests. Use your favorite!
We will use the shUnit2 shell unit testing framework to run our unit tests. It was designed for, and works with, Bash-like shells. shUnit2 is an open source framework released under the GPL license, and a copy of the framework is also included with this tutorial's sample source code.
Installing shUnit2 is very easy; simply download and extract the archive to any location on your hard drive. It is written in Bash, and as such, the framework consists of only script files. If you plan to frequently use shUnit2, I highly recommend that you put it in a location in your PATH.
Writing our First Test
For this tutorial, extract shUnit into a directory with the same name in your Sources
folder (see the code attached to this tutorial). Create a Tests
folder inside Sources
and added a new file call firstTest.sh
.
#! /usr/bin/env sh ### firstTest.sh ### function testWeCanWriteTests () { assertEquals "it works" "it works" } ## Call and Run all Tests . "../shunit2-2.1.6/src/shunit2"
Than make your test file executable.
$ cd __your_code_folder__/Tests $ chmod +x firstTest.sh
Now you can simply run it and observe the output:
$ ./firstTest.sh testWeCanWriteTests Ran 1 test. OK
It says we ran one successful test. Now, let's cause the test to fail; change the assertEquals
statement so that the two strings are not the same and run the test again:
$ ./firstTest.sh testWeCanWriteTests ASSERT:expected:<it works> but was:<it does not work> Ran 1 test. FAILED (failures=1)
A Tennis Game
You write acceptance tests at the beginning of a project/feature/story when you can clearly define a specific requirement.
Now that we have a working testing environment, let's write a script that reads a file, makes decisions based on the file's contents and outputs information to the screen.
The main goal of the script is to show the score of a tennis game between two players. We will concentrate only on keeping the score of a single game; everything else is up to you. The scoring rules are:
- At the beginning, each player has a score of zero, called "love"
- First, second and third balls won are marked as "fifteen", "thirty", and "forty".
- If at "forty" the score is equal, it is called "deuce".
- After this, the score is kept as "Advantage" for the player who scores one more point than the other player.
- A player is the winner if he manages to have an advantage of at least two points and wins at least three points (that is, if he reached at least "forty").
Definition of Input and Output
Our application will read the score from a file. Another system will push the information into this file. The first line of this data file will contain the names of the players. When a player scores a point, their name is written at the end of the file. A typical score file looks like this:
John - Michael John John Michael John Michael Michael John John
You can find this content in the input.txt
file in the Source
folder.
The output of our program writes the score to the screen one line at a time. The output should be:
John - Michael John: 15 - Michael: 0 John: 30 - Michael: 0 John: 30 - Michael: 15 John: 40 - Michael: 15 John: 40 - Michael: 30 Deuce John: Advantage John: Winner
This output can be also found in the output.txt
file. We will use this information to check if our program is correct.
The Acceptance Test
You write acceptance tests at the beginning of a project/feature/story when you can clearly define a specific requirement. In our case, this test simply calls our soon-to-be-created script with the name of the input file as the parameter, and it expects the output to be identical with the hand-written file from the previous section:
#! /usr/bin/env sh ### acceptanceTest.sh ### function testItCanProvideAllTheScores () { cd .. ./tennisGame.sh ./input.txt > ./results.txt diff ./output.txt ./results.txt assertTrue 'Expected output differs.' $? } ## Call and Run all Tests . "../shunit2-2.1.6/src/shunit2"
We will run our tests in the Source/Tests
folder; therefore, cd ..
takes us into the Source
directory. Then it tries to run tennisGamse.sh
, which does not yet exist. Then the diff
command will compare the two files: ./output.txt
is our hand-written output and ./results.txt
will contain the result of our script. Finally, assertTrue
checks the exit value of diff
.
But for now, our test returns the following error:
$ ./acceptanceTest.sh testItCanProvideAllTheScores ./acceptanceTest.sh: line 7: tennisGame.sh: command not found diff: ./results.txt: No such file or directory ASSERT:Expected output differs. Ran 1 test. FAILED (failures=1)
Let's turn those errors into a nice failure by creating an empty file called tennisGame.sh
and make it executable. Now when we run our test, we don't get an error:
./acceptanceTest.sh testItCanProvideAllTheScores 1,9d0 < John - Michael < John: 15 - Michael: 0 < John: 30 - Michael: 0 < John: 30 - Michael: 15 < John: 40 - Michael: 15 < John: 40 - Michael: 30 < Deuce < John: Advantage < John: Winner ASSERT:Expected output differs. Ran 1 test. FAILED (failures=1)
Implementation with TDD
Create another file called unitTests.sh
for our unit tests. We don't want to run our script for each test; we only want to run the functions that we test. So, we will make tennisGame.sh
run only the functions that will reside in functions.sh
:
#! /usr/bin/env sh ### unitTest.sh ### source ../functions.sh function testItCanProvideFirstPlayersName () { assertEquals 'John' `getFirstPlayerFrom 'John - Michael'` } ## Call and Run all Tests . "../shunit2-2.1.6/src/shunit2"
Our first test is simple. We attempt to retrieve the first player's name when a line contains two names separated by a hyphen. This test will fail because we do not yet have a getFirstPlayerFrom
function:
$ ./unitTest.sh testItCanProvideFirstPlayersName ./unitTest.sh: line 8: getFirstPlayerFrom: command not found shunit2:ERROR assertEquals() requires two or three arguments; 1 given shunit2:ERROR 1: John 2: 3: Ran 1 test. OK
The implementation for getFirstPlayerFrom
is very simple. It's a regular expression that is pushed through the sed
command:
### functions.sh ### function getFirstPlayerFrom () { echo $1 | sed -e 's/-.*//' }
Now the test passes:
$ ./unitTest.sh testItCanProvideFirstPlayersName Ran 1 test. OK
Let's write another test for the second player's name:
### unitTest.sh ### [...] function testItCanProvideSecondPlayersName () { assertEquals 'Michael' `getSecondPlayerFrom 'John - Michael'` }
The failure:
./unitTest.sh testItCanProvideFirstPlayersName testItCanProvideSecondPlayersName ASSERT:expected:<Michael> but was:<John> Ran 2 tests. FAILED (failures=1)
And now the function implementation to make it pass:
### functions.sh ### [...] function getSecondPlayerFrom () { echo $1 | sed -e 's/.*-//' }
Now we have passing tests:
$ ./unitTest.sh testItCanProvideFirstPlayersName testItCanProvideSecondPlayersName Ran 2 tests. OK
Let's Speed Things Up
Starting at this point, we will write a test and the implementation, and I will explain only what deserves to be mentioned.
Let's test if we have a player with only one score. Added the following test:
function testItCanGetScoreForAPlayerWithOnlyOneWin () { standings=$'John - Michael\nJohn' assertEquals '1' `getScoreFor 'John' "$standings"` }
And the solution:
function getScoreFor () { player=$1 standings=$2 totalMatches=$(echo "$standings" | grep $player | wc -l) echo $(($totalMatches-1)) }
We use some fancy-pants quoting to pass the newline sequence (\n
) inside a string parameter. Then we use grep
to find the lines that contain the player's name and count them with wc
. Finally, we subtract one from the result to counteract the presence of the first line (it contains only non-score related data).
Now we are at the refactoring phase of TDD.
I just realized that the code actually works for more than one point per player, and we can refactor our tests to reflect this. Change the above test function to the following:
function testItCanGetScoreForAPlayer () { standings=$'John - Michael\nJohn\nMichael\nJohn' assertEquals '2' `getScoreFor 'John' "$standings"` }
The tests still passes. Time to move on with our logic:
function testItCanOutputScoreAsInTennisForFirstPoint () { assertEquals 'John: 15 - Michael: 0' "`displayScore 'John' 1 'Michael' 0`" }
And the implementation:
function displayScore () { if [ "$2" -eq '1' ]; then playerOneScore='15' fi echo "$1: $playerOneScore - $3: $4" }
I only check the second parameter. This looks like I'm cheating, but it is the simplest code to make the test pass. Writing another test forces us to add more logic, but what test should we write next?
There are two paths we can take. Testing if the second player recieves a point forces us to write another if
statement, but we only have to add an else
statement if we choose to test the first player's second point. The latter implies an easier implementation, so let's try that:
function testItCanOutputScoreAsInTennisForSecondPointFirstPlayer () { assertEquals 'John: 30 - Michael: 0' "`displayScore 'John' 2 'Michael' 0`" }
And the implementation:
function displayScore () { if [ "$2" -eq '1' ]; then playerOneScore='15' else playerOneScore='30' fi echo "$1: $playerOneScore - $3: $4" }
This still looks cheating, but it works perfectly. Continuing on for the third point:
function testItCanOutputScoreAsInTennisForTHIRDPointFirstPlayer () { assertEquals 'John: 40 - Michael: 0' "`displayScore 'John' 3 'Michael' 0`" }
The implementation:
function displayScore () { if [ "$2" -eq '1' ]; then playerOneScore='15' elif [ "$2" -eq '2' ]; then playerOneScore='30' else playerOneScore='40' fi echo "$1: $playerOneScore - $3: $4" }
This if-elif-else
is starting to annoy me. I want to change it, but let's first refactor our tests. We have three very similar tests; so let's write them into a single test that makes three assertions:
function testItCanOutputScoreWhenFirstPlayerWinsFirst3Points () { assertEquals 'John: 15 - Michael: 0' "`displayScore 'John' 1 'Michael' 0`" assertEquals 'John: 30 - Michael: 0' "`displayScore 'John' 2 'Michael' 0`" assertEquals 'John: 40 - Michael: 0' "`displayScore 'John' 3 'Michael' 0`" }
That's better, and it still passes. Now, let's create a similar test for the second player:
function testItCanOutputScoreWhenSecondPlayerWinsFirst3Points () { assertEquals 'John: 0 - Michael: 15' "`displayScore 'John' 0 'Michael' 1`" assertEquals 'John: 0 - Michael: 30' "`displayScore 'John' 0 'Michael' 2`" assertEquals 'John: 0 - Michael: 40' "`displayScore 'John' 0 'Michael' 3`" }
Running this test results in interesting output:
testItCanOutputScoreWhenSecondPlayerWinsFirst3Points ASSERT:expected:<John: 0 - Michael: 15> but was:<John: 40 - Michael: 1> ASSERT:expected:<John: 0 - Michael: 30> but was:<John: 40 - Michael: 2> ASSERT:expected:<John: 0 - Michael: 40> but was:<John: 40 - Michael: 3>
Well that was unexpected. We knew that Michael would have incorrect scores. The surprise is John; he should have 0 not 40. Let's fix that by first modifying the if-elif-else
expression:
function displayScore () { if [ "$2" -eq '1' ]; then playerOneScore='15' elif [ "$2" -eq '2' ]; then playerOneScore='30' elif [ "$2" -eq '3' ]; then playerOneScore='40' else playerOneScore=$2 fi echo "$1: $playerOneScore - $3: $4" }
The if-elif-else
is now more complex, but we at least fixed the John's scores:
testItCanOutputScoreWhenSecondPlayerWinsFirst3Points ASSERT:expected:<John: 0 - Michael: 15> but was:<John: 0 - Michael: 1> ASSERT:expected:<John: 0 - Michael: 30> but was:<John: 0 - Michael: 2> ASSERT:expected:<John: 0 - Michael: 40> but was:<John: 0 - Michael: 3>
Now let's fix Michael:
function displayScore () { echo "$1: `convertToTennisScore $2` - $3: `convertToTennisScore $4`" } function convertToTennisScore () { if [ "$1" -eq '1' ]; then playerOneScore='15' elif [ "$1" -eq '2' ]; then playerOneScore='30' elif [ "$1" -eq '3' ]; then playerOneScore='40' else playerOneScore=$1 fi echo $playerOneScore; }
That worked well! Now it's time to finally refactor that ugly if-elif-else
expression:
function convertToTennisScore () { declare -a scoreMap=('0' '15' '30' '40') echo ${scoreMap[$1]}; }
Value maps are wonderful! Let's move on to the "Deuce" case:
function testItSayDeuceWhenPlayersAreEqualAndHaveEnoughPoinst () { assertEquals 'Deuce' "`displayScore 'John' 3 'Michael' 3`" }
We check for "Deuce" when all players have at least a score of 40.
function displayScore () { if [ $2 -gt 2 ] && [ $4 -gt 2 ] && [ $2 -eq $4 ]; then echo "Deuce" else echo "$1: `convertToTennisScore $2` - $3: `convertToTennisScore $4`" fi }
Now we test for the first player's advantage:
function testItCanOutputAdvantageForFirstPlayer () { assertEquals 'John: Advantage' "`displayScore 'John' 4 'Michael' 3`" }
And to make it pass:
function displayScore () { if [ $2 -gt 2 ] && [ $4 -gt 2 ] && [ $2 -eq $4 ]; then echo "Deuce" elif [ $2 -gt 2 ] && [ $4 -gt 2 ] && [ $2 -gt $4 ]; then echo "$1: Advantage" else echo "$1: `convertToTennisScore $2` - $3: `convertToTennisScore $4`" fi }
There's that ugly if-elif-else
again, and we have a lot of duplication as well. All our tests pass, so let's refactor:
function displayScore () { if outOfRegularScore $2 $4 ; then checkEquality $2 $4 checkFirstPlayerAdv $1 $2 $4 else echo "$1: `convertToTennisScore $2` - $3: `convertToTennisScore $4`" fi } function outOfRegularScore () { [ $1 -gt 2 ] && [ $2 -gt 2 ] return $? } function checkEquality () { if [ $1 -eq $2 ]; then echo "Deuce" fi } function checkFirstPlayerAdv () { if [ $2 -gt $3 ]; then echo "$1: Advantage" fi }
This'll work for now. Let's test the advantage for the second player:
function testItCanOutputAdvantageForSecondPlayer () { assertEquals 'Michael: Advantage' "`displayScore 'John' 3 'Michael' 4`" }
And the code:
function displayScore () { if outOfRegularScore $2 $4 ; then checkEquality $2 $4 checkAdvantage $1 $2 $3 $4 else echo "$1: `convertToTennisScore $2` - $3: `convertToTennisScore $4`" fi } function checkAdvantage () { if [ $2 -gt $4 ]; then echo "$1: Advantage" elif [ $4 -gt $2 ]; then echo "$3: Advantage" fi }
This works, but we have some duplication in the checkAdvantage
function. Let's simplify it and call it twice:
function displayScore () { if outOfRegularScore $2 $4 ; then checkEquality $2 $4 checkAdvantage $1 $2 $4 checkAdvantage $3 $4 $2 else echo "$1: `convertToTennisScore $2` - $3: `convertToTennisScore $4`" fi } function checkAdvantage () { if [ $2 -gt $3 ]; then echo "$1: Advantage" fi }
This is actually better than our previous solution, and it reverts to the original implementation of this method. But we now we have another problem: I feel uncomfortable with the $1
, $2
, $3
and $4
variables. They need meaningful names:
function displayScore () { firstPlayerName=$1; firstPlayerScore=$2 secondPlayerName=$3; secondPlayerScore=$4 if outOfRegularScore $firstPlayerScore $secondPlayerScore; then checkEquality $firstPlayerScore $secondPlayerScore checkAdvantageFor $firstPlayerName $firstPlayerScore $secondPlayerScore checkAdvantageFor $secondPlayerName $secondPlayerScore $firstPlayerScore else echo "$1: `convertToTennisScore $2` - $3: `convertToTennisScore $4`" fi } function checkAdvantageFor () { if [ $2 -gt $3 ]; then echo "$1: Advantage" fi }
This makes our code longer, but it is significantly more expressive. I like it.
It's time to find a winner:
function testItCanOutputWinnerForFirstPlayer () { assertEquals 'John: Winner' "`displayScore 'John' 5 'Michael' 3`" }
We only have to modify the checkAdvantageFor
function:
function checkAdvantageFor () { if [ $2 -gt $3 ]; then if [ `expr $2 - $3` -gt 1 ]; then echo "$1: Winner" else echo "$1: Advantage" fi fi }
We are almost done! As our last step, we'll write the code in tennisGame.sh
to make the acceptance test pass. This will be fairly simple code:
#! /usr/bin/env sh ### tennisGame.sh ### . ./functions.sh playersLine=`head -n 1 $1` echo "$playersLine" firstPlayer=`getFirstPlayerFrom "$playersLine"` secondPlayer=`getSecondPlayerFrom "$playersLine"` wholeScoreFileContent=`cat $1` totalNoOfLines=`echo "$wholeScoreFileContent" | wc -l` for currentLine in `seq 2 $totalNoOfLines` do firstPlayerScore=$(getScoreFor $firstPlayer "`echo \"$wholeScoreFileContent\" | head -n $currentLine`") secondPlayerScore=$(getScoreFor $secondPlayer "`echo \"$wholeScoreFileContent\" | head -n $currentLine`") displayScore $firstPlayer $firstPlayerScore $secondPlayer $secondPlayerScore done
We read the first line to retrieve the names of the two players, and then we incrementally read the file to compute the score.
Final Thoughts
Shell scripts can easily grow from a few lines of code to a few hundred of lines. When this happens, maintenance becomes increasingly difficult. Using TDD and unit testing can greatly help to make your complex script easier to maintain—not to mention that it forces you to build your complex scripts in a more professional manner.
Comments