How do you write and organize tests with pytest (fixtures, parametrize, marks)?

7 minintermediatetestingpytestfixtures

Quick Answer

pytest discovers tests as plain functions/methods named `test_*` (no boilerplate base class needed), uses plain `assert` statements (rewriting them to show rich failure diffs), and provides **fixtures** (`@pytest.fixture`) for reusable setup/teardown injected by parameter name, **`@pytest.mark.parametrize`** to run the same test logic against many inputs, and **marks** (`@pytest.mark.skip`, `.xfail`, custom marks) to control test execution.

Detailed Answer

Plain functions, plain assert

# test_math.py
def add(a, b):
    return a + b

def test_add_positive_numbers():
    assert add(2, 3) == 5

def test_add_negative_numbers():
    assert add(-1, -1) == -2

No self.assertEqual(...) boilerplate (as in unittest) — pytest rewrites plain assert statements at import time to produce detailed failure output (showing both sides of a failed comparison) without any special assertion methods.

Fixtures: reusable, composable setup/teardown

import pytest

@pytest.fixture
def db_connection():
    conn = create_connection()
    yield conn          # provided to the test
    conn.close()          # teardown, runs after the test (even if it failed)

def test_query(db_connection):    # requested by parameter name
    result = db_connection.execute("SELECT 1")
    assert result == 1

A fixture requested by a test function's parameter name is automatically resolved, run, and injected by pytest — yield splits it into setup (before) and teardown (after), with teardown guaranteed to run even if the test fails. Fixtures can depend on other fixtures, be scoped (scope="module", "session") to control how often they're recreated, and be shared across a whole directory via a conftest.py.

parametrize: one test, many inputs

import pytest

@pytest.mark.parametrize("a, b, expected", [
    (2, 3, 5),
    (-1, -1, -2),
    (0, 0, 0),
])
def test_add(a, b, expected):
    assert add(a, b) == expected

This runs test_add three times with three different argument sets, reported as three separate test results — far more maintainable than copy-pasting near-identical test functions for each input case, and each case's failure is reported independently.

Marks: controlling test execution

@pytest.mark.skip(reason="not implemented yet")
def test_future_feature():
    ...

@pytest.mark.skipif(sys.platform == "win32", reason="POSIX-only")
def test_unix_permissions():
    ...

@pytest.mark.xfail(reason="known bug, see #123")
def test_known_broken():
    assert broken_function() == expected

skip/skipif exclude a test from the run entirely; xfail runs the test but doesn't fail the suite if it fails as expected (and flags it if it unexpectedly passes, via strict=True) — useful for tracking known issues without either deleting the test or leaving the suite red.

Organizing a test suite

tests/
    conftest.py       # shared fixtures, available to every test in this directory tree
    test_models.py
    test_views.py
    integration/
        test_api.py

conftest.py files are auto-discovered by pytest and their fixtures are available to every test in the same directory and subdirectories, without any import — the standard way to share setup logic across a test suite.

Interview-ready summary: pytest tests are plain assert-based functions with no required base class; fixtures provide composable, scoped setup/teardown injected by parameter name; parametrize runs one test body against many input sets as separate reported cases; and marks (skip/skipif/xfail) control which tests run and how failures are interpreted.

Related Resources