Test-driven development (TDD) is a process that has been documented considerably over recent years. A process of baking your tests right into your everyday coding, as opposed to a nagging afterthought, should be something that developers seek to make the norm, rather than some ideal fantasy.
I will introduce the core concepts of TDD.
The whole process is very simple to get to grips with, and it shouldn't take too long before you wonder how you were able to get anything done before! There are huge gains to be made from TDD - namely, the quality of your code improving, but also clarity and focus on what it is that you are trying to achieve, and the way in which you will achieve it. TDD also works seamlessly with agile development, and can best be utilized when pair-programming, as you will see later on.
In this tutorial, I will introduce the core concepts of TDD, and will provide examples in Python, using the nosetests unit-testing package. I will additionally offer some alternative packages that are also available within Python.
What is Test-Driven Development?
This approach allows you to escape the trap that many developers fall into.
TDD, in its most basic terms, is the process of implementing code by writing your tests first, seeing them fail, then writing the code to make the tests pass. You can then build upon this developed code by appropriately altering your test to expect the outcome of additional functionality, then writing the code to make it pass again.
You can see that TDD is very much a cycle, with your code going through as many iterations of tests, writing, and development as necessary, until the feature is finished. By implementing these tests before you write the code, it brings out a natural tendency to THINK about your problem first. Whilst you start to construct your test, you have to think about the way you design your code. What will this method return? What if we get an exception here? Etc. etc. By developing in this way, it means you consider the different routes through the code, and cover these with tests as needed. This approach allows you to escape the trap that many developers fall into (myself included): diving into a problem and writing code exclusively for the first solution you need to handle.
The process can be defined as such:
- Write a failing unit test
- Make the unit test pass
Repeat this process for every feature, as is necessary.
Agile Development with Test Driven Development
TDD is a perfect match for the ideals and principles of the Agile Development process, with a great strive to deliver incremental updates to a product with true quality, as opposed to quantity. The confidence in your individual units of code that unit testing provides means that you meet this requirement to deliver quality, while eradicating issues in your production environments.
"This means both parties in the pair are engaged, focused on what they are doing, and checking one another's work at every stage."
TDD comes into its own when pair programming, however. The ability to mix up your development workflow, when working as a pair as you see fit. is nice. For example, one person can write the unit test, see it pass and then allow the other developer to write the code to make the test pass. The roles can either be switched each time, each half day or every day as you see fit. This means both parties in the pair are engaged, focused on what they are doing, and checking each other's work at every stage. This translates to a win in every sense with this approach, I think you'd agree.
TDD also forms an integral part of the Behaviour Driven Development process, which is again, writing tests up front, but in the form of acceptance tests. These ensure a feature "behaves" in the way you expect from end to end. More information can be found in my previous article, here on Nettuts+: BDD in Python.
Syntax for Unit Testing
The main methods that we make use of in unit testing for Python are:
- assert - base assert allowing you to write your own assertions
- assertEqual(a, b) - check a and b are equal
- assertNotEqual(a, b) - check a and b are not equal
- assertIn(a, b) - check that a is in the item b
- assertNotIn(a, b) - check that a is not in the item b
- assertFalse(a) - check that the value of a is False
- assertTrue(a) - check the value of a is True
- assertIsInstance(a, TYPE) - check that a is of type "TYPE"
- assertRaises(ERROR, a, args) - check that when a is called with args that it raises ERROR
There are certainly more methods available to us, which you can view - Python Unit Test Doc's - but, in my experience, the ones listed above are among the most frequently used. We will make use of these within our examples below.
Example Problem and Test Driven Approach
We are going to take a look at a really simple example to introduce both unit testing in Python and the concept of TDD. We will write a very simple calculator class, with add, subtract and other simple methods as you would expect. Following a TDD approach, let's say that we have a requirement for an
add function, which will determine the sum of two numbers, and return the output. Let's write a failing test for this.
import unittest class TddInPythonExample(unittest.TestCase): def test_calculator_add_method_returns_correct_result(self): calc = Calculator() result = calc.add(2,2) self.assertEqual(4, result)
Writing the test is fairly simple.
- First, we import the standard unittest module from the Python standard library.
- Next, we need a class to contain the different test cases.
- Finally, a method is required for the test, itself, with the only requirement being that it is named with "test_" at the beginning, so that it may be picked up and executed by the nosetest runner, which we will cover shortly.
With the structure in place, we then can then write the test code. We initialize our calculator so that we can execute the methods on it. Following this, we can then call the
add method which we wish to test, and store its value in the variable,
result. Once this is complete, we can then then make use of unittest's
assertEqual method to ensure that our calculator's
add method behaves as expected.
Now if we run this test, we'll see some failures:
From the output nosetest has given, us we can see that the problem relates to us not importing
Calculator. That's because we haven't created it yet! So let's go and define our
Calculator and import it:
class Calculator(object): def add(self, x, y): pass
import unittest from calculator import Calculator class TddInPythonExample(unittest.TestCase): def test_calculator_add_method_returns_correct_result(self): calc = Calculator() result = calc.add(2,2) self.assertEqual(4, result)
Now that we have
Calculator defined, let's see what nosetest indicates to us now:
So, obviously, our
add method is returning the wrong value, as it doesn't do anything at the moment. Handily, nosetest gives us the offending line in the test, and we can then confirm what we need to change. Let's fix the method and see if our test passes now:
class Calculator(object): def add(self, x, y): return x+y
Success! We have defined our
add method and it works as expected. However, there is more work to do around this method to ensure that we have tested it properly.
We have fallen into the trap of just testing the case we are interested in at the moment.
What would happen if someone were to add anything other than numbers? Python will actually allow for the addition of strings and other types, but in our case, for our calculator, it only makes sense to add numbers. Let's add another failing test for this case, making use of the
assertRaises method to test if an exception is raised here:
import unittest from calculator import Calculator class TddInPythonExample(unittest.TestCase): def test_calculator_add_method_returns_correct_result(self): calc = Calculator() result = calc.add(2,2) self.assertEqual(4, result) def test_calculator_returns_error_message_if_both_args_not_numbers(self): self.assertRaises(ValueError, self.calc.add, 'two', 'three')
You can see from above that we added the test and are now checking for a
ValueError to be raised, if we pass in strings. We could also add more checks for other types, but for now, we'll keep things simple. You may also notice that we've made use of the
setup() method. This allows us to put things in place before each test case. So, as we need our
Calculator object to be available in both test cases, it makes sense to initialize this in the
setUp method. Let's see what nosetest indicates to us now:
Now that we have a new failing test, we can code the solution:
class Calculator(object): def add(self, x, y): number_types = (int, long, float, complex) if isinstance(x, number_types) and isinstance(y, number_types): return x+y else: raise ValueError
From the code above you can see that we've added a small addition to check the types of the values and whether they match what we want. Normally in Python, we would follow duck typing, and simply attempt to use it as a number, and "try/except" the errors that would be raised. The above is a bit of an edge case and means we must check before moving forward. As mentioned earlier, strings can be concatenated with the plus symbol; so we only want to allow numbers. Utilising the
isinstance method allows us to ensure that the provided values can only be numbers.
To complete the testing, there are a couple of different cases that we can add. As there are two variables, it means that both could potentially not numbers. We can handle these cases, like so:
import unittest from calculator import Calculator class TddInPythonExample(unittest.TestCase): def test_calculator_add_method_returns_correct_result(self): calc = Calculator() result = calc.add(2,2) self.assertEqual(4, result) def test_calculator_returns_error_message_if_both_args_not_numbers(self): self.assertRaises(ValueError, self.calc.add, 'two', 'three') def test_calculator_returns_error_message_if_x_arg_not_number(self): self.assertRaises(ValueError, self.calc.add, 'two', 3) def test_calculator_returns_error_message_if_x_arg_not_number(self): self.assertRaises(ValueError, self.calc.add, 2, 'three')
When we run all these tests now, we can confirm that the method meets our requirements!
Installing and Using Python's Nose
Installation of the Nosetest runner is straightforward, following the standard "pip" install pattern. It's also usually a good idea to work on your projects using virtualenv's, which keeps all the packages you use for various projects separate. If you are unfamiliar with pip or virtualenv's, you can find documentation on them here: VirtualEnv, PIP.
The pip install is as easy as running this line:
"pip install nose"
Once installed, you can execute your tests either by running:
"nosetests example_unit_test.py" - to execute a single file of tests
"nosetests /path/to/tests" - to execute a suite of tests in a folder
The only standard you need to follow is to begin each test's method with “test_” to ensure that the nosetest runner can find your tests!
Some useful command line options that you may wish to keep in mind include:
v:gives more verbose output, including the names of the tests being executed
-nocapture: allows output of print statements, which are normally captured and hidden while executing tests. Useful for debugging.
--nologcapture: allows output of logging information
--rednose: an optional plugin, which can be downloaded here, but provides colored output for the tests.
--tags=TAGS: allows you to place an @TAG above a specific test to only execute those, rather than the entire test suite.
Other Unit Test Packages
py.test - a similar test runner to nosetests, which makes use of the same conventions, meaning that you can execute your tests in either of the two. A nice feature of py.test is that it captures your output from the test at the bottom in a separate area, meaning you can quickly see anything printed to the command line (see below). I've found py.test to be useful, when executing single tests, as opposed to a suite of tests.
UnitTest - Python's inbuilt unittest package that we have used to create our tests can actually be executed, itself, and gives nice output. This is useful, if you don't wish to install any external packages and keep everything pure to the standard library. To use this, simply add an "if __name__ == "__main__":" block to the end of your test file and execute it using
python example_test.py. Here is the output that you can expect:
Test-Driven Development is a process that can be both fun to practice, and hugely beneficial to the quality of your production code. Its flexibility in its application to large projects with many team members, right down to a small solo project means that it's a fantastic methodology to advocate to your team.
Whether pair programming or developing by yourself, the process of making a failing test pass is hugely satisfying. If you've ever argued that tests weren't necessary, hopefully this article has swayed your approach for future projects.
Make TDD a part of your daily workflow today.