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
assert, and why that's enoughOther 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.
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).
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()
| Scope | Runs | Good for |
|---|---|---|
function (default) | Once per test | Anything cheap, or anything that must be isolated between tests |
class | Once per test class | Setup shared only by tests grouped in one class |
module | Once per test file | A real but slow resource shared safely by every test in the file |
session | Once per full test run | Genuinely 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 testsTesting 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.