More pytest#
Unit tests sometimes require some setup to be done before the test is run. Fixtures provide this capability, allowing tests to run with a consistent environment and data.
Standard pytest fixtures are written as functions with the @pytest.fixture
decorator:
@pytest.fixture
def message():
return "Hello world!"
A fixture may return an object, which will be passed to any function that requests it, or it may just do some setup tasks (like creating a file or connecting to a database).
Test functions can request a fixture by specifying a parameter with the same name as the fixture:
def test_split(message):
assert len(message.split()) == 2
An alternate method for initializing test state is with explicit setup/teardown functions, which we’ll look at a bit later. This is a style that’s available in many other languages as well: see https://en.wikipedia.org/wiki/XUnit.
Fixtures examples#
Fixtures are reusable across different tests. This lets us avoid repeating the same setup code in multiple places, especially as we add more tests or need more complicated inputs.
Here are some tests for the Item
class that use fixtures, adapted from the
shopping cart exercise. The full code is available
here
on the github repository for this site. You can download this file and run
the tests with pytest -v test_item.py
.
@pytest.fixture
def a():
return Item("apple", 10)
@pytest.fixture
def b():
return Item("banana", 20)
@pytest.fixture
def c():
return Item("apple", 20)
All the fixtures that a test depends on will run once for each test. This gives each test a fresh copy of the data, so any changes made to the fixture results inside a test won’t impact other tests.
def test_add(a, c):
# modifies a
a += c
assert a.quantity == 30
def test_repr(a, b, c):
# receives unmodified a
assert repr(a) == "apple: 10"
assert repr(b) == "banana: 20"
assert repr(c) == "apple: 20"
def test_equality(a, b, c):
assert a != b
assert a == c
We can also test that a function raises specific exceptions with pytest.raises
:
def test_invalid_add(a, b):
with pytest.raises(ValueError, match="names don't match"):
a + b
def test_invalid_name():
with pytest.raises(ValueError, match="invalid item name"):
d = Item("dog")
Fixtures can request other fixtures#
This is useful to split up complex initialization into smaller parts. A fixture can also modify the results of the fixtures it requests, which will be visible to anything that includes the fixture.
Here is a set of tests that show how this can be used (test_list.py):
import pytest
@pytest.fixture
def numbers():
return []
@pytest.fixture
def append_1(numbers):
numbers.append(1)
@pytest.fixture
def append_2(numbers, append_1):
numbers.append(2)
Note that append_1()
and append_2()
only modify numbers
, and don’t return
anything. append_2()
requires append_1
, to make sure they are run in the
right order.
This test only requires numbers
, so it will receive an empty list:
def test_initial(numbers):
assert numbers == []
This test requires append_1
, but not append_2
:
def test_append_1(numbers, append_1):
assert numbers == [1]
This test requires append_2
, which itself pulls in append_1
:
def test_append_2(numbers, append_2):
assert numbers == [1, 2]
Example class#
It is common to use a class to organize a set of related unit tests. This is
not a full-fledged class – it simply helps to organize tests and data. In particular,
there is no constructor, __init__()
. See https://stackoverflow.com/questions/21430900/py-test-skips-test-class-if-constructor-is-defined
We’ll look at an example with a NumPy array
We’ll use xunit-style setup/teardown methods to store the array as a class member
This way we don’t have to ask for it in each of the tests
We’ll use NumPy’s own assertion functions: https://numpy.org/doc/stable/reference/routines.testing.html
Here’s an example:
# a test class is useful to hold data that we might want set up
# for every test.
import numpy as np
from numpy.testing import assert_array_equal
class TestClassExample:
@classmethod
def setup_class(cls):
""" this is run once for each class, before any tests """
pass
@classmethod
def teardown_class(cls):
""" this is run once for each class, after all tests """
pass
def setup_method(self):
""" this is run before each of the test methods """
self.a = np.arange(24).reshape(6, 4)
def teardown_method(self):
""" this is run after each of the test methods """
pass
def test_max(self):
assert self.a.max() == 23
def test_flat(self):
assert_array_equal(self.a.flat, np.arange(24))
Note
Here we see the @classmethod
decorator.
This means that the function receives the class itself as the first argument rather than an instance,
e.g., self
.
Put this into a file called test_class.py
and then we can run as:
pytest -v
Quick Exercise
Try adding a new test that modifies self.a
, above test_max()
.
Does this behave as you expect? What happens if you move the array creation
into setup_class()
instead?