14. pytest: Unit Testing#
# Small local extension
%load_ext save_and_run
I highly recommend taking some time to learn advanced pytest, as anything that makes writing tests easiser enables more and better testing, which is always a plus!
14.1. Tests should be easy#
Always use pytest. The built-in unittest is very verbose; the simpler the writing of tests, the more tests you will write!
%%save_and_run unittest
import unittest
class MyTestCase(unittest.TestCase):
def test_something(self):
x = 1
self.assertEqual(x, 2)
F
======================================================================
FAIL: test_something (tmp.MyTestCase)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/home/runner/work/level-up-your-python/level-up-your-python/notebooks/tmp.py", line 6, in test_something
self.assertEqual(x, 2)
AssertionError: 1 != 2
----------------------------------------------------------------------
Ran 1 test in 0.000s
FAILED (failures=1)
Contrast this to pytest:
%%save_and_run pytest
def test_something():
x = 1
assert x == 2
============================= test session starts ==============================
platform linux -- Python 3.10.13, pytest-7.4.3, pluggy-1.3.0
rootdir: /home/runner/work/level-up-your-python/level-up-your-python/notebooks
plugins: mock-3.12.0, anyio-4.1.0
collected 1 item
tmp.py F [100%]
=================================== FAILURES ===================================
________________________________ test_something ________________________________
def test_something():
x = 1
> assert x == 2
E assert 1 == 2
tmp.py:3: AssertionError
=========================== short test summary info ============================
FAILED tmp.py::test_something - assert 1 == 2
============================== 1 failed in 0.06s ===============================
pytest still gives you clear breakdowns, including what the value of x
actually is, even though it seems to use the Python assert
statement! You don’t need to set up a class (though you can), and you don’t need to remember 50 or so different self.assert*
functions! pytest can also run unittest tests, as well as the old nose
package’s tests, too.
Approximately equals is normally ugly to check, but pytest makes it easy too:
%%save_and_run pytest
from pytest import approx
def test_approx():
.3333333333333 == approx(1/3)
============================= test session starts ==============================
platform linux -- Python 3.10.13, pytest-7.4.3, pluggy-1.3.0
rootdir: /home/runner/work/level-up-your-python/level-up-your-python/notebooks
plugins: mock-3.12.0, anyio-4.1.0
collected 1 item
tmp.py . [100%]
============================== 1 passed in 0.01s ===============================
14.2. Tests should test for failures too#
You should make sure that expected errors are thrown:
%%save_and_run pytest --no-header
import pytest
def test_raises():
with pytest.raises(ZeroDivisionError):
1 / 0
============================= test session starts ==============================
collected 1 item
tmp.py . [100%]
============================== 1 passed in 0.00s ===============================
You can check for warnings as well, with pytest.warns
or pytest.deprecated_call
.
14.3. Tests should stay easy when scaling out#
pytest uses fixtures to represent complex ideas, like setup/teardown, temporary resources, or parameterization.
A fixture looks like a function argument; pytest recognizes them by name:
%%save_and_run pytest --no-header
def test_printout(capsys):
print("hello")
captured = capsys.readouterr()
assert "hello" in captured.out
============================= test session starts ==============================
collected 1 item
tmp.py . [100%]
============================== 1 passed in 0.00s ===============================
Making a new fixture is not too hard, and can be placed in the test file or in conftest.py
:
%%save_and_run pytest --no-header
import pytest
@pytest.fixture(params=[1,2,3], ids=["one", "two", "three"])
def ints(request):
return request.param
def test_on_ints(ints):
assert ints**2 == ints*ints
============================= test session starts ==============================
collected 3 items
tmp.py ... [100%]
============================== 3 passed in 0.01s ===============================
We could have left off ids
, but for complex inputs, this lets the tests have beautiful names.
Now you will get three tests, test_on_ints-one
, test_on_ints-two
, and test_on_ints-three
!
Fixtures can be scoped, allowing simple setup/teardown (use yield
if you need to run teardown). You can even set autouse=True
to use a fixture always in a file or module (via conftest.py
). You can have conftest.py
’s in nested folders, too!
Here’s an advanced example, which also uses monkeypatch
, which is a great way for making things hard to split into units into unit tests. Let’s say you wanted to make a test that “tricked” your code into thinking that it was running on different platforms:
%%save_and_run pytest --no-header
import platform
import pytest
@pytest.fixture(params=['Linux', 'Darwin', 'Windows'], autouse=True)
def platform_system(request, monkeypatch):
monkeypatch.setattr(platform, "system", lambda : request.param)
def test_anything():
assert platform.system() in {"Linux", "Darwin", "Windows"}
============================= test session starts ==============================
collected 3 items
tmp.py ... [100%]
============================== 3 passed in 0.01s ===============================
Now every test in the file this is in (or the directory that this is in if in conftest) will run three times, and each time will identify as a different platform.system()
! Leave autouse
off, and it becomes opt-in; adding platform_system
to the list of arguments will opt in.
14.4. Tests should be organized#
You can use pytest.mark.*
to mark tests, so you can easily turn on or off groups of tests, or do something else special with marked tests, like tests marked “slow”, for example. Probably the most useful built-in mark is skipif
:
%%save_and_run pytest --no-header
import pytest
@pytest.mark.skipif("sys.version_info < (3, 8)")
def test_only_on_37plus():
x = 3
assert f"{x = }" == "x = 3"
============================= test session starts ==============================
collected 1 item
tmp.py . [100%]
============================== 1 passed in 0.00s ===============================
Now this test will only run on Python 3.8, and will be skipped on earlier versions. You don’t have to use a string for the condition, but if you don’t, add a reason=
so there will still be nice printout explaining why the test was skipped.
You can also use xfail
for tests that are expected to fail (you can even strictly test them as failing if you want). You can use parametrize
to make a single parameterized test instead of sharing them (with fixtures). There’s a filterwarnings
mark, too.
Many pytest plugins support new marks too, like pytest-parametrize
. You can also use custom marks to enable/disable groups of tests, or to pass data into fixtures.
14.5. Tests should test the installed version, not the local version#
Your tests should run against an installed version of your code. Testing against the local version might work while the installed version does not (due to a missing file, changed paths, etc). This is one of the big reasons to use /src/package
instead of /package
, as python -m pytest
will pick up local directories and pytest
does not. Also, there may come a time when someone (possibly you) needs to run your tests off of a wheel or a conda package, or in a build system, and if you are unable to test against an installed version, you won’t be able to run your tests! (It happens more than you might think).
14.5.1. Mock expensive or tricky calls#
If you have to call something that is expensive or hard to call, it is often better to mock it. To isolate parts of your own code for “unit” testing, mocking is useful too. Combined with monkey patching (shown in an earlier example), this is a very powerful tool!
Say we want to write a function that calls matplotlib. We could use pytest-mpl
to capture images and compare them in our test, but that’s an integration test, not a unit test; and if something does go wrong, we are stuck comparing pictures, and we don’t know how our usage of matplotlib changed from the test report. Let’s see how we could mock it. We will use the pytest-mock
plugin for pytest, which simply adapts the built-in unittest.mock
in a more native pytest fashion as fixtures and such.
%%save_and_run pytest --no-header --disable-pytest-warnings
import pytest
from pytest import approx
import matplotlib.pyplot
from types import SimpleNamespace
def my_plot(ax):
ax.plot([1,3,2], label=None, linewidth=1.5)
@pytest.fixture
def mock_matplotlib(mocker):
fig = mocker.Mock(spec=matplotlib.pyplot.Figure)
ax = mocker.Mock(spec=matplotlib.pyplot.Axes)
line2d = mocker.Mock(name="step", spec=matplotlib.lines.Line2D)
ax.plot.return_value = (line2d,)
# Patch the main library if used directly
mpl = mocker.patch("matplotlib.pyplot", autospec=True)
mocker.patch("matplotlib.pyplot.subplots", return_value=(fig, ax))
return SimpleNamespace(fig=fig, ax=ax, mpl=mpl)
def test_my_plot(mock_matplotlib):
ax = mock_matplotlib.ax
my_plot(ax=ax)
assert len(ax.mock_calls) == 1
ax.plot.assert_called_once_with(
approx([1.0, 3.0, 2.0]),
label=None,
linewidth=approx(1.5),
)
============================= test session starts ==============================
collected 1 item
tmp.py . [100%]
============================== 1 passed in 1.48s ===============================
We’ve just mocked the parts we touch in our plot function that we need to test. We use spec=
to get the mock to just respond to the same things that the original object would have responded to. We can set return values so that our objects behave like the real thing.
If this changes, we immediately know exactly what changed - and this runs instantly, we aren’t making any images! While this is a little work to set up, it pays off in the long run.
The documentation at pytest-mock is helpful, though most of it just redirects to the standard library unittest.mock.