Skip to main content

๐Ÿงช PyTest Framework Fundamentals

pytest is the testing tool most working Python teams reach for first. It lets you write a test as a plain function with a plain assert โ€” no boilerplate, no base classes โ€” yet it scales up to fixtures, parametrization, plugins, and coverage. This lesson takes you from your first three-line test to a full, well-organized suite.

๐ŸŽฏ Learning Objectives

By the end of this lesson, you will be able to:

  • Install pytest and write, discover, and run tests from the command line
  • Use plain assert plus pytest.raises and pytest.approx for readable checks
  • Create fixtures, choose the right scope, and use yield for setup/teardown
  • Eliminate duplication with @pytest.mark.parametrize and organize tests with markers
  • Measure code coverage and apply pytest best practices to a realistic codebase

Estimated Time: 45โ€“55 minutes  โ€ข  Difficulty: Intermediate

Hands-on: Build a complete, fixture-driven test suite for a small calculator library.

In This Lesson

Why pytest?

Python ships with unittest, a capable framework modeled on Java's JUnit. But it asks you to subclass TestCase, remember dozens of assertEqual/assertTrue methods, and wrap everything in classes. pytest throws that ceremony out: a test is just a function whose name starts with test_, and a check is just Python's built-in assert.

๐Ÿ’ก A useful analogy: If testing were cooking, unittest is a traditional kitchen where every dish requires the same rigid mise-en-place. pytest is a modern kitchen with ergonomic tools โ€” the simple dishes get simpler, and the complex ones stay manageable.

The payoff compounds as your project grows. pytest's fixtures make shared setup composable, parametrization turns twenty near-identical tests into one, and a large plugin ecosystem adds coverage, parallelism, and framework integrations without changing how you write tests.

mindmap root((pytest)) Simple to write Plain functions Plain assert Rich failure output Powerful features Fixtures Parametrization Markers Plugins Built for scale Auto test discovery Parallel runs Subset selection

Getting Started

Always install into a virtual environment so your test tooling stays isolated per project:

python -m venv .venv
source .venv/bin/activate        # Windows: .venv\Scripts\activate
pip install pytest

Now write your first test. By convention the code under test and the test live in separate files, but for a first taste we can keep them together:

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

def test_add():
    assert add(2, 3) == 5
    assert add(-1, 1) == 0
    assert add(0, 0) == 0

Run the whole suite by typing one word in the project root:

pytest

Typical output

==================== test session starts ====================
collected 1 item

test_sample.py .                                       [100%]

===================== 1 passed in 0.01s =====================

That single dot after the filename is one passing test. A failing test shows an F, and pytest reprints the exact assertion that failed. You can also target individual files or tests:

pytest test_sample.py                 # one file
pytest test_sample.py::test_add       # one test
pytest -k "add"                       # any test whose name contains "add"

Test Discovery & Structure

pytest discovers tests automatically using naming conventions. You never register tests in a list โ€” you just follow the rules and pytest finds them:

ItemConvention
Test filestest_*.py or *_test.py
Test functionstest_*
Test classesTest* (no __init__)
Test methodstest_*

You can group related tests in a class โ€” but unlike unittest, the class does not inherit from anything:

# test_math.py
def test_standalone():
    assert True

class TestArithmetic:
    def test_addition(self):
        assert 1 + 1 == 2

    def test_multiplication(self):
        assert 2 * 3 == 6

The everyday loop is short: write a test file, add functions, run pytest, fix whatever is red, repeat.

flowchart LR A[Write test_*.py] --> B[Add test functions] B --> C[Run pytest] C --> D{All green?} D -->|Yes| E[Next feature] D -->|No| F[Fix code or test] F --> C

๐Ÿ“– Key Terms

Test discovery: the process pytest uses to find tests by scanning for the naming conventions above.

Fixture: a reusable function that prepares (and optionally cleans up) something a test needs.

Marker: a label attached to a test (e.g. @pytest.mark.slow) used to categorize or configure it.

Assertions & Exceptions

