pytest
Advanced · 18 min read · ▶ live playground · ✦ checkpoint
The surprise with pytest is how little testing code it asks you to write. You keep normal Python functions, normal assert statements, and small named helpers, while the tool handles discovery, reports, shared setup, repeated cases, labels, and project defaults.
Why teams like pytest
pytest is a third-party Python testing tool, installed from PyPI in real projects, that runs tests with less ceremony than unittest. You still test behavior. You still protect edge cases. The difference is the shape of the test file.
In unittest, you usually write a TestCase class and call assertion methods through self. In pytest, a test can be a plain function whose name starts with test_.
def badge_for_score(score):
if score >= 90:
return "gold"
return "keep practicing"
def test_gold_badge_starts_at_90():
assert badge_for_score(90) == "gold"
def test_lower_scores_keep_practicing():
assert badge_for_score(72) == "keep practicing"Once pytest is installed in that project environment, the usual command is:
$ python -m pytestA small passing run might look like this:
$ python -m pytest
============================= test session starts =============================
collected 2 items
test_badges.py .. [100%]
============================== 2 passed in 0.03s ==============================That .. is not decoration. Each dot means one passing test. If a test fails, pytest prints the failed expression and the values involved, which is why teams like plain assert here.
Plain assert, better reports
In regular Python, assert actual == expected raises AssertionError when the claim is false. Pytest keeps that syntax but rewrites the failure report so you can see more context.
Here is a wrong test:
def badge_for_score(score):
if score >= 90:
return "gold"
return "keep practicing"
def test_badge_boundary():
assert badge_for_score(89) == "gold"Pytest points at the exact claim and shows both sides:
$ python -m pytest test_scores.py
test_scores.py F [100%]
================================== FAILURES ===================================
____________________________ test_badge_boundary _____________________________
def test_badge_boundary():
> assert badge_for_score(89) == "gold"
E AssertionError: assert 'keep practicing' == 'gold'
E
E - gold
E + keep practicing
test_scores.py:8: AssertionError
=========================== short test summary info ===========================
FAILED test_scores.py::test_badge_boundary - AssertionError: assert 'keep pra...
============================== 1 failed in 0.02s ===============================That report is the main trade: you write normal Python claims, and pytest gives you a useful failure story. You are not switching away from the ideas in Testing Fundamentals. You are giving those ideas a faster runner.
Fixtures
A fixture is a named setup function pytest can run before a test and hand into that test by name. It fills the same role as setUp() from unittest, but it is more flexible because each test asks for the setup it needs.
Think of a fixture as a named prep station. If a test asks for starter_cart, pytest goes to that station, prepares the cart, and passes it in.
import pytest
def cart_total(cart):
return sum(item["price"] for item in cart)
@pytest.fixture
def starter_cart():
return [
{"item": "apples", "price": 3},
{"item": "bread", "price": 4},
]
def test_starter_cart_total(starter_cart):
assert cart_total(starter_cart) == 7
def test_adding_tea_changes_total(starter_cart):
starter_cart.append({"item": "tea", "price": 5})
assert cart_total(starter_cart) == 12The name matters. Pytest sees the starter_cart parameter in the test function, looks for a fixture with that name, calls it, and passes the returned value into the test.
This keeps setup local and readable. A test that does not need a cart does not mention starter_cart at all.
Parametrized tests
A parametrized test is one test function run several times with different input and expected-output rows. It is a clean way to protect several boundary cases without copying the whole test body.
Parametrization is a small score table clipped to one test. Each row changes the ingredients; the claim stays the same.
import pytest
def badge_for_score(score):
if score >= 90:
return "gold"
return "keep practicing"
@pytest.mark.parametrize(
"score, expected",
[
(91, "gold"),
(90, "gold"),
(89, "keep practicing"),
(40, "keep practicing"),
],
)
def test_badge_for_scores(score, expected):
assert badge_for_score(score) == expectedWithout parametrization, you would write four separate tests or one loop with a weaker failure location. With parametrization, pytest reports which row failed.
The string "score, expected" names the parameters. Each tuple supplies one row of values. When the test runs, score and expected behave like regular function parameters.
Markers
A marker is a label you attach to a test so pytest can select it, skip it, or treat it specially. Markers are colored tabs on the suite's checklist: they do not change what the test means, but they help you choose which checks to run.
import pytest
def checkout_message(player, total):
return f"{player}, your total is ${total:.2f}"
@pytest.mark.slow
def test_full_checkout_message():
assert checkout_message("Mina", 90) == "Mina, your total is $90.00"Then you can run only slow tests:
$ python -m pytest -m slowOr run everything except slow tests:
$ python -m pytest -m "not slow"Teams also use built-in markers such as skip, which means "do not run this test right now," and xfail, which means "this test is expected to fail for a known reason." Use those carefully. A skipped test is not protection.
Configuration
Configuration is a project settings file that tells a tool its default choices. For pytest, that usually means where tests live, which markers are allowed, and which command-line options the project wants every time.
A common config file is pytest.ini:
[pytest]
testpaths = tests
addopts = -q
markers =
slow: tests that take longer than the regular suite
checkout: tests for the checkout flowtestpaths = tests tells pytest to look in the tests folder by default. addopts = -q means "quiet output," so the normal report is shorter. The markers section documents the project's custom markers and helps catch misspelled marker names.
The config file is the team's written agreement. You should not need to remember the exact discovery folder or marker spellings; the project records them.
Checkpoint
Answer all three to mark this lesson complete
Pytest does not change what good testing means. It gives you a cleaner daily workflow, so the next lesson can go deeper: replacing hard-to-reach dependencies, measuring coverage, and using tests to drive design instead of only checking it afterward.