ይህ ትምህርት ገና ወደ አማርኛ አልተተረጎመም፤ ስለዚህ በእንግሊዝኛ ቀርቧል። የእንግሊዝኛውን ገጽ ክፈቱ
Fixtures and parametrize
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:
test_budget.py
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") == 120In the editor, press Escape then Tab to move on.
budget.py
def total_for(expenses, category):
total = 0
for e in expenses:
if e["category"] == category:
total = total + e["amount"]
return totalIn 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:
test_budget.py
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") == 120In the editor, press Escape then Tab to move on.
budget.py
def total_for(expenses, category):
total = 0
for e in expenses:
if e["category"] == category:
total = total + e["amount"]
return totalIn 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:
test_budget.py
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") == 0In the editor, press Escape then Tab to move on.
budget.py
def total_for(expenses, category):
total = 0
for e in expenses:
if e["category"] == category:
total = total + e["amount"]
return totalIn 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))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:
test_budget.py
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") == 120In the editor, press Escape then Tab to move on.
budget.py
def total_for(expenses, category):
total = 0
for e in expenses:
if e["category"] == category:
total = total + e["amount"]
return totalIn 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?
test_grades.py
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) == expectedIn the editor, press Escape then Tab to move on.
grades.py
def grade_for(score):
if score >= 85:
return "A"
elif score >= 70:
return "B"
elif score >= 50:
return "C"
else:
return "F"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:
test_contacts.py
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) == contactsIn the editor, press Escape then Tab to move on.
contacts.py
import json
def save_contacts(contacts, path):
with open(path, "w", encoding="utf-8") as f:
json.dump(contacts, f, ensure_ascii=False, indent=2)
def load_contacts(path):
with open(path, encoding="utf-8") as f:
return json.load(f)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
test_ethiopian.py
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.
ethiopian.py
def is_leap(year):
return year % 4 == 3In the editor, press Escape then Tab to move on.
መፍትሄውን አሳይHide the solution
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.parametrizeruns one test per row, and each row counts in the summary.tmp_pathgives each test its own empty folder, so file tests never touch your real files.