pytest rewrites the bytecode of your assert statements so that when one fails, it shows you the actual values โ€” not just "assertion failed". This "assertion introspection" is why plain assert is enough:

import pytest

def test_basic_assertions():
    assert 1 + 1 == 2                     # equality
    assert "abc".upper() == "ABC"         # strings
    assert 5 in [1, 2, 3, 4, 5]           # membership
    assert 0.1 + 0.2 == pytest.approx(0.3)  # floats: never use == directly

โš ๏ธ Floating point never compares exactly

0.1 + 0.2 is actually 0.30000000000000004 in binary floating point. pytest.approx() compares within a small tolerance so your test reflects real-world math, not IEEE-754 rounding.

When a check fails, the report is precise:

Failure report for assert a == b

def test_example():
        a = 5
        b = 6
>       assert a == b
E       assert 5 == 6

test_example.py:4: AssertionError

To assert that code raises, wrap it in pytest.raises. You can also inspect the exception message:

import pytest

def test_exceptions():
    with pytest.raises(ZeroDivisionError):
        1 / 0

    with pytest.raises(ValueError, match="Invalid value"):
        raise ValueError("Invalid value: 42")

The match= argument is a regular expression checked against the message, so you verify both the exception type and that it explains the right problem.

Fixtures & Scopes

A fixture supplies a test with something it needs โ€” sample data, a database connection, a configured object. You declare one with the @pytest.fixture decorator, and any test that lists the fixture's name as a parameter receives its return value automatically.

import pytest

@pytest.fixture
def sample_user():
    """Provide a ready-made user dict for tests."""
    return {"name": "Test User", "email": "test@example.com", "age": 30}

def test_user_name(sample_user):
    assert sample_user["name"] == "Test User"

def test_user_email(sample_user):
    assert "example.com" in sample_user["email"]
๐Ÿ’ก Analogy: Fixtures are the prep chefs of your test kitchen. They wash and chop the ingredients (data, connections, objects) so each test โ€” the head chef โ€” can focus on the actual cooking instead of the setup.

Setup and teardown with yield

For resources that must be cleaned up, yield the value and put teardown code after it. Everything before yield runs as setup; everything after runs once the test finishes โ€” even if it failed:

@pytest.fixture
def db_connection():
    connection = create_connection()   # setup
    yield connection                   # hand the value to the test
    connection.close()                 # teardown, always runs

def test_insert(db_connection):
    db_connection.execute("INSERT INTO users VALUES ('ray')")
    assert db_connection.count("users") == 1

Scopes: how often a fixture is created

By default a fixture is rebuilt for every test function. For expensive setup (a database, a web server) you can widen the scope so it is created once and shared:

ScopeCreatedGood for
function (default)Once per testCheap, isolated data
classOnce per test classShared state within a group
moduleOnce per fileA parsed config file
sessionOnce per whole runA Docker container, a live server
flowchart TD A[session: once for the whole run] --> B[module: once per file] B --> C[class: once per class] C --> D[function: once per test]

Fixtures can also depend on other fixtures โ€” just list them as parameters. pytest resolves the graph for you:

@pytest.fixture
def user():
    return {"username": "ray", "email": "ray@example.com"}

@pytest.fixture
def authenticated_user(user):     # depends on `user`
    user["authenticated"] = True
    return user

def test_auth(authenticated_user):
    assert authenticated_user["authenticated"] is True

โœ… conftest.py shares fixtures

Put a fixture in a file named conftest.py and every test in that directory (and below) can use it without importing anything. It is pytest's built-in place for shared fixtures.

Parametrizing Tests

When the same test logic should run against many inputs, don't copy-paste and don't loop inside one test (a loop stops at the first failure and hides the rest). Use @pytest.mark.parametrize โ€” pytest generates a separate, independently-reported test per case:

import pytest

def add(a, b):
    return a + b

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

That is four tests. If the (-1, 1, 0) case breaks, pytest reports exactly that case and still runs the others.

Readable case IDs

Give each case a label with ids= so failures read clearly in the output:

