Writing unit tests should an integral part of delivering software for every developer. Whenever a piece of code is changed, it has the potential to break all other parts. The broken parts can even be discovered in a far later stage, having caused potential damage that is hard to restore. Regression tests are a way to prevent unwanted changes to a working system.
Tests should be: fast, isolated, repeatable, self-checking (the test itself can check whether it failed or passed), timely (to write the test).
Let’s create a very simple python project, take a look at my previous post on how to create a virtual environment, then set up the following folder structure:
➜ tree -a -L 2
.
├── .venv
│ └── ...
├── Pipfile
├── Pipfile.lock
├── src
│ └── demo
│ └── ...
└── tests
└── demo
└── ...
9 directories, 3 files
Now we will create a very basic program in the src/demo/
folder called main.py
, which looks like this:
import sys
def say_hello(name='World'):
return f'Hello {name}!'
if __name__ == "__main__":
try:
your_name = sys.argv[1]
print(say_hello(your_name))
except IndexError:
print('Please enter your name.')
Run the program to see if it works:
➜ python src/demo/main.py
Please enter your name.
➜ python src/demo/main.py Stefan
Hello Stefan!
Great! Let’s proceed and add a few test cases.
Make sure pytest is installed in your virtual environment:
pipenv install -d pytest
Create a python file called test_main.py
to the tests/demo/
folder that contains the following code:
from demo.main import say_hello
def test_say_hello():
assert say_hello('Bob') == 'Hello Stefan!'
Note if you are receiving “No module named demo…”, take a look at packaging python code, and install your project locally, using the
pip install -e .
command, then run the tests again.
Run the tests to see if they succeed:
➜ pytest
...
E AssertionError: assert 'Hello Bob!' == 'Hello Stefan!'
E - Hello Bob!
E + Hello Stefan!
The test failed, but that’s good! Now you are aware that the code is broken before you ship it.
Note if you are following along, fix the test before you proceed.
We have now added proper tests to our code. It is good practice to document your code, and docstrings are a great place to show examples to show other developers how your code should be used and what your intentions are. This is great until you forget to update the docstring after making a change to the code.
Doctests are a great way to test and prove that the documentation is correct and up-to-date. Let’s add a docstring to our function:
def say_hello(name='World'):
"""Say hello.
>>> say_hello('Stefan')
'Hello Bob!'
"""
return f'Hello {name}!'
Run the doctests using pytest, we’ll get an error message as expected:
➜ pytest --doctest-modules
...
009 >>> say_hello('Stefan')
Expected:
'Hello Bob!'
Got:
'Hello Stefan!'
You can also check whether exceptions are raised as expected (use the wildcard ...
in the result to capture generic output):
"""
>>> say_hello(1, 2) # doctest: +ELLIPSIS
Traceback (most recent call last):
...
TypeError: ... takes from 0 to 1 ... but 2 were given
"""
Our function accepts a very basic string argument. In some cases you may want to define the data that is used in the tests beforehand. Fixtures are a great way to inject arguments to your functions, making your tests more readable.
Add a file called conftest.py
in the root of your project, and paste the following code inside:
"""Some data for our tests."""
from pytest import fixture
@fixture
def names():
return 'Bob', '', None, 123, [], ()
Modify your test files to use this new fixture:
def test_say_hello(names):
assert say_hello('Stefan') == 'Hello Stefan!'
bob, empty, none, integer, li, tup = names
assert say_hello(bob) == 'Hello Bob!'
assert say_hello(empty) == 'Hello !'
assert say_hello(none) == 'Hello None!'
assert say_hello(integer) == 'Hello 123!'
assert say_hello(li) == 'Hello []!'
assert say_hello(tup) == 'Hello ()!'
You can also use fixtures in doctests!
"""
>>> bob, *_ = getfixture('names')
>>> bob
'Bob'
>>> say_hello(bob)
'Hello Bob!'
"""