CodeOath
← All posts
Testing36 min total · 12 parts

Testing Fundamentals: Unit, Integration, and E2E Tests Done Right

Part 8 of 12 · ~4 min

pytest Fundamentals (Python)

pytest is Python's dominant test framework, and its whole design philosophy is "get out of the way" — plain functions, plain assert, minimal boilerplate compared to the class-based unittest module it largely superseded.

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

Plain assert, and why that's enough

Other frameworks push you toward a library of specific assertion methods — assertEqual, assertTrue, assertIn, assertGreater — because a plain assert in most languages, on failure, tells you nothing more than "assertion failed." pytest doesn't need that library, because it rewrites the bytecode of every assert statement in a test file at collection time to capture the actual values on both sides of the comparison:

def test_user_permissions():
    user = User(role="viewer")
    assert user.can_edit()   # plain assert — no assertTrue() needed

When this fails, pytest's output shows the real values involved — assert False, and the expression that produced it — without you ever having to reach for a specific matcher method. That's assertion rewriting, and it's the direct reason pytest test code reads like ordinary Python instead of a dialect of assertion-method calls: assert a == b, assert x in y, assert not flag all just work, and all produce a genuinely useful failure message, because pytest inspects the expression itself rather than relying on you picking the "right" pre-built method for the comparison you wanted.

Fixtures

A fixture is a function that provides setup (and optional teardown) for tests that ask for it by name, as a parameter:

import pytest

@pytest.fixture
def db_connection():
    conn = create_test_db_connection()
    yield conn                 # everything before yield is setup; this value is injected into the test
    conn.close()                # everything after yield is teardown, runs even if the test fails

def test_insert_and_query(db_connection):
    db_connection.execute("INSERT INTO users (name) VALUES ('Ana')")
    result = db_connection.execute("SELECT name FROM users").fetchone()
    assert result[0] == "Ana"

pytest matches the db_connection parameter name in the test function to the fixture of the same name and calls it automatically — no explicit setup call anywhere in the test itself. The yield splits the fixture into a before-half and an after-half, and the after-half runs during teardown even if the test raises an exception, which is what makes fixtures reliable for anything that needs cleanup (closing a connection, deleting a temp file, rolling back a transaction).

Fixture scope

By default, a fixture runs fresh for every single test that requests it — but that's controllable, and getting it right matters for both correctness and speed:

@pytest.fixture(scope="function")   # default — runs once per test
def fresh_cart():
    return Cart()

@pytest.fixture(scope="module")     # runs once per test FILE, shared across all its tests
def expensive_db_connection():
    conn = connect_to_test_database()
    yield conn
    conn.close()

@pytest.fixture(scope="session")    # runs once for the ENTIRE test run, across every file
def compiled_test_assets():
    return build_frontend_assets()
ScopeRunsGood for
function (default)Once per testAnything cheap, or anything that must be isolated between tests
classOnce per test classSetup shared only by tests grouped in one class
moduleOnce per test fileA real but slow resource shared safely by every test in the file
sessionOnce per full test runGenuinely expensive, safely-shared setup (a compiled asset, a spun-up test container)

A wider scope is a speed/isolation trade, not a free win. A session-scoped fixture that returns mutable state — a database connection, a shared object — can let one test's leftover changes bleed into the next test that shares it, reintroducing the exact order-dependence problem isolated tests are supposed to avoid. Default to function scope; widen it deliberately, only for things that are genuinely expensive to set up and either immutable or safely resettable between tests.

parametrize for data-driven tests

Testing the same logic against several inputs shouldn't mean copy-pasting the test body once per input:

import pytest

@pytest.mark.parametrize("input_value,expected", [
    (0, "zero"),
    (1, "positive"),
    (-1, "negative"),
    (100, "positive"),
])
def test_classify_number(input_value, expected):
    assert classify_number(input_value) == expected

pytest runs test_classify_number once per tuple in the list, reporting each as its own separate pass/fail — test_classify_number[0-zero], test_classify_number[1-positive], and so on — rather than one test that silently only checks the last case. This is the direct Python analogue of a test.each table in Jest, and it's the right tool anytime you find yourself writing several nearly-identical test functions that only differ in their input and expected output.