Type something to search...
Unit Testing in Python with pytest: A Complete Beginner's Guide

Unit Testing in Python with pytest: A Complete Beginner's Guide

If you've ever changed one function and broken something three files away, you already know why tests exist. The hard part is usually getting started: picking a framework, figuring out where test files go, and learning enough of the tool to be productive. For Python, the framework question has a clear answer. pytest is the de facto standard, and it's popular for good reason: tests are plain functions, checks are plain assert statements, and when something fails, the output tells you exactly what went wrong.

This guide takes you from an empty project to a test suite you'd be comfortable running in CI. You'll write your first tests, learn to read pytest's failure reports, test exceptions and floating-point results, use a few built-in fixtures, select and skip tests with markers, and configure pytest in pyproject.toml. The examples use pytest 9 on Python 3.13.

What a Unit Test Is

A unit test checks one small piece of behavior in isolation: a function returns the right value for a given input, a method raises the right error for bad input, a class keeps its data consistent. Unit tests are fast (milliseconds each), so you can run hundreds of them every time you save a file.

A good unit test follows a simple shape, often called Arrange, Act, Assert:

  1. Arrange: set up the objects and inputs.
  2. Act: call the code you're testing.
  3. Assert: check the result.

Keep that structure in mind; every example below follows it.

Setting Up

Install pytest into your project's virtual environment:

python -m pip install pytest

We'll test a small shopping cart module. The project uses the src layout, with tests in a separate tests/ directory:

shop/
├── pyproject.toml
├── src/
│   └── shop/
│       ├── __init__.py
│       └── cart.py
└── tests/
    └── test_cart.py

Here's the code under test:

# src/shop/cart.py
from dataclasses import dataclass, field


@dataclass
class Item:
    name: str
    price: float
    quantity: int = 1


@dataclass
class Cart:
    items: list[Item] = field(default_factory=list)

    def add(self, name: str, price: float, quantity: int = 1) -> None:
        if price < 0:
            raise ValueError(f"price must be non-negative, got {price}")
        if quantity < 1:
            raise ValueError(f"quantity must be at least 1, got {quantity}")
        self.items.append(Item(name, price, quantity))

    def total(self) -> float:
        return sum(item.price * item.quantity for item in self.items)

    def apply_discount(self, percent: float) -> float:
        if not 0 <= percent <= 100:
            raise ValueError("percent must be between 0 and 100")
        return round(self.total() * (1 - percent / 100), 2)


def save_receipt(cart: Cart, path) -> None:
    lines = [
        f"{item.name} x{item.quantity}: {item.price * item.quantity:.2f}"
        for item in cart.items
    ]
    lines.append(f"TOTAL: {cart.total():.2f}")
    path.write_text("\n".join(lines) + "\n")
    print(f"Saved receipt with {len(cart.items)} items")

So that import shop works from the tests without installing the package, tell pytest where the source lives. Add this to pyproject.toml:

# pyproject.toml
[tool.pytest]
testpaths = ["tests"]
pythonpath = ["src"]

testpaths tells pytest where to look when you run it with no arguments, and pythonpath adds src to the import path during the test run. (The alternative is pip install -e ., which installs your package in editable mode; that works too and is common in larger projects.) We'll come back to configuration later.

Writing Your First Tests

A test is any function whose name starts with test_, in a file whose name starts with test_ (or ends with _test.py). No classes to inherit from, no special assertion methods:

# tests/test_cart.py
from shop.cart import Cart


def test_new_cart_is_empty():
    cart = Cart()
    assert cart.items == []
    assert cart.total() == 0


def test_total_multiplies_price_by_quantity():
    cart = Cart()
    cart.add("pen", 1.50, quantity=4)
    cart.add("notebook", 3.25)
    assert cart.total() == 9.25


def test_discount_is_rounded_to_cents():
    cart = Cart()
    cart.add("lamp", 19.99)
    assert cart.apply_discount(15) == 16.99

Run pytest from the project root:

pytest
============================= test session starts ==============================
platform darwin -- Python 3.13.2, pytest-9.1.1, pluggy-1.6.0
rootdir: /Users/maria/code/shop
configfile: pyproject.toml
testpaths: tests
collected 3 items

