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

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?