pytest and fixtures
Unit tests are important part of the software development. In python those can be approached from different angles and using different libraries. Here I want to show everything I wish I knew before I started working with pytest library, fixtures and parametrization.
Contents
Fixtures
Fixtures are pieces of code that are used to set up a test context. Those can be some data or metadata, system states or helper methods. Same fixture can be used in multiple tests providing the same starting points, encapsulating some functionality and ensuring test repeatability.
In pytest fixtures are defined as functions that serve a particular purpose. We define them using a decorator on a regular function returning desired output.
Let’s say we want to reuse a list object in our tests – such a fixture will have following form:
1 2 3 4 5 6 |
import pytest @pytest.fixture def my_list(): """List fixture.""" return [1, 3, 3, 2, 5] |
Fixtures can be also constructed out of other fixtures:
1 2 3 4 |
@pytest.fixture def my_multiplied_list(my_list): """Fixture extending list twice.""" return my_list + my_list |
More about defining pytest fixtures can be found in official docs.
Tests with fixtures
Created fixture can be used directly in a test. Let’s define a helper function, which we will test using different scenarios. (Note that I omit error handling, not to clutter the function for the examples).
1 2 3 4 5 |
from typing import List def multiply_list(list_arg: List[int], n: int) -> List[int]: """Combining n copies of the list.""" return list_arg * n |
Now let’s test this method using our fixture, by simply providing it as a parameter to the test.
1 2 3 4 5 |
def test_multiply_list_two_lists(my_list): """Test `multiply_list` with 2 copies.""" assert multiply_list(my_list, 2) == my_list + my_list # 1 test passed |
Of course, alternatively here we could directly insert values into such a simple test. However we’re assuming that our fixture will be used in multiple tests. That is super useful in some more complex scenarios, leveraging the exact same mechanism.
Parametrization
Let’s look at a more advanced scenario, where we provide more than one nparameter. This can be done via parametrization. This option gives us the opportunity to test more than one scenario using the same test.
1 2 3 4 5 6 |
@pytest.mark.parametrize("n", [1, 5, 4, 2, 3]) def test_multiply_list_n_lists(my_list, n): """Test `multiply_list` with different number of copies.""" assert len(multiply_list(my_list, n)) == n * len(my_list) # 5 tests passed |
By parameterizing number of copies in the test, we tested efficiently 5 different use cases.
We can enhance this test further by also directly providing expected values for each input value.
1 2 3 4 5 6 7 8 9 |
@pytest.mark.parametrize( "n, expected_length", [ (1, 5), (5, 25), (4, 20), (2, 10), (3, 15) ]) def test_multiply_list_expected_length(my_list, n, expected_length): """Test `multiply_list` with number of copies and expected length.""" assert len(multiply_list(my_list, n)) == expected_length # 5 tests passed |
Again we run 5 tests, this time providing 2 values for each case – number of copies and expected length of output list.
But why limit yourself to just one list. We can take it even further, by testing several lists instead of one. In a simple manner, we can pass them manually as yet another parameter.
1 2 3 4 5 6 7 8 9 10 11 12 |
@pytest.mark.parametrize( "list_param, n, expected_length", [ ([1, 2, 4], 1, 3), ([3, 6, 2, 6, 5], 3, 15), ([2, 6, 3, 4], 2, 8) ]) def test_multiply_list_several_lists(list_param, n, expected_length): """Test `multiply_list` with different lists, number of copies and expected length.""" assert len(multiply_list(list_param, n)) == expected_length # 3 tests passed |
Note the change of the passed list parameter name, now it is defined within the parametrize.
However the above option does not consider our fixture! And we would like to pass different lists as a fixture, instead of just one.
Fixture parametrization
We can define a fixture consisting of several lists:
1 2 3 4 5 6 7 8 9 10 |
@pytest.fixture( params=[ [1, 3, 6, 2], [4, 2, 8, 7], [1, 2, 3, 4] ] ) def different_lists(request): """Fixture with different lists.""" return request.param |
Note the use of requestargument. The request is actually a special fixture providing information of the requesting test function. By calling paramon the request we can access such parameter value. Fixtures by design do not accept non-fixture arguments, therefore we need to use this special way.
If instead of request we provided x, we would get error: fixture 'x' not found.
Now when we pass this fixture to the test, we get 15 combinations!
1 2 3 4 5 6 |
@pytest.mark.parametrize("n", [1, 5, 4, 2, 3]) def test_multiply_list_list_fix(different_lists, n): """Test `multiply_list` with different lists and number of copies.""" assert len(multiply_list(different_lists, n)) == n * len(different_lists) # 15 tests passed |
Interestingly, if we would like to pass to a test simultaneously two lists instead of one, using the same fixture, we could do it in 2 ways, achieving 2 different scenarios.
Let’s consider a function accepting two lists:
1 2 3 4 5 |
import numpy as np def add_two_lists(one_list: List[int], two_list: List[int]) -> List[int]: """Add two lists element wise.""" return np.add(one_list, two_list) |
Now for test we can create a copy of the fixture to be used as a second list:
1 2 3 4 |
@pytest.fixture def different_lists_2(different_lists): """Fixture with copied different lists.""" return different_lists |
This way we get 3 tests, each with the exact same two lists passed.
1 2 3 4 5 |
def test_add_two_lists(different_lists, different_lists_2): """Test `add_two_lists` using two fixtures.""" assert len(add_two_lists(different_lists, different_lists_2)) == len(different_lists) # 3 tests passed |
What if instead we would like to have a cross product of those lists tested? We can achieve that by creating a helper variable instead of a fixture.
1 2 3 4 5 6 7 |
different_lists_3 = different_lists def test_add_two_lists(different_lists, different_lists_3): """Test `add_two_lists` using fixture and alias.""" assert len(add_two_lists(different_lists, different_lists_3)) == len(different_lists) # 9 tests passed |
Now we have 9 tests checked.
Generic fixture
Lastly, let’s consider a parametrized fixture using which we could generate a list of specified length for the particular test.
Again, we need to use the requestfixture:
1 2 3 4 |
@pytest.fixture def my_gen_list(request): """Fixture providing a list of specified length.""" return list(range(1, request.param + 1)) |
Now it is tempting to pass the desired length as a parameter:
1 2 3 4 5 |
@pytest.mark.parametrize("n", [1, 5, 4, 2, 3]) @pytest.mark.parametrize("list_length", [4, 2, 3]) def test_multiply_list_parametrized_wrong(my_gen_list, n, list_length): """Improper test for `multiply_list`.""" assert len(multiply_list(my_gen_list(list_length), n)) == n * list_length |
However that leads to an error:
AttributeError: 'SubRequest' object has no attribute 'param'.
In order to make it work we need to specify within the test that parameter is to be passed into that fixture. This can be achieved by providing it in the parametrizeand specifying that it should be used indirectly. This way first provided parameter goes into the fixture definition.
1 2 3 4 5 6 7 8 9 10 11 |
@pytest.mark.parametrize("n", [1, 5, 4, 2, 3]) @pytest.mark.parametrize( "my_gen_list, list_length", [ (4, 4), (2, 2), (3, 3) ], indirect=["my_gen_list"] ) def test_multiply_list_parametrized(my_gen_list, list_length, n): """Test `multiply_list` with parametrization.""" assert len(multiply_list(my_gen_list, n)) == n * list_length # 15 tests passed |
Without the indirectkeyword, my_gen_list would be treated as a integer variable and not as a fixture.