Advertisement
Tools & Tips

Test-Driving Shell Scripts

by

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 getFirstPlayerFromis 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.

Related Posts
  • Code
    Web Development
    Refactoring Legacy Code: Part 1 - The Golden MasterRefactoring wide retina preview
    Learn techniques for how to deal with complex and complicated unknown legacy code, how to understand it, and finally writing the Golden Master tests for future changes.Read More…
  • Code
    HTML5
    HTML5: Vibration APIPdl54 preview image@2x
    HTML5 has been a breath of fresh air for the web, which hasn't only affected the web as we know it. HTML5 provides a number of APIs that enable developers to create interactive websites and improve the user experience on mobile devices. In this article, we'll take a closer look at the Vibration API.Read More…
  • Code
    Corona SDK
    Build a Poker Game in Corona: Game LogicOd95a preview image@2x
    In the first part of this tutorial, we set up the project and created the game's interface. We also created and implemented a function to create a deck of cards. In this second tutorial, we will create the game logic.Read More…
  • Game Development
    Implementation
    Write Once, Publish Everywhere With HaxePunk: Making a GamePreviewretinaimage
    You've probably had this experience before: you hear about an awesome game, but then you find out that it's only coming out on the one platform that you don't own. It doesn't have to be this way. In this tutorial, you will learn how to use Haxe to make a game in one development platform that can target multiple gaming platforms, including Linux, Mac, Windows, iOS, Android, and Flash.Read More…
  • Code
    Articles
    Coding With KodingCoding with koding retina preview
    Cloud IDEs have been around for a little while now, and they have been pretty good for things like pair programming, or cases where you want to code consistently no matter where you are. Koding just came out of private beta, and they would like to take this notion a couple steps further, with their "cloud ecosystem". In this article we will take a look at what Koding is, as-well as some of the benefits you can get from using it.Read More…
  • Code
    Other
    The Fundamentals of Bash ScriptingBash scripting fundamentals 400
    Shell scripts are widely used in the UNIX world. They're excellent for speeding up repetitive tasks and simplifying complex execution logic. They can be as simple as a set of commands, or they can orchestrate complex tasks. In this tutorial, we'll learn more about the Bash scripting language by writing an example script step-by-step.Read More…