DEV Community

Cover image for Test organization in Python
Daniel Waller (he/him)
Daniel Waller (he/him)

Posted on

Test organization in Python

For quite some time now Python has been my hacking and proof-of-concept language of choice.

As such, testing in Python never really was an important issue for me because I wouldn't use the code in production or even revisit it most of the time.

Recently, I have been writing a small flask app in python to calibrate and continuously read from an HX711 load cell with a Raspberry Pi Zero W.

Since I'm using this code as part of my bachelor thesis I consider it to be 'production' code and most importantly I want it to be easy to refactor and extend.

So for the first time I had to think about testing in Python.


The setup

First things first, my project without tests looks like this:

Output of tree command on the root of the project directory containing my code without tests

As you can see, it is a very simple setup with 2 modules backend and service and an entry point server.py.

The Goal

For easy testing I want to be able to define tests for each module and be able to run them separately, as well as an easy way of running all tests of all modules.

The solution

For testing I will use pythons unittest module. The smallest unit for testing with unittest is the TestCase which can have 1 or more methods of the form def test_*():.

To begin, I tried to separate the different responsibilities of my modules into TestCase classes, eg.:

    import unittest

    class TestCalibrationChecks(unittest.TestCase):

        """
        methods that test whether all checks on the calibration state of the scale work
        """

    class TestCalibrationProcess(unittest.TestCase):

        """
        methods that test calibration of the scale
        """
Enter fullscreen mode Exit fullscreen mode

After writing test cases for the modules my project looked like this:

Output of tree command on the root of the project directory containing my code with module tests

Running a TestCase from the project root looks like this: python -m unittest backend.test.calibration_test.TestCalibrationChecks

Now I wanted to be able to run all tests in each module so I looked around the docs and came to unittest's TestSuite.

A TestSuite is a collection of TestCases and/or TestSuites and can be used to group tests together.

So in the root of my modules I wrote a file tests.py to combine all TestCases from that module's test folder into a single suite.
For the backend module it looked like this:

    import unittest

    from backend.test import TestCalibrationChecks
    from backend.test import TestCalibrationProcess
    from backend.test import TestSystemHealthChecks

    def test_scale_suite():
        scale_test_suite = unittest.TestSuite([
            unittest.TestLoader().loadTestsFromTestCase(TestCalibrationChecks),
            unittest.TestLoader().loadTestsFromTestCase(TestCalibrationProcess),
            unittest.TestLoader().loadTestsFromTestCase(TestSystemHealthChecks)
        ])
        result = unittest.TestResult()
        runner = unittest.TextTestRunner()
        print(runner.run(scale_test_suite))

    if __name__ == '__main__':
        test_scale_suite()
Enter fullscreen mode Exit fullscreen mode

After writing a file like that for each module my project looked like this:

Output of tree command on the root of the project directory containing my code with module tests and tests.py in each module root

I was now able to run tests for each module from the project root with a command like this: python -m unittest backend.tests.

But what about running all tests from all modules with a single command?

With a tiny refactor of backend/tests.py and service/tests.py I was able to write a tests.py in the root directory and combine the backend and service test suites to a larger TestSuite

First I changed the tests.py in each module so that the TestSuite isn't created in the function but is an exported variable:

    import unittest

    from backend.test import TestCalibrationChecks
    from backend.test import TestCalibrationProcess
    from backend.test import TestSystemHealthChecks

    scale_test_suite = unittest.TestSuite([
        unittest.TestLoader().loadTestsFromTestCase(TestCalibrationChecks),
        unittest.TestLoader().loadTestsFromTestCase(TestCalibrationProcess),
        unittest.TestLoader().loadTestsFromTestCase(TestSystemHealthChecks)
    ])

    def test_scale_suite():
        result = unittest.TestResult()
        runner = unittest.TextTestRunner()
        print(runner.run(scale_test_suite))

    if __name__ == '__main__':
        test_scale_suite()
Enter fullscreen mode Exit fullscreen mode

Now I was able to define tests.py in the project root like this:

    import unittest

    from backend import tests as backend_tests
    from service import tests as service_tests

    complete_test_suite = unittest.TestSuite([
        backend_tests.scale_test_suite,
        service_tests.service_test_suite
    ])


    def run_all_suites():
        result = unittest.TestResult()
        runner = unittest.TextTestRunner()
        print(runner.run(complete_test_suite))


    if __name__ == '__main__':
        run_all_suites()
Enter fullscreen mode Exit fullscreen mode

The finished project now looks like this:

Output of tree command on the root of the project directory containing my code with module tests, tests.py in each module root, and a global tests.py in the project root

And from the project root I am able to run

  • all tests python -m tests
  • backend tests python -m unittest backend.tests
  • service tests python -m unittest service.tests

Mission Accomplished

Notes

  • I am aware there is a function unittest.TestLoader().discover() which will automatically discover all TestCases in a file tree. I chose not to use it because I like to explicitly declare what to run so I can see what to expect and avoid some tests not running because the discoverer missed them for some reason.
  • I am not saying this is the right way to organize your tests in Python, it just seems to work very well for me so far. Happy to hear your comments on this :)

Top comments (0)