๐งช 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
assertpluspytest.raisesandpytest.approxfor readable checks - Create fixtures, choose the right scope, and use
yieldfor setup/teardown - Eliminate duplication with
@pytest.mark.parametrizeand 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.
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:
| Item | Convention |
|---|---|
| Test files | test_*.py or *_test.py |
| Test functions | test_* |
| Test classes | Test* (no __init__) |
| Test methods | test_* |
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.
๐ 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:
| Scope | Created | Good for |
|---|---|---|
function (default) | Once per test | Cheap, isolated data |
class | Once per test class | Shared state within a group |
module | Once per file | A parsed config file |
session | Once per whole run | A Docker container, a live server |
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
โ ๏ธ 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
- Write a
calcfixture that returns a freshCalculator(). - Parametrize a test for
addwith at least four(a, b, expected)cases. - Use
pytest.raisesto assert both error paths (divide by zero, negative square root). - Use
pytest.approxwhen checkingsquare_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 tests | Tests that must run in a specific order |
| Use parametrize for many similar cases | A for loop inside one test (stops at first failure) |
| Give fixtures the narrowest scope that works | Session-scoped mutable data shared across tests |
| Test one behavior per test function | One 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 plainassertโ pytest's introspection makes failures readable. - Fixtures supply reusable setup;
yieldadds teardown, and scope controls how often they run. - Parametrize turns one test into many independently-reported cases.
pytest.raisestests error paths;pytest.approxhandles floating-point comparisons.- Markers, name selection (
-k), andpytest-covhelp 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.