Testing with pytest — fixtures, parametrize, and plain assert
The Testing & QA domain covers what a good test looks like, conceptually, regardless of language. This lesson is the other half — the actual pytest mechanics: plain assert instead of assertEqual, fixtures for setup/teardown, and parametrize for running one test body against many inputs.
4 min read
assert, not self.assertEqual(...)
def add(a, b):
return a + b
def test_add():
assert add(2, 3) == 5
assert add(-1, 1) == 0Python's built-in unittest framework requires a class-based test with methods like self.assertEqual(a, b), self.assertTrue(x), and dozens of other specifically-named assertion methods. pytest allows plain functions with plain assert statements instead — no class, no special methods to remember. The reason this doesn't just produce a useless "AssertionError" on failure is pytest's assertion rewriting: it inspects the actual assert statement at import time and rewrites it to produce a detailed failure message showing the real values on both sides (e.g. assert 4 == 5 on failure shows exactly what each side evaluated to), something a plain Python assert alone would never do.
Fixtures: reusable, composable setup (and automatic teardown)
import pytest
@pytest.fixture
def db_connection():
conn = connect_to_test_db() # SETUP — runs before the test
yield conn # the connection is handed to the test
conn.close() # TEARDOWN — runs after the test, even if it failed
def test_insert_user(db_connection):
db_connection.execute("INSERT INTO users VALUES ('ada')")
assert db_connection.query("SELECT * FROM users").count() == 1A fixture is a function decorated with @pytest.fixture that a test requests simply by naming it as a parameter — pytest sees the parameter name db_connection, finds the matching fixture function, runs it, and passes its yielded value into the test. The code before yield is setup; the code after yield is teardown, and pytest guarantees the teardown runs even if the test itself raises an exception, which manual setup/teardown in a class-based test (setUp/tearDown) doesn't guarantee as cleanly. Fixtures can also depend on other fixtures by naming them as their own parameters, letting setup logic compose instead of being copy-pasted across tests.
Fixture scope: how often setup actually runs
@pytest.fixture(scope="function") # default — fresh instance for EVERY test
def temp_file(): ...
@pytest.fixture(scope="module") # created once, shared across all tests in this FILE
def api_client(): ...
@pytest.fixture(scope="session") # created once, shared across the ENTIRE test run
def docker_container(): ...By default, a fixture reruns its setup for every single test that requests it — the safest default, since it guarantees no state leaks between tests. scope="module" or scope="session" trade some of that isolation for speed, reusing the same setup across many tests — appropriate for something genuinely expensive to create (starting a Docker container, spinning up a real database) and safe to share, but risky for anything a test might mutate, since a change made by one test would then leak into the next.
parametrize: one test body, many inputs
@pytest.mark.parametrize("input_value, expected", [
(0, "zero"),
(1, "one"),
(-1, "negative"),
])
def test_classify(input_value, expected):
assert classify(input_value) == expectedWithout parametrize, testing the same function against several inputs means either one test function per case (a lot of near-duplicate code) or a single test with a manual loop over cases (which stops at the first failure and reports it as one generic failure, hiding the others). @pytest.mark.parametrize runs the same test body once per row of data, reported as separate, independently-passing-or-failing test results — one failing input doesn't hide or block the others, and pytest's output names each run by its actual input values, making a failure immediately traceable to the specific case that broke.
conftest.py: fixtures shared across multiple test files
# tests/conftest.py — no import needed; pytest discovers it automatically
import pytest
@pytest.fixture
def sample_user():
return {"id": 1, "name": "Ada"}A fixture defined directly in a test file is only visible to tests in that file. Placing it in a file named exactly conftest.py instead makes it automatically available to every test in that directory and its subdirectories, with no import statement required — pytest discovers conftest.py files by name and wires their fixtures in implicitly. This is where fixtures genuinely shared across a whole test suite belong (a database connection, a configured API client, common sample data), rather than being copy-pasted into every file that needs them.
Further reading
- pytest docs — how to write and report assertions
- pytest docs — fixtures
- pytest docs — parametrizing tests
- What actually makes a test good — independent of pytest's specific API — is covered in the Testing & QA domain, starting with Why We Test.
Check your understanding
A quick comprehension check — not tracked, not graded, just for you.
1. Why can pytest give a detailed failure message from a plain `assert add(2, 3) == 6` statement, with no special assertion method?
2. In a pytest fixture using `yield`, when does the code AFTER yield run?
3. What does `@pytest.mark.parametrize` do that a manual for-loop inside one test does NOT?
4. What's special about a fixture defined in a file named exactly `conftest.py`?