How To Write Unit Test - Advanced

By JoeVu, at: Feb. 22, 2023, 7:39 p.m.

Estimated Reading Time: 17 min read

Advance Python Unittest
Advance Python Unittest

This is the next chapter of How To Write Unit Test in Python - Beginner

In this chapter, we will cover some important topics

  1. Test Discovery
  2. Setup and Teardown
  3. Mocking
  4. Coverage + Report
  5. Tips + Best practices + Guidelines
  6. UnitTest Principles and key concepts
  7. References

Lets go through one by one

1. Test Discovery

Python unittest discover works by scanning through all of the files in the directory and its subdirectories and finding any files that match the test pattern. The test pattern is defined by the user and can be customized to find tests that are specific to the project. Once the test pattern is identified, the tool will execute all of the tests that match the pattern.

What are the benefits of using Python unittest discover?

Using Python unittest discover can save developers and testers time and effort by simplifying the process of finding and running tests

Assuming we have a tests directory as follow, you can check the sample code here 

garage/
    cars/
        honda.py
        test_honda.py
    libs/
        datetimes.py
        test_datetimes.py


You can pull the code and go to the root directory to run the tests 

cd garage
python -m unittest discover


By default, it automatically finds all test*.py files in the current directory (garage), we can achieve the same effect by

python -m unittest  # to run all tests under the directory garage
python -m unittest discovery libs  # to run all tests under the directory garage/libs
python -m unittest discovery cars  # to run all tests under the directory garage/honda
python -m unittest cars/test_honda.py  # to run the test file cars/test_honda.py
python -m unittest cars.test_honda  # to run the test file cars/test_honda.py


2. Setup and Teardown

Setup and teardown are important features in Python unittest, there are 4 methods

- setUp
- setUpClass
- tearDown
- tearDownClass


The setUp() and tearDown() methods are used to define code that runs before and after each test method, while the setUpClass() and tearDownClass() methods are used to define code that runs before and after the whole test suite.

For example, suppose you have a class with a set of tests to verify the behavior of a method that creates a file. You can use setUp() and tearDown() to create and delete the file before and after each test. The code might look something like this:


import os

class TestFileUpload(unittest.TestCase):
    def setUp(self):
        self.file_name = "temporary.txt"
        with open(self.file_name, "w") as fp:
            pass
 
    def tearDown(self):
        os.remove(self.file_name)


In this example, the setUp() method creates a new file for each test, and the tearDown() method removes it after the test has run.

In some cases, you may need to perform setup and teardown operations for the entire test suite, rather than for each individual test. For example, if you are testing against a database, you may need to set up the database connection once, before all of the tests run, and then tear it down after all of the tests have finished. This can be done with the setUpClass() and tearDownClass() methods, like this:


