Skip to content

ይህ ትምህርት ገና ወደ አማርኛ አልተተረጎመም፤ ስለዚህ በእንግሊዝኛ ቀርቧል። የእንግሊዝኛውን ገጽ ክፈቱ

Fixtures and parametrize

5 min read

Real test suites repeat themselves: the same sample data at the top of every test, the same assertion with different numbers. By the end of this lesson you'll write the setup once and share it, run one test function on many rows of inputs, and test code that saves files without touching your own.

Setup you write once

budget.py adds up one category's amounts. Both tests start by building the same list:

from budget import total_for


def test_food():
    expenses = [
        {"amount": 250, "category": "food"},
        {"amount": 120, "category": "transport"},
        {"amount": 60, "category": "food"},
    ]
    assert total_for(expenses, "food") == 310


def test_transport():
    expenses = [
        {"amount": 250, "category": "food"},
        {"amount": 120, "category": "transport"},
        {"amount": 60, "category": "food"},
    ]
    assert total_for(expenses, "transport") == 120

In the editor, press Escape then Tab to move on.

Ten tests would mean ten copies, and one change to the sample means ten edits. A is a function that makes the sample. You mark it with @pytest.fixture, and any test that has a parameter with the fixture's name gets what it returns:

import pytest
from budget import total_for


@pytest.fixture
def expenses():
    return [
        {"amount": 250, "category": "food"},
        {"amount": 120, "category": "transport"},
        {"amount": 60, "category": "food"},
    ]


def test_food(expenses):
    assert total_for(expenses, "food") == 310


def test_transport(expenses):
    assert total_for(expenses, "transport") == 120

In the editor, press Escape then Tab to move on.

A line starting with @ just above a def is a : it changes how the function below it is used. @pytest.fixture tells pytest "this isn't a test; it makes something tests ask for". Module 12 shows how decorators work.

The parameter is how a test asks. Leave it out, and expenses inside the test is the fixture function itself, not its list:

import pytest
from budget import total_for


@pytest.fixture
def expenses():
    return [
        {"amount": 250, "category": "food"},
        {"amount": 60, "category": "food"},
    ]


def test_food():
    assert total_for(expenses, "food") == 310


def test_none(expenses):
    assert total_for(expenses, "rent") == 0

In the editor, press Escape then Tab to move on.

test_food fails with TypeError: 'FixtureFunctionDefinition' object is not iterable: the loop in total_for got a function, not a list. Add expenses to its brackets and both pass.

A fresh value for every test

What if one test changes the list? pytest calls the fixture function again for each test that asks for it. Predict what two calls give:

ውጤቱን ገምቱ

Decide before you look. Guessing wrong is how this sticks.

def expenses():
    return [250, 120, 60]


first = expenses()
first.append(40)
second = expenses()
print(len(first), len(second))
Pick the output

Python prints

4 3

Each call runs the return line again, and that builds a new list. first and second are two different lists, so appending to one leaves the other alone.

If you picked 4 4, you pictured lesson 4.8's two names on one list. That happens with b = a, but not here: every call makes a new list. So whatever one test does to its expenses, the next test gets a fresh one, because the list is built inside the fixture. A fixture that returns a list made outside it, like return EXPENSES, hands every test that same list.

A list made at the top of the test file is different. It's made once, when the file is imported, and every test's EXPENSES refers to that one list:

from budget import total_for

EXPENSES = [
    {"amount": 250, "category": "food"},
    {"amount": 120, "category": "transport"},
]


def test_add():
    EXPENSES.append({"amount": 40, "category": "transport"})
    assert total_for(EXPENSES, "transport") == 160


def test_transport():
    assert total_for(EXPENSES, "transport") == 120

In the editor, press Escape then Tab to move on.

test_transport is right on its own; it fails with assert 160 == 120 because test_add ran first and left its expense in the list. Move test_transport above test_add and both pass. A test whose result depends on the order is a test you can't trust: use a fixture.

Many cases, one test

Lesson 5.8 checked grade_for at eight boundaries with a loop. does that in pytest: @pytest.mark.parametrize takes the parameter names, as one string with commas, and a list of rows, one tuple per test. pytest runs the test once per row. Before you run it: how many tests will the summary count?

import pytest
from grades import grade_for


@pytest.mark.parametrize("score, expected", [
    (0, "F"),
    (49, "F"),
    (50, "C"),
    (69, "C"),
    (70, "B"),
    (84, "B"),
    (85, "A"),
    (100, "A"),
])
def test_grade(score, expected):
    assert grade_for(score) == expected

In the editor, press Escape then Tab to move on.

Eight rows, eight tests. Now change >= 85 to > 85 in the grades.py tab and run again. The loop in 5.8 printed its own message; here pytest names the failing row with its values: FAILED test_grades.py::test_grade[85-A], and 1 failed, 7 passed.

ጥያቄ

A test file has one test with @pytest.mark.parametrize and 6 rows, and 2 plain tests. What's the count when they all pass?

A test can ask for a fixture and take parametrize's names too: def test_total(expenses, category, expected): gets a fresh list and one row's values, once per row.

A folder for each test

Code that saves files is tricky to test: a test that writes contacts.json could overwrite your real contacts. tmp_path is a fixture that comes with pytest. Name it as a parameter and you get a Path (lesson 8.2) to a new, empty folder, made for that one test. contacts.py is lesson 8.4's saving and loading, as two functions that take a path:

from contacts import load_contacts, save_contacts


def test_round_trip(tmp_path):
    path = tmp_path / "contacts.json"
    contacts = {"ሰላም": "0900 000 003", "Abel": "0900 000 001"}
    save_contacts(contacts, path)
    assert load_contacts(path) == contacts

In the editor, press Escape then Tab to move on.

A round trip saves something and loads it back, and the result should equal what you saved. The file went into the test's own folder, outside the working folder, so nothing appears in Files: your files are never touched. Each test that names tmp_path gets a different folder. On a computer, pytest makes them inside your system's temporary folder (on Linux, something like /tmp/pytest-of-abel/pytest-3/test_round_trip0) and deletes old ones itself, keeping the last three runs.

መልመጃ

Parametrize is_leap

ethiopian.py has is_leap from lesson 5.8: a year is a leap year when year % 4 == 3. Replace the two tests with one parametrized test over 2015, 2016, 2017, 2018, 2019, and 2023, each with True or False.

Your code

from ethiopian import is_leap


def test_2019():
    assert is_leap(2019)


def test_2020():
    assert not is_leap(2020)

In the editor, press Escape then Tab to move on.

መፍትሄውን አሳይ

This is one way to solve it, not the only one. If yours prints the same thing, it works.

import pytest
from ethiopian import is_leap


@pytest.mark.parametrize("year, expected", [
    (2015, True),
    (2016, False),
    (2017, False),
    (2018, False),
    (2019, True),
    (2023, True),
])
def test_is_leap(year, expected):
    assert is_leap(year) == expected

ዋና ዋና ነጥቦች

  • A fixture (@pytest.fixture) is setup you write once. pytest calls it again for each test that names it as a parameter, so a list built inside it is fresh every time.
  • A list at the top of a test file is shared by every test, so tests start to depend on their order. Don't.
  • @pytest.mark.parametrize runs one test per row, and each row counts in the summary.
  • tmp_path gives each test its own empty folder, so file tests never touch your real files.