Three simple steps of test-driven development

The test-driven development process, in its simplest form, consists of the following three steps:

  1. Writing automated tests for a new functionality or improvement that was not implemented yet.
  2. Providing minimal code that just passes all the defined tests.
  3. Refactoring code to meet the desired quality standards.

The most important fact to remember about this development cycle is that tests should be written before actual implementation. It is not an easy task for inexperienced developers, but it is the only approach that reliably guarantees that the code you are going to write will be testable.

For example, a developer who is asked to write a function that checks whether the given number is a prime number may write a few examples on how to use it, and the expected results, as follows:

assert is_prime(5) 
assert is_prime(7) 
assert not is_prime(8) 

The developer that implements this feature does not need to be the only one responsible for providing tests. The examples can be provided by another person as well. For instance, often, official specifications of network protocols or cryptography algorithms provide test vectors that are intended to verify the correctness of the implementation. These are a perfect basis for test cases of code that aims to implement such protocols and algorithms.

From there, the function can be iteratively implemented until the preceding example works, as follows:

def is_prime(number):
    for element in range(2, number): 
        if number % element == 0: 
            return False 
    return True 

It is possible that upon usage, code users will find bugs or unexpected results. Such special cases are new examples of usage that the implemented function should be able to deal with, as follows:

>>> assert not is_prime(1)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AssertionError  

As new problematic usage examples are discovered, the function is gradually improved, as follows:

def is_prime(number):
    if number in (0, 1):
        return False 
 
    for element in range(2, number):
        if number % element == 0:
            return False
 
    return True

This process may be repeated multiple times, as it is often hard to predict all meaningful and problematic usage examples, for example:

>>> assert not is_prime(-3)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AssertionError

The point of this approach is to find an implementation through a series of gradual improvements. This process guarantees that the function properly handles all possible edge cases within the defined usage constraints, as follows: 

def is_prime(number): 
    if number < 0 or number in (0, 1): 
        return False 
 
    for element in range(2, number): 
        if number % element == 0: 
            return False 
 
    return True 

And the collection of discovered or planned usage examples serves as a definition of such usage constraints. Common usage examples become a test for an implemented function that verifies that the implementation meets all known requirements. In practice, common usage examples are gathered in their own names function so, they can be executed every time the code evolves, as follows:

def test_is_prime(): 
    assert is_prime(5) 
    assert is_prime(7) 
 
    assert not is_prime(8) 
    assert not is_prime(0) 
    assert not is_prime(1) 
 
    assert not is_prime(-1) 
    assert not is_prime(-3) 
    assert not is_prime(-6) 

In the previous example, every time we come up with a new requirement, the test_is_prime() function should be updated first to define the expected behavior of the is_prime() function. Then, a test is run to check if the implementation delivers the desired results. Only if the tests are known to be failing is there a need to update the code for the tested function.

Test-driven development provides a lot of benefits, including the following:

The best convention to deal with multiple tests in Python is to gather all of them in a single module or package (usually named tests) and have an easy way to run their whole suite using a single shell command. Fortunately, there is no need to build whole test toolchains all by yourself. Both the Python standard library and Python Package Index come with plenty of test frameworks and utilities that allow you to build, discover, and run tests in a convenient way. We will discuss the most notable examples of such packages and modules later in this chapter.

Let's discuss the benefits of test-driven development in the following sections.