class TestDatabase(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        cls.db_connection = DatabaseConnection("localhost", "test_db")
 
    @classmethod
    def tearDownClass(cls):
        cls.db_connection.close()


In this example, the setUpClass() method creates a new database connection, and the tearDownClass() method closes it after all of the tests have finished.

This is extremely useful when you work with a Django application, where setUp and setUpClass are used for the test records population, and tearDown and tearDownClass are used for test records cleanup for each test case, each test class.


3. Mocking

Mock objects are simulated objects that mimic the behavior of real objects in controlled ways. They are used in unit testing to isolate and control the environment in which tests are run, and to simplify tests by removing the need to create actual objects.

The unittest library provides the patch, patch.object and side_effect methods to create mock objects. The patch method is used to patch a function or class, while the patch.object method is used to patch an instance of a class. The side_effect method allows developers to specify the behavior of a mock object, such as what it will return when called.

For example, assuming there is a facebook api library, and we have a function to call one facebook api.

file facebook_api.py

import requests


class FacebookAPI:
    def get_userinfo(self, username):
        response = requests.get("https://facebook.com/")
        if response.status_code == 200:
            return response.json()
        return {}


file user.py

from libs.facebook_api import FacebookAPI


def is_registered_user(username):
    user_info = FacebookAPI().get_userinfo(username)
    return user_info != {}


file test_user.py

import unittest
from unittest import mock
from libs.facebook_api import FacebookAPI
from libs.user import is_registered_user


class TestDatetimes(unittest.TestCase):
    @mock.patch.object(FacebookAPI, "get_userinfo")
    def test_is_registered_user_return_true(self, mock_get_userinfo):
        mock_get_userinfo.return_value  = {"userid": 1}
        result = is_registered_user("joe")
        self.assertTrue(result)

    @mock.patch.object(FacebookAPI, "get_userinfo")
    def test_is_registered_user_return_false(self, mock_get_userinfo):
        mock_get_userinfo.return_value  = {}
        result = is_registered_user("joe")
        self.assertFalse(result)


As the FacebookAPI makes a request to Facebook URL, which we don't want to execute in unittest, we use mock.

Using mock objects in unittest allows developers to quickly and easily create unit tests without having to interact with the actual implementation of the code. Mock objects provide a way to isolate and control the environment in which tests are run, and simplify tests by removing the need to create actual objects.


4. Test Coverage + Report

Python unittest allows developers to write tests to cover the source code, but it does not show the coverage report (how much percentage the source code is covered by test). Code coverage is an important metric to measure the effectiveness of unit tests. Code coverage measures the amount of code that is executed by the tests. The higher the coverage, the better the tests.

The Coverage package is a popular tool for measuring code coverage in Python. It can be used to measure the coverage of tests written using the Python unittest module. The Coverage package can be installed using the pip command: pip install coverage

Once installed, Coverage can be used to generate a report of the code coverage of the tests. To generate a report, the Coverage command needs to be executed with the appropriate parameters. For example, to generate a report of the code coverage of all the tests in the test_module.py file, the following command needs to be executed:

coverage run -m unittest discover


Once the command is executed, Coverage will generate a report of the code coverage for the tests. This report can be viewed in the terminal, or it can be written to a file in the HTML or XML format.

coverage report

Name                     Stmts   Miss  Cover
--------------------------------------------
cars/__init__.py             0      0   100%
cars/honda.py               29      0   100%
cars/test_honda.py          41      0   100%
libs/__init__.py             0      0   100%
libs/datetimes.py            7      0   100%
libs/facebook_api.py         7      4    43%
libs/test_datetimes.py      16      0   100%
libs/test_user.py           15      0   100%
libs/user.py                 4      0   100%
--------------------------------------------
TOTAL                      119      4    97%


Or we can use

coverage html


This writes HTML report to htmlcov/index.html

open htmlcov/index.html


This will open a new browser window to analyze which piece of code is not test covered yet.

The Coverage report is an invaluable tool for developers. It provides them with a detailed view of the code coverage of their tests. By analyzing the report, developers can identify areas of their code that are not be tested.


5. Tips + Best practices + Guidelines

  1. Use Test-Driven Development: Test-driven development, or TDD, is a powerful tool for developing robust unit tests. It involves writing a test for each feature of your code before you actually write the code itself. This helps to ensure that the code meets all the specifications and requirements set out by the development team.
  2. Create Independent Tests: It's important to create tests that are independent of each other. This means that each test should focus on a single feature or aspect of the code, rather than testing all the features together. This ensures that any bugs or issues that arise in one part of the code don't affect the other parts of the code.
  3. Write Detailed Test Cases: Writing detailed test cases is an important step in the unit testing process. Each test should clearly state what it is testing and what the expected outcome
  4. Organize test cases and test files: For each file in the source code (facebook_api.py), there will be a test file for that with prefix "test_" (test_facebook_api.py). Each function/method will have a list of test cases to cover all logic
    - output test: value and format
    - side effect test: exception handling test
    - for every single if/else statement, try/except, there should be a test
  5. Be Disciplined and commit: Writing good unit tests requires time and dedication. Not only do you need to modify your tests when making changes to your code, but you may also discover bugs in your test code. Despite the effort involved, the benefits of unit testing are invaluable, even for small projects. It is important to remember, however, that these rewards come at a cost. You must be disciplined. Be consistent. Make sure the tests always pass. Don't let the tests be broken because you "know" the code is OK.
  6. Automate: In order to maintain discipline, you should set up automated unit tests. These tests should be run at key moments such as pre-commit or pre-deployment. Additionally, it would be beneficial for your source control management system to reject any code that does not pass all its tests. Using pre-commit https://pre-commit.com/ is the best option here


6. UnitTest Principles and key concepts

6.1 Design for Testability

Design for testability is a key concept in python unittest. It involves designing code that is easy to test and has a good structure for testing. 

  • code is modularized, that functions are unit-testable
  • proper logging and error handling. 
  • writing tests that are meaningful, that cover all necessary code paths, and that are easy to maintain. 
  • using tools like test runners and continuous integration to ensure tests are regularly run and that tests cover all necessary scenarios. 
  • create code that is easier to test, maintain, and improve.

6.2 Cost/Benefit

With python unittest, developers can quickly and easily assess the cost/benefit of individual tests by considering:

  • the number of lines of code
  • speed of execution

in order to gain the best approach for their project, ensuring the best outcome with the least amount of resources.

6.3 Testing Mindset

The testing mindset emphasizes the following

  • Making sure that each test is specific and independent of other tests
  • Creating assertions to verify expected behavior
  • Formulating a plan for writing tests
  • Using the test-driven development methodology 
  • Documenting test results

6.4 Pure Functions

Pure functions in python unittest are functions which do not rely on any external factors to produce the same output. They are also referred to as deterministic functions. The benefits of using such functions in unittesting are:

  • Easier to write tests for as the same input always produces the same output
  • No need to worry about external factors affecting the test results
  • Makes tests more reliable and repeatable
  • Helps to maintain code consistency and reduce bugs in the long run

6.5 Testing Error Handling

Testing Error Handling in Python unittest is the process of ensuring that errors are handled properly in the code. Error handling is an important aspect of programming and is necessary to ensure that code runs as expected. Unittest is a powerful testing framework provided by Python that allows developers to test code with ease. Here are some ways to test error handling in Python unittest:

  • Create a test case that throws an exception and verify that the expected exception is thrown.
  • Create a test case that takes an expected exception as an input and verify that the expected exception is thrown.
  • Create a test case that takes an unexpected exception as an input and verify that the expected exception is not thrown.
  • Create a test case that checks for the correct error message when an exception is thrown.
  • Create a test case that checks for the correct handling of errors.

6.6 Testing Private Methods

Testing private methods in python unittest is a useful way to make sure that all parts of a program are working correctly. Here are a few benefits of testing private methods:

  • Detect and fix errors quickly, as private methods can be tested without having to run the entire program.
  • Identify the source of an issue more quickly, as they can narrow down the search to a specific private method.
  • Promote better code design, as private methods can be tested to make sure they are written correctly.
  • Refactor code more safely, as the tests can be used to make sure that the private methods still function correctly after refactoring.

6.7 How to Organize Your Unit Tests

Organizing unit tests in python unittest is a great way to ensure that your code is thoroughly tested and functioning properly. Here are a few tips on how to organize your unit tests:

  • Group tests into logical test suites - Group your tests into suites that are related to a specific section of your code. This will help you keep your tests organized and easy to read.
  • Write descriptive test names - Give your tests descriptive names that help explain what the test is doing. This will help you quickly identify which tests are related to a specific part of your code.
  • Use setUp and tearDown - Use the setUp and tearDown methods to set up and clean up any resources you may need for your tests. This will help reduce duplicate code and make your tests easier to read.
  • Create data-driven tests - Create data-driven tests to test your code against multiple sets of data. This will help make sure that your code is functioning properly no matter what data is inputted.
  • Utilize assertions - Use assert statements to make sure that your code is returning the expected results. This will help you quickly identify any issues with your code.


7. References

Books
    Python Testing Cookbook
    Test-Driven Development with Python
    Testing In Python

Links
    https://docs.python.org/3/library/unittest.html 
    https://docs.python.org/3/library/unittest.mock.html
    https://coverage.readthedocs.io/en/7.1.0/
 


Related

Subscribe

Subscribe to our newsletter and never miss out lastest news.