Test-Driving Shell Scripts

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.

Than make your test file executable.

Now you can simply run it and observe the output:

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:


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:

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:

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:

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:

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:


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:

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:

The implementation for getFirstPlayerFromis very simple. It's a regular expression that is pushed through the sed command:

Now the test passes:

Let's write another test for the second player's name:

The failure:

And now the function implementation to make it pass:

Now we have passing tests:


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:

And the solution:

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:

The tests still passes. Time to move on with our logic:

And the implementation:

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:

And the implementation:

This still looks cheating, but it works perfectly. Continuing on for the third point:

The implementation:

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:

That's better, and it still passes. Now, let's create a similar test for the second player:

Running this test results in interesting output:

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:

The if-elif-else is now more complex, but we at least fixed the John's scores:

Now let's fix Michael:

That worked well! Now it's time to finally refactor that ugly if-elif-else expression:

Value maps are wonderful! Let's move on to the "Deuce" case:

We check for "Deuce" when all players have at least a score of 40.

Now we test for the first player's advantage:

And to make it pass:

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:

This'll work for now. Let's test the advantage for the second player:

And the code:

This works, but we have some duplication in the checkAdvantage function. Let's simplify it and call it twice:

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:

This makes our code longer, but it is significantly more expressive. I like it.

It's time to find a winner:

We only have to modify the checkAdvantageFor function:

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:

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.

Tags:

Comments

Related Articles