tests/test_cart.py ...                                                   [100%]

============================== 3 passed in 0.41s ===============================

Each dot is a passing test. pytest found the file, collected the three functions, and ran them. That's the whole workflow.

Name Tests After Behavior

Notice the test names: test_total_multiplies_price_by_quantity, not test_total_2. When a test fails six months from now, its name is the first thing you'll read. A name that describes the expected behavior tells you what broke before you open the file.

Reading Failures

pytest's best feature is what it shows you when a test fails. Here are two tests with problems:

# tests/test_failures.py
from shop.cart import Cart


def test_total_with_floats():
    cart = Cart()
    cart.add("coffee", 0.10, quantity=3)
    assert cart.total() == 0.30


def test_item_names():
    cart = Cart()
    cart.add("pen", 1.50)
    cart.add("ink", 4.00)
    assert [item.name for item in cart.items] == ["pen", "paper"]
=================================== FAILURES ===================================
____________________________ test_total_with_floats ____________________________

    def test_total_with_floats():
        cart = Cart()
        cart.add("coffee", 0.10, quantity=3)
>       assert cart.total() == 0.30
E       AssertionError: assert 0.30000000000000004 == 0.3
E        +  where 0.30000000000000004 = total()
E        +    where total = Cart(items=[Item(name='coffee', price=0.1, quantity=3)]).total

tests/test_failures.py:7: AssertionError
_______________________________ test_item_names ________________________________

    def test_item_names():
        cart = Cart()
        cart.add("pen", 1.50)
        cart.add("ink", 4.00)
>       assert [item.name for item in cart.items] == ["pen", "paper"]
E       AssertionError: assert ['pen', 'ink'] == ['pen', 'paper']
E
E         At index 1 diff: 'ink' != 'paper'
E         Use -v to get more diff

