Skip to content

Test Driven Development

Valentina Gaggero edited this page May 11, 2023 · 1 revision

Introduction

Test-driven development (TDD) is a practice where test cases are developed to specify and validate what the code will do. In other words, the approach consists in writing a test for certain functionality, and then if the test fails then new code is written in order to pass the test and make sure the code is bug-free. This approach instructs the developers to write new code only if the test fails, this method avoids duplication of code.

Below we have a tutorial written in Python, but the same methodology can be implemented in any language with the corresponding libraries.

Development cycle

The process for TDD can be defined in this way:

  1. Add a test for a certain feature: the test will pass if and only if the feature’s specifications are met.
  2. Run all the tests. The test should fail because that feature has not been implemented yet.
  3. Write the simplest needed code to be able to pass the test.
  4. Run all the tests, the tests should pass now.
  5. Refactor the code to improve readability and maintenance. After refactoring, run again the tests to be sure all the functionalities are not broken.
  6. Repeat from step 1.

Tutorial

Implementing TDD in Python

To implement TDD in Python you can make use of the built-in library pytest.

You need to install the library first:

python3 -m pip install pytest

pytest implements a test discovery based on certain rules:

  • the tests need to be placed in the folder tests
  • the tests files need to be named as follows: test_*.py or *_test.py
  • the testing functions need to be called as follows: test*
  • the testing classes need to be called as follows: Test*

You can then run the test by invoking from the project directory:

pytest

If you need to set something up before running each test, you can include the function setup_function() and if you need some cleanup code after each test, you can include the function teardown_function():

def setup_function():
    # will run before each test
    pass

def teardown_function():
    # will run after each test
    pass

If you need to set something up only once in the module, you can include the function setup_module() and if you need some cleanup code only once in the module, you can include the function teardown_module():

def setup_module():
    # will run once per module
    pass

def teardown_module():
    # will run once per module
    pass

If your tests are defined in a test class you can define the setup_method and teardown_method which will be run before and after each test. Example:

class TestClass:
    def setup_method(self):
        pass

    def teardown_method(self):
        pass

    def test1(self):
        """a test"""
        pass

    def test2(self):
        """another test"""
        pass

Test-Driven development is more oriented to the software specifications. Once you have defined the specifications of the functionality you want to have you can start writing your first test. For example, you are asked to write a function that has these requirements:

  • Define a function called get_value which takes a parameter as input.
  • It returns a string
  • get_value should return "Hi" when a multiple of 3 is passed an input parameter
  • get_value should return "Bye" when a multiple of 5 is passed an input parameter
  • get_value should return "Hi - Bye" when a multiple of 3 and 5 is passed an input parameter
  • get_value will return "Fine" for all other values of the input_parameter

We can write the tests for those requirements with the following steps:

  1. Call the function get_value
  2. Get "Fine" when I pass any value
  3. Get "Hi" when I pass a multiple of 3
  4. Get "Bye" when I pass a multiple of 5
  5. Get "Hi - Bye" when I pass a multiple of 3 and 5

Step 1

We implement the first test checking that the function get_value exists.

def test_we_can_call_get_value():
    input_value = 11
    output = get_value()
    assert output == None

The test will fail with the following error:

NameError: name 'get_value' is not defined

We then need to define a get_value function

def get_value(value):
     pass

This will allow our test to pass.

Step 2

The second requirement asks us to change the function so that it returns "Fine" whenever a value is passed. We write the corresponding test inside the TestFunctionality class for this requirement:

def test_call_get_value_returns_Fine():
    input_value = 11
    output = get_value(input_value)
    assert output == "Fine"

The test will fail. We then add the functionality to get_value, we then need to refactor the code, the test we implemented in the first step needs to be deleted because it will now fail:

def get_value(value):
     return "Fine"

Step 3

We add a test to check that get_value returns "Hi" when a value multiple of 3 is passed as an input parameter:

def test_call_get_value_returns_Hi_when_mutple_of_3_is_passed():
    input_value = 9
    output = get_value(input_value)
    assert output == "Hi"

The test will of course fails, so we have to modify the function get_value to make the test pass:

def get_value(value):
    if (value % 3) == 0:
        return "Hi - Bye"
    return "Fine"

Step 4

We add a test to check that get_value returns "Bye" when a value multiple of 5 is passed as an input parameter:

def test_call_get_value_returns_Bye_when_mutple_of_5_is_passed():
    input_value = 25
    output = get_value(input_value)
    assert output == "Bye

The test will fail, we then modify get_value to make the test pass:

def get_value(value):
    if (value % 3) == 0:
        return "Hi - Bye"
    elif (value % 5) == 0:
        return "Bye"
    return "Fine"

Step 5

The last step requires us to return "Hi - Bye" when we pass a multiple of 3 and 5 to the function get_value. We write a test to check this:

def test_call_get_value_returns_ByeandHi_when_mutple_of_5and3_is_passed():
    input_value = 15
    output = get_value(input_value)
    assert output == "Hi - Bye"

The test will fails, we then modify get_value to make the test pass:

def get_value(value):

    if (value % 3) == 0 and (value % 5) == 0:
        return "Hi - Bye"
    elif (value % 3) == 0:
        return "Hi"
    elif (value % 5) == 0:
        return "Bye"

    return "Fine"

Congratulations!!! You successfully implemented your first function with the TDD practice!

Click here for the full code!
def get_value(value):
    if (value % 3) == 0 and (value % 5) == 0:
        return "Hi - Bye"
    elif (value % 3) == 0:
        return "Hi"
    elif (value % 5) == 0:
        return "Bye"

    return "Fine"

def test_call_get_value_returns_Fine():
    input_value = 11
    output = get_value(input_value)
    assert output == "Fine"

def test_call_get_value_returns_Hi_when_mutple_of_3_is_passed():
    input_value = 9
    output = get_value(input_value)
    assert output == "Hi"

def test_call_get_value_returns_Bye_when_mutple_of_5_is_passed():
    input_value = 25
    output = get_value(input_value)
    assert output == "Bye"

def test_call_get_value_returns_ByeandHi_when_mutple_of_5and3_is_passed():
    input_value = 15
    output = get_value(input_value)
    assert output == "Hi - Bye"

Tips and tricks

Composition is your best friend

This means that if your function or class needs some external resource, it is best that it receives it as a parameter. This way, you can test with mock data.

For example, you have a class that reads from a database and does something.

If the database is merged with the class (for example, it is a member of the class):

class ReadFromDBAndDoThings:

    self.database = ProductionDatabase()

    def doThingsInDatabase(self):
        self.database.change_all_the_data() # this is dangerous to try in tests!

You limit yourself, because you can only test with the real database. Not only is this dangerous, but you don't have many options to test.

Using composition, we pass the database to this class as a parameter:

class ReadFromDBAndDoThings:

    def __init__(self, database):
        self.database = database

    def doThingsInDatabase(self):
        self.database.change_all_the_data()

Now when you test you can use any fake database you want:

def test_database_is_changed():
    fake_database = FakeDatabase()
    object_to_test = ReadFromDBAndDoThings(fake_database)
    object_to_test.doThingsInDatabase()

And you can test in different configurations and with no fear of breaking something important.