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_*.py or *_test.py, then collects functions named test_* and classes named Test* inside them, no registration step required.
  • Plain assert is enough. Pytest rewrites the test module’s bytecode at import time so a failed assert a == b prints both values and a diff, instead of just AssertionError.
  • Fixtures are pytest’s core mechanism: a function decorated @pytest.fixture provides setup (and, via yield, 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, or session (built once, shared across the whole run), set via @pytest.fixture(scope="module").
  • conftest.py files hold fixtures shared across a directory without importing them explicitly, pytest auto-discovers them based on file location.
  • @pytest.mark.parametrize runs 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-cov for coverage, pytest-xdist for parallel runs, pytest-mock for a fixture-flavored wrapper around unittest.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 assert plus automatic diffing removes the need to memorize assertEqual/assertIn/assertTrue method names the way unittest requires.
  • 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 (session for 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_path fixture or a monkeypatch/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

PyTestunittestRobot FrameworkJUnit
EcosystemPythonPython (standard library)Python (keyword-driven)Java/JVM
Test structurePlain functions, plain assertClasses extending TestCase, assertX methodsKeyword-based, often no code at allAnnotated methods (@Test)
Setup/teardownFixtures, scoped, dependency-injectedsetUp/tearDown methodsSetup/teardown keywords@BeforeEach/@AfterEach
Parameterization@pytest.mark.parametrizeManual loops or subTestData-driven test templates@ParameterizedTest
Best fitMost Python projects, unit to integrationStdlib-only environments, legacy codebasesQA teams writing tests without much PythonAny 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.

  • Jest — JavaScript’s closest equivalent, with beforeEach instead of fixtures
  • JUnit — the annotation-based Java equivalent, less flexible fixture model but the same underlying xUnit lineage
  • Playwright — pytest-playwright wraps 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 --cov runs are a standard pipeline gate before merge

Dig deeper