Python Testing using pytest
Overview
Teaching: 60 min
Exercises: 10 minQuestions
How is a Python module tested?
Objectives
Explain the overall structure of testing.
Explain the reasons why testing is important.
Understand how to write tests using the pytest framework.
Lesson Contents
Until now, we have been writing functions and checking their behavior using assert statements.
While this seems to work, it can be tedious and prone to error.
In this lesson, we’ll discuss how to write tests and run them using the pytest
testing framework.
Using a testing framework will allow us to easily define tests for all of our functions and modules, and to test these each time we make a change. This will ensure that our code is behaving the way we expect, and that we do not break any features existing in the code by making new changes.
This episode explains the importance of code testing and demonstrates the possible capabilities.
Why testing
Software should be tested regularly throughout the development cycle to ensure correct operation. Thorough testing is typically an afterthought, but for larger projects, it is essential for ensuring changes in some parts of the code do not negatively affect other parts.
Software testing is checking the behavior of part of the code (such as a method, class, or a module) by comparing its expected output or behavior with the observed one. We will explain this in more detail shortly.
Unit vs Regression vs Integration testing
There are three main levels of testing:
-
Unit tests: the purpose is to verify that each part of the code is functioning as expected. Unit testing is done on smaller units (such as single functions or classes) as you work on your code. This is helpful for catching errors in uncommonly-used parts of the code. Unit tests should be added as new features are added, resulting in better code coverage. In unit tests, you are testing a part of your code independent of any other factors; therefore, you should avoid using the file system, databases, network, or any other resources unless you are testing a function directly related to that resource.
-
Integration tests: this is a more holistic approach where you test the interface between modules, and how they combine and integrate together.
-
System tests: where you test your system as a whole to check if it meets all the requirements.
In this lesson, we are focusing on unit testing. The same concepts here can be applied to perform Integration tests across modules.
The pytest testing framework
We recommend using the pytest testing framework.
Other testing frameworks are available (such as unittest and nose tests);
however, the combination of easy implementation, parametrization of tests,
fixtures, and test marking
make pytest
an ideal testing framework.
If you don’t have pytest
installed or it’s not updated to version 3, install it using:
$ pip install -U pytest-cov
Running our first test
When we run pytest
, it will look for directories and files which start with test
or test_
. It then looks inside of those files and executes any functions that begin with the word test_
. This syntax lets pytest know that these functions are tests. If these functions do not result in an error, pytest
counts the function as passing. If an error occurs, the test fails.
Create a directory called tests
in your mcsim
package. In your tests
directory, create an empty file called test_coord.py
. It is a good idea to separate your tests into different modules based on which python file you will be testing.
Let’s write a test for our calculate_distance
function.
Add the following contents to the test_coord.py
file.
"""
Tests for the coord module
"""
# import your package.
# we can do this because we used setup.py and pip install.
import mcsim
from mcsim.monte_carlo import calculate_distance
In this first part of the file, we are getting ready to write our test.
We first import the libraries and modules we need.
We will only need the mcsim
package we have installed in the previous lesson.
Next, we write our test.
When you write a test to be run with pytest
, it must be inside a function that begins with the word test.
We set up our calculation, then we use the function we are testing and compare the output with our expected output.
This is very similar to what we did before with our assert
statements, except that it is now inside of a function:
def test_calculate_distance():
point_1 = [0, 0, 0]
point_2 = [1, 0, 0]
expected_distance = 1
dist1 = calculate_distance(point_1, point_2)
assert dist1 == expected_distance
We can run this test using pytest
in our terminal. In the folder containing both mcsim
and tests
, type
$ pytest
You should see an output similar to the following.
==================== test session starts ====================
platform darwin -- Python 3.7.6, pytest-5.4.2, py-1.8.1, pluggy-0.13.1
rootdir: /Users/jessica/lesson-follow/monte-carlo-lj
plugins: cov-2.10.0
collected 1 item
tests/test_coord.py .
Here, pytest
has looked through our directory and its subdirectories for anything matching test*
.
It found the tests
folder, and within that folder, it found the file test_coord.py
.
It then executed the function test_calculate_distance
within that module.
Since our assertion
was True
, our test did not result in an error and the test passed.
We can see the names of the tests pytest
ran by adding a -v
tag to the pytest command.
$ pytest -v
Using the command argument -v
will result in pytest listing which tests are executed and whether they pass or not.
There are a number of
additional command line arguments to explore.
==================== test session starts ====================
platform darwin -- Python 3.7.6, pytest-5.4.2, py-1.8.1, pluggy-0.13.1 -- /Users/jessica/miniconda3/envs/molssi_best_practices/bin/python3.7
cachedir: .pytest_cache
rootdir: /Users/jessica/lesson-follow/monte-carlo-lj
plugins: cov-2.10.0
collected 1 item
tests/test_coord.py::test_calculate_distance PASSED [100%]
Now we see that pytest
displays the test name for us, as well as PASSED
next to the test name.
Failing tests
Let’s see what happens when a test fails.
In case of test failure, Pytest will show detailed output from doing its own analysis to discover the error by inspecting your objects at runtime. Change the value of the expected
variable in your test function to 2
and rerun the test.
$ pytest -v
==================== test session starts ====================
platform darwin -- Python 3.7.6, pytest-5.4.2, py-1.8.1, pluggy-0.13.1 -- /Users/jessica/miniconda3/envs/molssi_best_practices/bin/python3.7
cachedir: .pytest_cache
rootdir: /Users/jessica/lesson-follow/monte-carlo-lj
plugins: cov-2.10.0
collected 1 item
tests/test_coord.py::test_calculate_distance FAILED [100%]
========================= FAILURES ==========================
__________________ test_calculate_distance __________________
def test_calculate_distance():
point_1 = [0, 0, 0]
point_2 = [1, 0, 0]
expected_distance = 2
dist1 = calculate_distance(point_1, point_2)
> assert dist1 == expected_distance
E assert 1.0 == 2
E +1.0
E -2
tests/test_coord.py:23: AssertionError
Pytest shows a detailed failure report, including the source code around the failing line.
The line that failed is marked with >
.
Next, it shows the values used in the assert comparison at runtime, that is ` 1.0 == 2`.
This runtime analysis is one of the advantages of pytest that help you debug your code.
Check Your Understanding
What happens if you leave your
expected_value
equal to 2, but remove the assertion? Change your assertion line to the followingexpected_distance == calculated_distance
-
Answer
If you remove the word
assert
, you should notice that your test still passes. This is because the expression evaluated toFalse
, but since there was no Assertion, there was no error. Since there was no error, pytest counted it as a passing test. Theassert
statement causes an error when it evaluates to False.It’s very important to remember that
pytest
counts a test as failing when some type of exception occurs.
Change the expected value back to 1 so that your tests pass and make sure you have the assert
statement.
Exercise
Create a second test for the
calculate_distance
function. Use the following points and box length:r1 = [0, 0, 0] r2 = [0, 0, 8] box_length = 10
With periodic boundaries, these points correspond to a distance of 2.
Verify that your test is working by running pytest. You should now see two passing tests.
Solution
def test_calculate_distance2(): point_1 = [0, 0, 0] point_2 = [0, 0, 8] box_length = 10 expected_distance = 2 dist1 = calculate_distance(point_1, point_2, box_length=box_length) assert dist1 == expected_distance
Advanced features of pytest
Python Decorators
Some of pytest’s advanced features make use of decorators. Decorators are a very powerful tool in programming, which we will not explore in depth here. You can think of them as functions that act on other functions. To decorate a particular function, you write the name of the decorator, preceded by
@
, in the line above thedef
statement:@decorator def foo(): pass
Pytest Marks
Pytest marks allow you to mark your functions.
There are built in marks for pytest and you can also define your own marks. Marks are implemented using decorators.
One of the built-in marks in pytest is @pytest.mark.skip
. Modify your test_calculate_distance
function to use this mark.
@pytest.mark.skip
def test_calculate_distance():
point_1 = [0, 0, 0]
point_2 = [1, 0, 0]
expected_distance = 1
dist1 = calculate_distance(point_1, point_2)
assert dist1 == expected_distance
When you run your tests, you will see that this test is now skipped:
tests/test_coord.py::test_calculate_distance SKIPPED [ 50%]
tests/test_coord.py::test_calculate_distance2 PASSED [100%]
You might also use the pytest.mark.xfail
if you expect a test to fail.
Edge and Corner Cases
Edge cases
The situation where the test examines either the beginning or the end of a range, but not the middle, is called an edge case. In a simple, one-dimensional problem, the two edge cases should always be tested along with at least one internal point. This ensures that you have good coverage over the range of values.
Anecdotally, it is important to test edges cases because this is where errors tend to arise. Qualitatively different behavior happens at boundaries. As such, they tend to have special code dedicated to them in the implementation.
Corner cases
When two or more edge cases are combined, it is called a corner case. If a function is parametrized by two linear and independent variables, a test that is at the extreme of both variables is in a corner.
Code Coverage
Now that we have a set of modules and associated tests, we want to see how much of our package is “covered” by our tests. We’ll measure this by counting the lines of our packages that are touched, i.e. used, during our tests.
We already have everything we need for this since we installed pytest-cov
earlier which includes the coverage tools on top of the pytest
package.
We can assess our code coverage as follows:
pytest --cov=mcsim
==================== test session starts ====================
platform darwin -- Python 3.7.6, pytest-5.4.2, py-1.8.1, pluggy-0.13.1
rootdir: /Users/jessica/lesson-follow/monte-carlo-lj
plugins: cov-2.10.0
collected 6 items
tests/test_coord.py ...... [100%]
---------- coverage: platform darwin, python 3.6.8-final-0 -----------
Name Stmts Miss Cover
------------------------------------------
mcsim/coord.py 33 8 76%
mcsim/energy.py 34 34 0%
mcsim/monte_carlo.py 51 51 0%
------------------------------------------
TOTAL 118 93 21%
===================== 6 passed in 0.07s =====================
The output shows how many statements (i.e. not comments) are in a file, how many weren’t executed during testing, and the percentage of statements that were.
To improve our coverage, we also want to see exactly which lines we missed and we can determine this using the .coverage
file produced by pytest
. You can see an html version by running the command
pytest --cov=mcsim --cov-report=html
You will now have an additional folder called htmlcov
in the directory.
You can open the index.html
file in this folder in your browser to view your code coverage report.
Do we need to get 100% coverage?
Short answer: no. Code coverage is a useful tool to assess how comprehensive our set of tests are and in general the higher our code coverage the better. However, trying to achieve 100% coverage on packages any larger than this sample package is a bit unrealistic and would require more time than that last bit of coverage is worth.
Key Points
A good set of tests covers individual functions/features and behavior of the software as a whole.
It’s better to write tests during development so you can check your progress along the way. The longer you wait to start the harder it is.
Try to test as much of your package as you can, but don’t go overboard, most packages don’t have 100% test coverage.