PyTest
PyTest
Definition: The most widely used testing framework for Python, known for letting you write tests as plain functions with plain assert statements instead of a rigid class-based structure.
How It Works
- Discovery: pytest walks the project looking for files matching
test_*.pyor*_test.py, then collects functions namedtest_*and classes namedTest*inside them, no registration step required. - Plain
assertis enough. Pytest rewrites the test module’s bytecode at import time so a failedassert a == bprints both values and a diff, instead of justAssertionError. - Fixtures are pytest’s core mechanism: a function decorated
@pytest.fixtureprovides setup (and, viayield, teardown) that tests request simply by naming it as a parameter. - Fixture resolution is dependency injection by name: pytest inspects a test function’s parameter list, finds a matching fixture, resolves that fixture’s own fixture dependencies recursively, then builds the whole dependency graph before running anything.
- Fixture scope controls lifetime:
function(default, new instance per test),class,module, orsession(built once, shared across the whole run), set via@pytest.fixture(scope="module"). conftest.pyfiles hold fixtures shared across a directory without importing them explicitly, pytest auto-discovers them based on file location.@pytest.mark.parametrizeruns one test function once per input set, showing each case as a separate result in the report.- The plugin ecosystem covers most things not in core pytest:
pytest-covfor coverage,pytest-xdistfor parallel runs,pytest-mockfor a fixture-flavored wrapper aroundunittest.mock. - Markers (
@pytest.mark.skip,@pytest.mark.xfail, custom markers filtered with-m) let a suite selectively skip, expect-fail, or subset tests without deleting or commenting them out.
Under the Hood
Given a test that needs a database connection, built from a config fixture Step:
import pytest
@pytest.fixture(scope="session")
def db_config():
return {"host": "localhost", "port": 5432}
@pytest.fixture
def db_connection(db_config):
conn = connect(**db_config)
yield conn
conn.close()
def test_user_count(db_connection):
assert db_connection.query("SELECT COUNT(*) FROM users") >= 0
Answer: Pytest sees test_user_count needs db_connection, sees db_connection needs db_config, builds db_config once per test session (it’s expensive-ish but stateless), builds a fresh db_connection per test, runs the test, then resumes db_connection after its yield to close the connection, in reverse dependency order.
Given the same validation logic needs checking against several inputs Step:
@pytest.mark.parametrize("email,valid", [
("user@example.com", True),
("not-an-email", False),
("", False),
])
def test_email_validation(email, valid):
assert is_valid_email(email) == valid
Answer: Pytest runs test_email_validation three times, once per tuple, and reports each as its own pass/fail line (test_email_validation[user@example.com-True]), so a failure on one case doesn’t hide the other two.
Why It Matters
- Plain
assertplus automatic diffing removes the need to memorizeassertEqual/assertIn/assertTruemethod names the wayunittestrequires. - Fixtures with explicit scope give precise control over expensive setup (spinning up a test database once per session) without accidentally sharing state between tests that need isolation.
- Because tests are plain functions, pytest has no forced inheritance hierarchy, keeping test code close to ordinary Python instead of a framework-specific dialect.
- The plugin ecosystem means a team assembles exactly the testing capability they need, coverage, parallelism, mocking, without pytest itself growing bloated.
- Rich, readable assertion failures reduce time spent figuring out why a test failed, especially in large suites run unattended in CI.
Common Pitfalls
- Giving a fixture a broader scope than it needs (
sessionfor something that holds mutable state) and having one test’s side effects leak into another. - Mutating a fixture’s return value in one test and assuming the next test gets a fresh copy, when the fixture is actually session- or module-scoped and shared.
- Over-parametrizing a single test with dozens of loosely related cases, making one failure hard to isolate from the noise of the others.
- Writing to the real filesystem or a real network resource in a test instead of using the built-in
tmp_pathfixture or amonkeypatch/mock, making tests slow and order-dependent. - Catching broad exceptions inside a test (
try/except: pass) to avoid a failure, silently hiding the actual bug the test was written to catch. - Relying on test execution order (test B assumes test A already ran and left state behind) instead of making each test independently set up what it needs.
- Defining a fixture with a mutable default (a list or dict built once at import time) and returning the same object to every test, instead of constructing a fresh one inside the fixture body.
Comparison
| PyTest | unittest | Robot Framework | JUnit | |
|---|---|---|---|---|
| Ecosystem | Python | Python (standard library) | Python (keyword-driven) | Java/JVM |
| Test structure | Plain functions, plain assert | Classes extending TestCase, assertX methods | Keyword-based, often no code at all | Annotated methods (@Test) |
| Setup/teardown | Fixtures, scoped, dependency-injected | setUp/tearDown methods | Setup/teardown keywords | @BeforeEach/@AfterEach |
| Parameterization | @pytest.mark.parametrize | Manual loops or subTest | Data-driven test templates | @ParameterizedTest |
| Best fit | Most Python projects, unit to integration | Stdlib-only environments, legacy codebases | QA teams writing tests without much Python | Any Java/JVM project |
Example
# test_discount.py
import pytest
from discount import apply_discount
class TestDiscountEdgeCases:
def test_zero_price_returns_zero(self):
assert apply_discount(0, "GOLD") == 0
def test_negative_price_raises(self):
with pytest.raises(ValueError, match="must be positive"):
apply_discount(-10, "GOLD")
@pytest.mark.parametrize("tier,expected_rate", [
("GOLD", 0.10),
("SILVER", 0.05),
("NONE", 0.0),
])
def test_discount_rate_by_tier(tier, expected_rate):
assert apply_discount(100, tier) == pytest.approx(100 * (1 - expected_rate))
Mixing a class (for grouping related edge cases) with a plain parametrized function in the same file is normal in pytest, both are discovered and run identically, and pytest.approx handles floating-point comparison without a manual epsilon check.
Related Terms
- Jest — JavaScript’s closest equivalent, with
beforeEachinstead of fixtures - JUnit — the annotation-based Java equivalent, less flexible fixture model but the same underlying xUnit lineage
- Playwright —
pytest-playwrightwraps browser automation as pytest fixtures - Test Pyramid and TDD — pytest is the usual engine for the base layer of Python test suites
- CI-CD —
pytest --covruns are a standard pipeline gate before merge