Test framework¶
pytest is the most popular test framework. It discovers your tests, automates their execution and reports the results. The basic concept is that a test asserts that the obtained result corresponds to the expected one: either because the values are identical, sufficiently similar, or because the expected exception has been raised.
Discovery¶
In the tests folder, any python file prefixed with test_ will be discovered as a test file.
Inside, any function prefixed with test_ is a test and will be run.
Assertions¶
In addition to the usual strict equality assertion assert obtained == expected, two common cases occur:
In order to test that the expected exception is raised, a context manager exists:
1# raises.py 2 3 4def raise_ValueError() -> None: 5 raise ValueError("message")
1# test_raises.py 2 3import pytest 4 5from tools_methods.testing.raises import raise_ValueError 6 7 8def test_raise_ValueError() -> None: 9 with pytest.raises(ValueError, match="message"): 10 raise_ValueError()
Because the comparison between floating numbers (real) is tricky (
assert 0.1 + 0.2 == 0.3fails). There is apytesttool to assert the sufficiently proximity of two numbers:1import pytest 2 3def test(): 4 assert 0.1 + 0.2 == pytest.approx(0.3)
Note
If the data structures are
numpyarrays, thenumpy.allcloseis also useful.
Fixtures¶
Your tests usually require some data to work on. The specific functions which builds this input data are called fixtures.
The basic syntax is simple, decorate a function returning the input data with @pytest.fixture, then use this function name
as an input parameter in every test functions where it is used. A trivial example would be:
1# trivial.py
2
3
4def add_one(number: int) -> int:
5 return number + 1
1# test_trivial.py
2
3import pytest
4
5from trivial import add_one
6
7
8@pytest.fixture
9def my_number() -> int:
10 return 3
11
12def test_add_one(my_number: int) -> None:
13 assert add_one(my_number) == my_number + 1
A fixture can even take one or several other fixtures as input parameters.
Exercise (easy)
In the exercise about project groups,
1def inverse_to_group_affiliation_of_students(
2 student_composition_of_groups: dict[int, set[int]],
3) -> dict[int, set[int]]: # later, improvement of type hinting
4 """from a dict[group, students], tranpose to dict[student: groups]"""
5 group_affiliation_of_students: dict[int, set[int]] = {}
6 for group, students in student_composition_of_groups.items():
7 for student in students:
8 if student not in group_affiliation_of_students:
9 # later, improvement defaultdict
10 group_affiliation_of_students[student] = set()
11 group_affiliation_of_students[student].add(group)
12 return group_affiliation_of_students
1def test_inverse_to_group_affiliation_of_students() -> None:
2 student_composition_of_groups: dict[int, set[int]] = {
3 0: {10, 11},
4 1: {10, 12},
5 } # later, fixtures will be used
6 obtained = inverse_to_group_affiliation_of_students(student_composition_of_groups)
7 expected: dict[int, set[int]] = {
8 10: {0, 1},
9 11: {0},
10 12: {1},
11 }
12 assert obtained == expected
1def has_no_common_student_between_groups(
2 groups: list[int], student_composition_of_groups: dict[int, set[int]]
3) -> bool:
4 """add in a set the students from several groups,
5 if there is no student in common between any group,
6 then the size of the set is the sum of the size of every group"""
7 sum_group_sizes: int = 0
8 students_merged: set[int] = set()
9 for group in groups:
10 students: set[int] = student_composition_of_groups[group]
11 sum_group_sizes += len(students)
12 students_merged |= students
13 return len(students_merged) == sum_group_sizes
1def test_no_student_in_common_between_groups_false() -> None:
2 student_composition_of_groups: dict[int, set[int]] = {
3 0: {10, 11},
4 1: {10, 12},
5 } # later, fixtures will be used
6 groups: list[int] = [0, 1]
7
8 obtained = has_no_common_student_between_groups(
9 groups, student_composition_of_groups
10 )
11 expected = False
12 assert obtained == expected
Rewrite the tests using fixtures.
Correction
1@pytest.fixture
2def student_composition_of_groups() -> dict[int, set[int]]:
3 return {
4 0: {10, 11},
5 1: {10, 12},
6 }
1def test_inverse_to_group_affiliation_of_students(
2 student_composition_of_groups: dict[int, set[int]],
3) -> None:
4 obtained = inverse_to_group_affiliation_of_students(student_composition_of_groups)
5 expected = {
6 10: {0, 1},
7 11: {0},
8 12: {1},
9 }
10 assert obtained == expected
1@pytest.fixture
2def groups(
3 student_composition_of_groups,
4) -> list[int]: # a fixture takes another fixture as input
5 return list(student_composition_of_groups.keys())
1def test_no_student_in_common_between_groups_false(
2 student_composition_of_groups: dict[int, set[int]], groups: list[int]
3) -> None:
4 # takes two fixtures
5 obtained = has_no_common_student_between_groups(
6 groups, student_composition_of_groups
7 )
8 expected = False
9 assert obtained == expected
Some fixtures are already built-in pytest, for example:
tmp_path, a temporary path to a temporary folder in which you can safely write / read, which is cleaned up after the test.capsys, in order to catch the prints
However, you can create and use your own fixtures. For example, one or several functions of your code may require a specific data strutures, remember the student number - project group example. Even more, complex, one or several functions of your code may require an input data file (to import data, to convert data etc).
If the same fixture is used in multiple test files (typically to test different modules), it is possible to create a conftest.py at the
root of the tests folder and mutualize there those fixtures.
Parametrization¶
Sometimes the same test is to be run with different input parameters. Instead of either duplicating the test; or write your own loop inside over the cases, pytest offers this feature as parametrization.
You can either parametrize the test directly, or you can parametrize the feature. The first solution is often more readable if suitable.
1import pytest
2
3from tools_methods.testing.fixture_parametrize import add_one
4
5
6@pytest.mark.parametrize("my_number, expected", [(-1, 0), (2, 3), (4, 5)])
7def test_add_one_parametrized(
8 my_number: int, expected: int
9) -> None: # test parametrization
10 assert add_one(my_number) == expected
11
12
13@pytest.fixture(params=[-1, 2, 4])
14def other_number(request) -> int: # fixture parametrization
15 return request.param
16
17
18def test_add_one_fixture_parametrized(other_number: int) -> None:
19 assert add_one(other_number) == other_number + 1