@pytest.mark.parametrize(
    "value, expected",
    [(2, True), (3, True), (4, False), (16, False)],
    ids=["small_prime", "medium_prime", "composite", "larger_composite"],
)
def test_is_prime(value, expected):
    assert is_prime(value) == expected

Stacking two parametrize decorators multiplies the cases โ€” three roles times three users is nine generated tests, which is a concise way to cover a matrix.

Worked Example: User Management

Let's pull the pieces together on a small but realistic module. Here is the code under test โ€” a User class with a little behavior and validation, plus an in-memory UserRepository:

# users.py
class User:
    ALLOWED_ROLES = ("user", "editor", "admin")

    def __init__(self, username, email, role="user"):
        if not username or not email:
            raise ValueError("Username and email are required")
        self.username = username
        self.email = email
        self.role = role
        self.is_active = True

    def deactivate(self):
        self.is_active = False

    def promote(self, new_role):
        if new_role not in self.ALLOWED_ROLES:
            raise ValueError(f"Role must be one of: {', '.join(self.ALLOWED_ROLES)}")
        self.role = new_role

    def can_edit_content(self):
        return self.is_active and self.role in ("editor", "admin")


class UserRepository:
    def __init__(self):
        self._users = {}

    def add(self, user):
        if user.username in self._users:
            raise ValueError(f"User {user.username} already exists")
        self._users[user.username] = user
        return user

    def get(self, username):
        return self._users.get(username)

    def delete(self, username):
        return self._users.pop(username, None) is not None

Now the test suite. Notice how fixtures remove repetition, parametrization covers the permission matrix, and pytest.raises pins down the error cases:

# test_users.py
import pytest
from users import User, UserRepository


@pytest.fixture
def repo():
    return UserRepository()

@pytest.fixture
def sample_user():
    return User("ray", "ray@example.com")


class TestUser:
    def test_defaults(self):
        user = User("ray", "ray@example.com")
        assert user.role == "user"
        assert user.is_active is True

    def test_requires_fields(self):
        with pytest.raises(ValueError):
            User("", "ray@example.com")

    def test_promote_rejects_unknown_role(self, sample_user):
        with pytest.raises(ValueError, match="Role must be one of"):
            sample_user.promote("superuser")

    @pytest.mark.parametrize("role, active, can_edit", [
        ("user",   True,  False),
        ("editor", True,  True),
        ("admin",  True,  True),
        ("editor", False, False),   # inactive users cannot edit
    ])
    def test_can_edit_content(self, role, active, can_edit):
        user = User("ray", "ray@example.com", role)
        if not active:
            user.deactivate()
        assert user.can_edit_content() is can_edit


class TestUserRepository:
    def test_add_and_get(self, repo, sample_user):
        repo.add(sample_user)
        assert repo.get("ray") is sample_user

    def test_no_duplicates(self, repo, sample_user):
        repo.add(sample_user)
        with pytest.raises(ValueError):
            repo.add(sample_user)

    def test_delete(self, repo, sample_user):
        repo.add(sample_user)
        assert repo.delete("ray") is True
        assert repo.get("ray") is None
        assert repo.delete("ghost") is False

This is a complete, maintainable suite in well under a hundred lines. The permission test alone is four tests thanks to parametrize, and every test starts from a fresh repo because the fixture rebuilds it each time.

Coverage, Markers & Selection

Measuring coverage

The pytest-cov plugin reports which lines your tests actually execute:

pip install pytest-cov
pytest --cov=users                       # terminal summary
pytest --cov=users --cov-report=html     # browsable htmlcov/ report
pie showData title Line coverage of users.py "Covered" : 88 "Uncovered" : 12

โš ๏ธ Coverage measures execution, not quality

100% coverage means every line ran during the tests โ€” not that every line was meaningfully checked. Treat coverage as a way to find untested code, not as proof the tests are good.

Markers and selection

Markers label tests so you can run subsets. Register custom markers in pyproject.toml (or pytest.ini) to avoid warnings:

import pytest

@pytest.mark.slow
def test_full_reindex():
    ...
pytest -m slow            # only tests marked slow
pytest -m "not slow"      # skip the slow ones (fast feedback loop)
pytest -k "user and not repository"   # select by name expression

