Unit Testing: The Basics

Published 13:36 on 03 May, 2009

As a web development manager, it’s part of my job to be involved in defining best practices for my team. This means defining a set of standard practices that will benefit the whole team throughout the entire development process. A good example of one such best practice would be code testing.

Testing, for web developers, is usually a fairly disorganised affair. Most of the time, we test “on the job”, i.e., we code something and then we run it; if it breaks, we fix it. In fact, often, the thought of actually structuring our testing process can seem somewhat of an over complication. This is certainly not the case.

The benefits of a structured testing process are legion, as any software engineer will attest. At the very least, it provides confidence in our code – something which can only be of incredible benefit when code is shared across a wider team. At its best, the tests we write can influence the way we develop the code being tested; and save effort, time, and money.

In this post I’m going to look at “unit testing”, and how it can be of benefit to web developers in general.

What is unit testing?

Unit testing is the process of validating and verifying the individual “units” of code in a system. A unit is the smallest testable part of a system or application. In the majority of cases within web development, these units are usually individual functions or methods of our object-oriented back-end and front-end code.

At this point, it’s probably worth noting that unit testing is really only applicable to functional code, such as PHP, JavaScript, Python, Perl, Ruby etc., because it breaks down into units nicely. Testing mark-up and CSS is a slightly different process and one that I may look at in a future blog post.

A note on language

The concept of unit testing is entirely language agnostic, therefore the examples in the following tutorials can be ported to PHP, Perl, Ruby, JavaScript, or any other functional or object-oriented language you are using.

I’ll use Python as the language for all examples in this post as it’s my current weapon of choice (and the interactive interpreter is very handy for trying out ideas); if you’ve never seen Python syntax, don’t worry, it’s easily understandable (if you want more information, take a look at the Python tutorial).

Writing a simple test

To begin with, let’s imagine we have a function for calculating the area of a rectangle:

def get_area_of_rect( width, height ):
    return width * height

Let’s paste this function into the interactive interpreter, so that we can play with it:

>>> def get_area_of_rect( width, height ):
...     return width * height
...
>>> 

Now if we call it, we’ll receive the area of our rectangle like so:

>>> get_area_of_rect( 2, 3 )
6
>>>

Now if we wanted to test this function, we could call it with a some defined parameters and test the output against our expectations:

def test_get_area_of_rect():
    width = 2
    height = 3
    expected_result = 6

    result = get_area_of_rect( width, height )

    if result == expected_result:
        print "Test passed: Received expected result!"

Now if we run our test, we should see the following:

>>> test_get_area_of_rect()
Test passed: Received expected result!
>>>

Brilliant; our code passed the test because the expected result was the same as the actual result – however, that’s actually not very useful to us. It’s far more important that our tests can trap failing code than code that works.

Imagine our get_area_of_rect function had been updated as follows:

def get_area_of_rect( width, height ):
    return width / height

Anyone with rudimentary maths skills can tell that this will no longer return the correct area of a rectangle. In fact, if we rerun our test, we’ll get no output:

>>> test_get_area_of_rect()
>>>

A fat lot of good that is. If our code is failing for some reason, we want to know about it. After all, that’s the whole reason for testing it.

Let’s update the if statement in our test_get_area_of_rect function:

if not result == expected_result:
    raise AssertionError
else:
    print "Test passed: Received expected result!"

In fact, we could probably dump the else clause since it’s of little real importance except for logging purposes:

if not result == expected_result:
    raise AssertionError

That’s nicer, but Python provides us a shortcut to this if not … raise AssertionError syntax with the assert statement:

assert result == expected_result

With that in mind, the test_get_area_of_rect function should now look like this:

def test_get_area_of_rect():
    width           = 2
    height          = 3
    expected_result = 6
    
    result = get_area_of_rect( width, height )
    
    assert result == expected_result

If we run our test again, we should receive more constructive output, considering our get_area_of_rect function is still dividing:

>>> test_get_area_of_rect()
Traceback (most recent call last):
  File "", line 1, in 
  File "", line 8, in test_get_area_of_rect
AssertionError
>>>

We now have a decent automated test, but we’re only checking a single expected result. If, for instance, we passed in a width of 1 and a height of 1, both multiplication and division would return the same result: 1. This means that we should pass multiple sets of width and height parameters and test against their expected results. Since these parameters will be constant, let’s define them as tuples and loop through them, testing each in turn:

def test_get_area_of_rect():
    known_values = ( ( 0, 0, 0 ),
                     ( 1, 1, 1 ),
                     ( 2, 2, 4 ),
                     ( 2, 3, 6 ),
                     ( 3, 0, 0 ) )
    
    for width, height, expected_result in known_values:
        result = get_area_of_rect( width, height )
        assert result == expected_result

As you can see, we’re now passing several sets of parameters. Each set of parameters is valid, and as such, we should expect a successful outcome from our get_area_of_rect() call.

Now if we run our test, we should get something like the following:

>>> test_get_area_of_rect()
Traceback (most recent call last):
  File "", line 1, in 
  File "", line 8, in test_get_area_of_rect
  File "", line 2, in get_area_of_rect
ZeroDivisionError: integer division or modulo by zero
>>>

Ah ha! By passing multiple sets of parameters we’ve uncovered more significant information regarding the AssertionError that was raised earlier; we have a division by zero error on line 2 of get_area_of_rect. This has supplied us with all the information we need to fix our error, so let’s take a look at that function again:

def get_area_of_rect( width, height ):  # Line 1
    return width / height               # Line 2

Change the division back to multiplication, like so:

def get_area_of_rect( width, height ):  # Line 1
    return width * height               # Line 2

And then rerun our test:

>>> test_get_area_of_rect()
>>> 

We get no output at all because the test passed successfully. As the old proverb goes, “no news is good news”.

Now if we add to our original code in the future, we’ll still be able to run this testing code to see what has been affected. What’s more, we can add to it by writing more tests, should the complexity grow. In fact, creating an extensive suite of tests is generally the aim of unit testing, but to do so, it’s a good idea to utilise a testing framework, which I’ll look at in the next post of this series.

Summary

Writing tests for code enables more efficient debugging, faster bug fixing, and an overall confidence in the quality of your code. Furthermore, the rest of your team (or whomsoever you’re sharing said code with) will equally have confidence thanks to the tests included.

In brief, here’s what we’ve learnt about unit testing:

  • It's most important that your tests catch failure, not success.
  • Test the smallest amount of code, or "unit", possible each time.
  • Test as many different parameters, inputs, and outputs as possible.
  • Run the tests often.

The next post

In the next post, I’ll be expanding on unit testing by looking at test frameworks, test architecture, code coverage, and other more advanced techniques that can improve your testing environment.