tests/test_failures.py:14: AssertionError
=========================== short test summary info ============================
FAILED tests/test_failures.py::test_total_with_floats - AssertionError: assert 0....
FAILED tests/test_failures.py::test_item_names - AssertionError: assert ['pen', '...
============================== 2 failed in 0.10s ===============================

A plain assert normally just raises AssertionError with no details. pytest rewrites the assert statements in your test files when it imports them, so on failure it can show the actual values of every part of the expression. The > marks the failing line, and the E lines show:

  • The first failure isn't a bug in Cart at all. 0.1 * 3 is 0.30000000000000004 in binary floating point. The fix is in the test, as you'll see in a moment.
  • The second failure points straight at the mismatched element: index 1 is 'ink', not 'paper'.

Get in the habit of reading the E lines carefully before touching any code. They usually tell you whether the bug is in the code or the test.

Comparing Floats with pytest.approx

Never compare computed floats with ==. Use pytest.approx, which allows a small relative tolerance (one part in a million by default):

import pytest

from shop.cart import Cart


def test_total_with_floats():
    cart = Cart()
    cart.add("coffee", 0.10, quantity=3)
    assert cart.total() == pytest.approx(0.30)

approx also works on lists, tuples, and dict values, so assert result == pytest.approx([0.1, 0.2]) compares element by element. For money in real applications, the better long-term fix is decimal.Decimal or integer cents, but approx is right for anything that's genuinely a float.

Testing Exceptions with pytest.raises

Error handling is behavior too, and it deserves tests. pytest.raises is a context manager that passes only if the block raises the expected exception:

def test_negative_price_is_rejected():
    cart = Cart()
    with pytest.raises(ValueError, match="non-negative"):
        cart.add("refund", -5.00)

The match argument is a regular expression searched against the exception message, so you can confirm you got the right ValueError and not some unrelated one. If you need to inspect the exception in more detail, capture it:

def test_discount_out_of_range():
    cart = Cart()
    with pytest.raises(ValueError) as exc_info:
        cart.apply_discount(150)
    assert "between 0 and 100" in str(exc_info.value)

exc_info.value is the exception instance. Keep the with block as small as possible: only the call that should raise belongs inside it. If an earlier setup line raised the same exception type by accident, a too-large block would make the test pass for the wrong reason.

Built-in Fixtures: tmp_path and capsys

Fixtures are pytest's mechanism for providing tests with the things they need. You ask for a fixture by adding a parameter with its name, and pytest supplies the value. pytest ships with several useful ones. Two you'll reach for early:

  • tmp_path gives you a fresh temporary directory as a pathlib.Path, unique to this test.
  • capsys captures anything written to stdout and stderr.
from shop.cart import Cart, save_receipt


def test_save_receipt(tmp_path, capsys):
    cart = Cart()
    cart.add("pen", 1.50, quantity=2)
    receipt = tmp_path / "receipt.txt"

    save_receipt(cart, receipt)

    assert receipt.read_text() == "pen x2: 3.00\nTOTAL: 3.00\n"
    assert capsys.readouterr().out == "Saved receipt with 1 items\n"

The test never touches your real filesystem, and it checks both the file contents and the printed message. Other handy built-ins include monkeypatch (temporarily change environment variables, attributes, or dictionary entries), caplog (capture log records), and tmp_path_factory. Run pytest --fixtures to see them all.

You can also write your own fixtures to share setup across tests, such as a pre-filled cart. That topic, along with running one test against many inputs, gets its own post: pytest fixtures and parametrization.

Running Just the Tests You Want

On a growing suite you rarely want to run everything while you work. These options cover most situations:

CommandWhat it does
pytest -vVerbose: one line per test with its result
pytest -qQuiet: minimal output
pytest tests/test_cart.pyRun one file
pytest tests/test_cart.py::test_new_cart_is_emptyRun one test
pytest -k discountRun tests whose name matches an expression
pytest -xStop at the first failure
pytest --lfRe-run only the tests that failed last time
pytest --ffRun last failures first, then the rest
pytest -sDon't capture output, so print calls show up live
pytest --pdbDrop into the debugger on failure

-k accepts boolean expressions like -k "discount and not range". Here it is selecting every test with "discount" in its name:

pytest -k discount -v
tests/test_cart.py::test_discount_is_rounded_to_cents PASSED             [ 33%]
tests/test_cart_more.py::test_discount_out_of_range PASSED               [ 66%]
tests/test_cart_more.py::test_bulk_discount XFAIL (bulk discounts no...) [100%]

================== 2 passed, 7 deselected, 1 xfailed in 0.10s ==================

The -x and --lf pair is a fast debugging loop: run with -x to stop on the first failure, fix it, then --lf to confirm, and finally run everything. When a test fails in a way you don't understand, --pdb opens the debugger right at the failing line; see how to debug a Python program if pdb is new to you.

Markers: Skip, Expected Failures, and Custom Labels

Markers attach metadata to tests. Three built-in ones cover most needs:

import sys

import pytest

from shop.cart import Cart


@pytest.mark.skipif(sys.platform == "win32", reason="POSIX paths only")
def test_posix_only():
    assert "/" in __file__


@pytest.mark.xfail(reason="bulk discounts not implemented yet")
def test_bulk_discount():
    cart = Cart()
    cart.add("pen", 1.00, quantity=100)
    assert cart.total() == 90.00


@pytest.mark.slow
def test_many_items():
    cart = Cart()
    for i in range(10_000):
        cart.add(f"item-{i}", 1.00)
    assert cart.total() == 10_000
  • skipif skips a test when a condition is true, typically a platform or Python version check. Plain @pytest.mark.skip(reason=...) skips unconditionally.
  • xfail marks a test that's expected to fail, such as a known bug or an unfinished feature. It's reported as XFAIL instead of a failure, so the suite stays green while the test documents the gap.
  • Custom markers like slow are labels you define. Run everything except slow tests with -m:
pytest -m "not slow" -q
........x                                                                [100%]
=========================== short test summary info ============================
XFAIL tests/test_cart_more.py::test_bulk_discount - bulk discounts not implemented yet
8 passed, 1 deselected, 1 xfailed in 0.09s

The lowercase x in the progress line is the expected failure. Register custom markers in your configuration (shown next) so a typo like @pytest.mark.slwo is caught instead of silently creating a new marker.

Configuring pytest

pytest reads its settings from pyproject.toml, pytest.toml, pytest.ini, tox.ini, or setup.cfg. In a modern project, pyproject.toml is the natural home:

# pyproject.toml
[tool.pytest]
testpaths = ["tests"]
pythonpath = ["src"]
addopts = ["-ra"]
strict = true
markers = ["slow: tests that take more than a second"]
  • addopts adds command-line options to every run. -ra prints a short summary of every test that didn't simply pass (skips, xfails, failures) at the end.
  • strict = true (new in pytest 9) turns on all the strictness options at once: unknown markers and config keys become errors, and an xfail test that unexpectedly passes is reported as a failure, so you notice when a known bug gets fixed.
  • markers registers your custom markers with a description that shows up in pytest --markers.

The [tool.pytest] table with native TOML types arrived in pytest 9. On older versions, use [tool.pytest.ini_options], where addopts is a single string (addopts = "-ra") and the strict shortcut isn't available (use --strict-markers in addopts instead). pytest 9 still reads the old table, so existing projects keep working.

Organizing a Growing Test Suite

A few conventions keep a suite pleasant as it grows:

  • Mirror the source tree. Tests for src/shop/cart.py go in tests/test_cart.py; tests for src/shop/payments/stripe.py go in tests/payments/test_stripe.py.
  • One behavior per test. Several asserts are fine if they check one outcome from different angles. If a test checks two unrelated things, split it, so a failure points at one problem.
  • Tests must be independent. Each test should pass on its own and in any order. Never rely on state left behind by another test.
  • Group with classes when it helps. pytest collects methods from classes named Test* (with no __init__ method), which is handy for grouping related tests:
class TestDiscounts:
    def test_zero_percent_keeps_total(self):
        cart = Cart()
        cart.add("lamp", 20.00)
        assert cart.apply_discount(0) == 20.00

    def test_full_discount_is_free(self):
        cart = Cart()
        cart.add("lamp", 20.00)
        assert cart.apply_discount(100) == 0
  • Shared fixtures go in conftest.py. pytest loads conftest.py files automatically, and fixtures defined there are available to every test in that directory and below, with no imports.

What About unittest?

Python's standard library includes unittest, a class-based framework modeled on Java's JUnit. It works, and you'll meet it in older codebases and in CPython itself. pytest runs unittest-style tests too, so you can switch runners without rewriting anything and convert tests gradually.

For new code, pytest is simply less ceremony: plain functions instead of TestCase subclasses, assert x == y instead of self.assertEqual(x, y), and much more informative failure output. The one piece of unittest you'll keep using with pytest is unittest.mock, covered in mocking in Python with unittest.mock.

Conclusion

pytest's design gets out of your way: tests are functions, checks are assert statements, and failure reports show you the values that mattered. With the handful of tools covered here (pytest.approx for floats, pytest.raises for errors, tmp_path and capsys for files and output, markers for skipping and labeling, and a short [tool.pytest] config), you can test the large majority of everyday Python code.

From here, the natural next steps are writing your own fixtures and parametrizing tests to cut repetition, mocking external dependencies, and measuring which code your tests actually exercise with coverage.py. Start small: pick one module you're nervous about changing, write five tests for it, and run them every time you touch it.

Tags :
Share :

Related Posts

Abstract Base Classes in Python with the abc Module

Abstract Base Classes in Python with the abc Module

Python leans on duck typing: if an object has the method you need, you call it and move on. That works well until you have a family of classes that a

Continue Reading
*args and **kwargs in Python: Flexible Function Signatures

*args and **kwargs in Python: Flexible Function Signatures

You've seen def wrapper(*args, **kwargs): in decorators, and probably super().__init__(**kwargs) in class hierarchies. These two parameters let a

Continue Reading
Asyncio in Python: A Beginner's Guide to Asynchronous Programming

Asyncio in Python: A Beginner's Guide to Asynchronous Programming

A lot of programs spend most of their time waiting. A web scraper waits for pages to download, an API server waits for the database, a chat bot waits

Continue Reading