DEV Community

Cover image for Test-Driven Development in Python using Unittest and Nose
Subhodeep Sarkar
Subhodeep Sarkar

Posted on • Edited on

Test-Driven Development in Python using Unittest and Nose

Test-driven development implies that we first write unit tests for our modules before writing the module itself. This way we know what we expect from our function and then write the function so that it passes the test cases. There are a total of 3 steps involved in TDD (Test-Driven Development).

  1. Red - You write test cases first which fails
  2. Green - You then write just enough code that passes the test cases.
  3. Refactor - Now you improve the code quality

For a demonstration of TDD, we will be writing a simple function that calculates the area of a rectangle.

def rect(length,breadth):
    return length*breadth
Enter fullscreen mode Exit fullscreen mode

Although this function looks good the reality is that it is full of bugs let's try it out.

def rect(length,breadth):
    return length*breadth

print(rect(5,10)) #Output: 50 
print(rect(4.5,3.4)) #Output: 15.29
print(rect(3,"HELLO")) #Output: HELLOHELLOHELLO
print(rect(-3,2)) #Output: -6
print(rect(0,5)) #Output: 0
Enter fullscreen mode Exit fullscreen mode

As we can see when we pass a string with an integer, a negative value or zero it handles them incorrectly and we get unexpected results. No side of a rectangle can have a negative value or zero nor should it accept a string value. This is where unit testing comes into place. Before writing code we first write some of the test cases.

Create a new file and make sure you add test_ prior to the name for example, if we assume our python file's name is app.py then our test file should be test_app.py.

Write the following test cases

from unittest import TestCase #import TestCase class from unittest module
from app import rect #import your rect function from app.py you wrote

class testApp(TestCase): #make a class which inherits the TestCase class
    '''Test rect function'''
    from unittest import TestCase #import TestCase class from unittest module
from app import rect #import your rect function from app.py you wrote

class testApp(TestCase): #make a class which inherits the TestCase class
    '''Test rect function'''
    def test_area_of_rectangle(self): 
        '''Test the area of a rectangle'''
        self.assertEqual(rect(5,10),50)

    def test_bad_value(self):
        '''Test if the function rejects invalid values'''
        self.assertRaises(ValueError, rect,-5,3)
        self.assertRaises(ValueError, rect,0,2)

    def test_bad_types(self):
        '''Test if the function rejects invalid types'''
        self.assertRaises(TypeError, rect,"Hello", 3)
        self.assertRaises(TypeError, rect,True, 5)
Enter fullscreen mode Exit fullscreen mode

The first test case will check if the function is returning the expected result when passing good values.

This is an example of 'Happy paths' in testing which tests if the function returns the expected result.

The next two test cases check if the function handles exceptions when passed bad data.

This is an example of 'Sad paths' in testing which tests if the function's exception handlers works properly when bad data is passed.

You can either use the default test-runner that comes with Python i.e unittest.

go to the file location and type in python -m unittest discover this will run all the test cases. If the test case pass then it will show '.' else it will show 'E' along with the test case name with which failed. In our case if we run this we get:

Running test cases with unittest

So, two of the tests failed but the output was not so helpful. let's try another test runner nosetests

But we need few other things as well, pinocchio for organized output and coloured text and coverage for code coverage report

We need coverage to ensure that every code statement e.g if-else branches etc. are tested before pushing it to production. The code coverage should be as high as possible.

Install:
pip install nose pinocchio coverage

Troubleshooting: If you are using python 3.10 or above, then nosetests won't work directly and will give you error. So you need to make changes to the following files:
.local/lib/python3.10/site-packages/nose/suite.py
.local/lib/python3.10/site-packages/nose/case.py
you have to change two things, first
change import collections to import collections.abc and then change collections.Callable to collections.abc.Callable wherever you see the statement.

To run nosetests along with pinocchio and coverage
nosetests --with-spec --spec-color --with-coverage

Running test cases with nosetests

The test cases highlighted in red are the ones that failed and the one highlighted in green has passed. We can also see that the code coverage for the file is 100% which is perfect. We can see that the exceptions were not raised. So let's update our code.

def rect(length,breadth):
    if type(length) not in [int,float] or type(breadth) not in [int,float]:
        raise TypeError("Length or Breadth must be int or float")
    return length*breadth
Enter fullscreen mode Exit fullscreen mode

We have added a statement to raise TypeError if the type of length or breadth is not integer or float. Let's try out our test cases.

Passing test case 1

Great! test_bad_types has passed but still, the test_bad_values is not raising an exception when it is getting a negative or zero value for length or breadth. Let's modify our code again.

def rect(length,breadth):
    if type(length) not in [int,float] or type(breadth) not in [int,float]:
        raise TypeError("Length or Breadth must be int or float")
    if length<=0 or breadth<=0: 
        raise ValueError("Lenght or Breadth cannot be zero or negative")
    return length*breadth
Enter fullscreen mode Exit fullscreen mode

Now we have added a statement to raise ValueError when we receive negative or zero values for length or breadth. Let's try our test cases once more.

Passing test case 2

Congratulations! All the test cases have been passed, the happy paths and the sad paths have been tested, the code coverage is 100% and our rect function is now ready to be pushed in production.

Quick tip: The nosetests command is a long one and everytime you run it, you need to pass all the required flags to trigger pinocchio and coverage. So add a file setup.cfg which will contain the following:

[nosetests]
verbosity=2
with-spec=1
spec-color=1
with-coverage=1
cover-package=app

[coverage:report]
show_missing = True
Enter fullscreen mode Exit fullscreen mode

I have also added show_missing = True parameter which will pinpoint the line numbers which are not tested. Now just run nosetests without any parameter or flags, it will fetch all the required configuration flags and parameters from setup.cfg and will provide the same result.

Nostest with setup.cfg

So we now have understood the idea of TDD. Write test cases first and let your unit test derive your code.

Thank you,
Happy Coding!

Top comments (0)