About Unittest

Unit Testing is the process of writing and automatically running tests to ensure that the functions you code work as expected.

What Is Unit Testing and Why Is It Important?

What is unittest?

A “Unit” is the smallest possible software component in your app (i.e, functions, classes, or components). Individual unit tests make sure that the core component of your app is behaving as expected, and that a future commit to one area of your code doesn’t break code in another. If it does, you likely have a bug in either your new or old code (or in a poorly written/outdated test).

The goal of unit tests is obvious—reduce bugs, especially bugs that arise from integration. A developer might think everything is fine locally and commit their code, only to find out that another commit has broken the app. Unit testing helps catch some of these defects before they become issues, and when combined with automated continuous integration pipelines, can make sure that the daily build is always working properly.

Unit testing isn’t limited to small bits of code; You can also unit test larger components that make use of multiple other functions, that may themselves be unit tested. This helps you track down errors more effectively—is the error in the methods of the large component object, or in one of the other components it makes use of?

While unit tests are important, they also aren’t the only testing you should be doing. Running End-to-End UI testing and manual human review will catch plenty of logic bugs that unit tests may miss when every unit is operating as expected.

Unit Testing Leads to Cleaner Codebases

One of the main problems with legacy codebases is dinosaur code—code so old that it’s basically a black box, you might have no idea how it works, but somehow it does work, and you don’t want to refactor it due to fears it might break everything.

In a way, when you write unit tests, you’re writing documentation for it. You might not have to write a whole manual, but you’ll always be defining two things: what to give the function, and what it returns, similarly to how you’d define an API schema. With these two bits of information, it’s clear what the function does, and how to integrate it into your app. Obviously, unit testing doesn’t solve existing legacy codebases, but it does prevent you from writing this type of dinosaur code in the first place.

Often, you’ll be able to write your tests before the actual function you’re testing. If you know what your function needs to do, writing the test first forces you to think about the end result of your code, and what it is responsible for.

If you like this effect of unit testing, you might also be interested in TypeScript—a compiled superset of JavaScript that makes it strongly typed. You’ll still want to write unit tests, but knowing what types a function gives and takes while you’re coding is a very useful feature.

How to write an unittest?

A sample example

Now we will see how to use the Python unittest framework to write the three tests executed in the previous section.

Firstly, let’s assume that the main application code is in the file user.py. You will write your unit tests in a file called test_user.py.

The common naming convention for unit tests: the name of the file used for unit tests simply prepends “test_” to the .py file where the Python code to be tested is.

To use the unittest framework we have to do the following:

  • import the unittest module

  • create a test class that inherits unittest.TestCase. We will call it TestUser.

  • add one method for each test.

  • add an entry point to execute the tests from the command line using unittest.main.

Here is a Python unittest example:

import unittest

class TestUser(unittest.TestCase):

    def test_user_activation(self):
        pass

    def test_user_points_update(self):
        pass

    def test_user_level_change(self):
        pass

if __name__ == '__main__':
    unittest.main()

You have created the structure of the test class. Before adding the implementation to each unit test class method let’s try to execute the tests to see what happens.

To run unit tests in Python you can use the following syntax:

python test_user.py
# ...
# ---------------------------------------------------------------------------
# Ran 3 tests in 0.000s
# 
# OK

How to write?

Now that we have the structure of our test class we can implement each test method.

Unit tests have this name because they test units of your Python code, in this case, the behavior of the methods in the class User.

Each unit test should be designed to verify that the behavior of our class is correct when a specific sequence of events occurs. As part of each unit test, you provide a set of inputs and then verify the output is the same as you expected using the concept of assertions.

In other words, each unit test automates the manual tests we have executed previously.

Technically, you could use the assert statement to verify the value returned by methods of our User class.

In practice, the unittest framework provides its assertion methods. We will use the following in our tests:

  • assertEqual

  • assertTrue

Let’s start with the first test case…