Handy command-line flags

pytest -v              # verbose: one line per test
pytest -x              # stop at the first failure
pytest --maxfail=2     # stop after two failures
pytest -q              # quiet
pytest --durations=5   # show the 5 slowest tests

Hands-on Exercise

๐Ÿ‹๏ธ Test a Calculator library

Objective: Write a complete pytest suite using a fixture, parametrization, and exception testing.

Here is the code to test:

# calculator.py
class Calculator:
    def add(self, a, b):
        return a + b

    def divide(self, a, b):
        if b == 0:
            raise ValueError("Cannot divide by zero")
        return a / b

    def square_root(self, a):
        if a < 0:
            raise ValueError("Cannot take the square root of a negative number")
        return a ** 0.5

Your tasks

  1. Write a calc fixture that returns a fresh Calculator().
  2. Parametrize a test for add with at least four (a, b, expected) cases.
  3. Use pytest.raises to assert both error paths (divide by zero, negative square root).
  4. Use pytest.approx when checking square_root(2).
๐Ÿ’ก Hint

Give the fixture the same name you list as a parameter. For the approximate check, write assert calc.square_root(2) == pytest.approx(1.41421356). Remember each pytest.raises block should contain only the one line expected to raise.

โœ… Sample solution
# test_calculator.py
import pytest
from calculator import Calculator


@pytest.fixture
def calc():
    return Calculator()


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


def test_divide(calc):
    assert calc.divide(6, 2) == 3

def test_divide_by_zero_raises(calc):
    with pytest.raises(ValueError, match="divide by zero"):
        calc.divide(1, 0)

def test_square_root(calc):
    assert calc.square_root(2) == pytest.approx(1.41421356)

def test_negative_square_root_raises(calc):
    with pytest.raises(ValueError):
        calc.square_root(-4)

Run pytest -v and you should see the four parametrized test_add cases plus the four other tests, all green.

Best Practices

โœ… Do๐Ÿšซ Avoid
Name tests for the behavior they verify (test_promote_rejects_unknown_role)Vague names like test_1 or test_it_works
Keep each test independent โ€” no shared mutable state between testsTests that must run in a specific order
Use parametrize for many similar casesA for loop inside one test (stops at first failure)
Give fixtures the narrowest scope that worksSession-scoped mutable data shared across tests
Test one behavior per test functionOne giant test asserting a dozen unrelated things
๐Ÿ’ก Tests are documentation. A well-named, focused test tells the next developer exactly how the code is meant to behave โ€” and fails loudly the moment that contract breaks.

โœ… A rich plugin ecosystem

Once you are comfortable, reach for plugins as needed: pytest-cov (coverage), pytest-mock (a thin mocker fixture over unittest.mock), pytest-xdist (run tests in parallel), pytest-django / pytest-flask (framework integration), and pytest-asyncio (async tests).

Summary & Quiz

๐ŸŽ‰ Key Takeaways

  • A pytest test is a plain test_* function checked with a plain assert โ€” pytest's introspection makes failures readable.
  • Fixtures supply reusable setup; yield adds teardown, and scope controls how often they run.
  • Parametrize turns one test into many independently-reported cases.
  • pytest.raises tests error paths; pytest.approx handles floating-point comparisons.
  • Markers, name selection (-k), and pytest-cov help you run and measure the right tests.

๐ŸŽฏ Quick Quiz

Question 1: How does pytest decide which functions are tests?

Question 2: You need setup that must be cleaned up after each test. What's the idiomatic pytest tool?

Question 3: Why prefer @pytest.mark.parametrize over a for loop inside one test?

๐Ÿ“š Further Reading

๐Ÿš€ What's Next?

You now have a solid, general-purpose Python testing foundation. Next we'll apply testing to a real web framework and use Django's purpose-built tools โ€” its TestCase classes, the test Client, and database handling โ€” in Django Testing Tools.

๐ŸŽ‰ Nice work!

Plain functions, plain asserts, powerful fixtures โ€” you can write a real pytest suite now.