…actually before doing that we need to be able to see the User class from our test class.

How can we do that?

This is the content of the current directory:

$ ls
test_user.py user.py 

To use the User class in our tests add the following import after the unittest import in test_user.py:

from user import User

And now let’s implement three unit tests.

1st Use Case: User state is active after activation has been completed

def test_user_activation(self):
    user1 = User()
    user1.activate()
    self.assertTrue(user1.is_active())

In this test, we activate the user and then assert that the is_active() method returns True.

2nd Use Case: User points are incremented correctly

def test_user_points_update(self):
    user1 = User()
    user1.add_points(25)
    self.assertEqual(user1.get_points(), 25)

This time instead of using assertTrue we have used assertEqual to verify the number of points assigned to the user.

3rd Use Case: User level changes from 1 to 2 when the number of points is greater than 200

def test_user_level_change(self):
    user1 = User()
    user1.add_points(205)
    self.assertEqual(user1.get_level(), 2)

The implementation of this unit test is similar to the previous one with the only difference being that we are asserting the value of the level for the user.

And now it’s the moment to run our tests…

$ python test_user.py
...
---------------------------------------------------------------------------
Ran 3 tests in 0.000s

OK
All the tests are successful!

An Example of Unit Test Failure

Before completing this tutorial I want to show you what would happen if one of the tests fails.

First of all, let’s assume that there is a typo in the is_active() method:

def is_active(self):
    return self.profile['active_user']

I have replaced the attribute active of the user profile with active_user which doesn’t exist in the profile dictionary.

Now, run the tests again…

$ python test_user.py
E..
===========================================================================
ERROR: test_user_activation (__main__.TestUser)
---------------------------------------------------------------------------
Traceback (most recent call last):
  File "test_user.py", line 9, in test_user_activation
    self.assertTrue(user1.is_active())
  File "/opt/Python/Tutorials/user.py", line 9, in is_active
    return self.profile['active_user']
KeyError: 'active_user'

---------------------------------------------------------------------------
Ran 3 tests in 0.000s

FAILED (errors=1)

In the first line of the test execution you can see:

E..

Each character represents the execution of a test. E indicates an error while a dot indicates a success.

This means that the first test in the test class has failed and the other two are successful.

The output of the test runner also tells us that the error is caused by the assertTrue part of the test_user_activation method.

This helps you identify what’s wrong with the code and fix it.

To further learn how to write unittest, one can refer to our tests directory or go to the python website for more information.

How to run unittest?

Recently we have code covered about 80% by unittest which including most of the single units and the whole run loop.

To run all unittests, one need to set several environment variables to make test_zrunnner.py (for testing the whole loop) work.

For example, one should use command as the following:

CALYPSO_M3GNET_PYTHON="/home/wangzy/soft/anaconda3/envs/m3gnet/bin/python" CALYPSO_CHGNET_PYTHON="/home/wangzy/soft/anaconda3/envs/chgnet/bin/python" make test

In this command, CALYPSO_M3GNET_PYTHON and CALYPSO_CHGNET_PYTHON are used to set the python path of m3gnet and chgnet for running the threadrunner.py and splitrunner.py.

If one do not want to test the runner, typing make test is sufficient. Behind this command is

.PHONY: all init test lint format clean

init:
        git config --unset-all core.hooksPath
        # git config core.hooksPath .git/hooks/
        cp .githooks/* .git/hooks/
        pre-commit install

test:
        python -m coverage run -m unittest -v
        python -m coverage xml
        python -m coverage report
        @make lint

lint:
        python -m mypy calypso tests
        python -m black --check -S calypso tests
        python -m isort --check calypso tests

format:
        python -m black -S calypso tests docs
        python -m isort calypso tests docs

manual: clean
        cd docs; make all-html; cd ..

clean:
        rm -rf ./docs/developer_source/api 
        rm -rf docs/build* 

How to set an unittest workflow in GitHub action?

pass