Python Testing #1 Getting Started with pytest: Why One assert Is All You Need

7 min read

If you’ve ever changed some code and felt that nagging worry — “did I just break something somewhere else?” — this series is for you. Aimed at readers who’ve finished Modern Python Basics, it’s a seven-part series that builds pytest, the standard testing tool for Python, from the ground up.

  • #1 Getting started with pytest ← this post
  • #2 Fixtures
  • #3 parametrize and markers
  • #4 mock and monkeypatch
  • #5 Testing the outside world (HTTP, DB, web frameworks)
  • #6 Test design and coverage
  • #7 CI integration (wrap-up)

We already covered FastAPI endpoint testing in Modern Python in Practice #6. That post was about running tests on top of a web framework; this series builds the foundation underneath it — pytest itself — step by step from the beginning.

The real cost of untested code #

Projects without tests tend to follow the same story. At first, you move fast. Every hour not spent writing tests is an hour spent shipping another feature. The trouble starts once the code piles up.

You fix one utility function, and some other screen that uses it quietly breaks. You only find out after deployment, from a user report. After that happens two or three times, the whole team reaches the same conclusion: “let’s touch this as little as possible.” That’s the moment the code starts to age. You can’t fix bad structure, you can’t clean up duplication even when you see it. The moment changes become scary is the real cost of untested code.

A test is executable evidence that “this code behaves like this.” With that evidence in place, you can change code and re-verify everything with a single command — and changes stop being scary.

Installation: uv add –dev pytest #

pytest is a third-party package, so it needs to be installed. In a uv project, it’s one line.

Install pytest
uv add --dev pytest

--dev marks it as a dependency “only needed during development, not in production deployments.” A testing tool has no business on a production server, so the dev group is exactly the right place for it. If you’re not using uv, pip install pytest works just the same.

Your first test: a single assert is enough #

Let’s write a function to test.

calc.py
def add(a: int, b: int) -> int:
    return a + b

The test looks like this.

test_calc.py
from calc import add


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

That’s everything. There are only two rules.

  • Test function names start with test_
  • The condition you want to check goes in a plain Python assert statement

No special class, no dedicated assertion methods. Running it is one command too.

Run
uv run pytest
Output
========================= test session starts =========================
collected 1 item

test_calc.py .                                                   [100%]

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

Each . is one passing test. As tests grow, the dots grow; when one fails, an F appears in its place.

Compared with unittest: no classes, no assertEqual #

The standard library has its own test framework, unittest. The same test written with unittest looks like this.

🚫 The unittest way
import unittest

from calc import add


class TestAdd(unittest.TestCase):
    def test_add(self):
        self.assertEqual(add(2, 3), 5)


if __name__ == "__main__":
    unittest.main()

You create a class, inherit from TestCase, and call self.assertEqual. Every kind of check has its own method name, too — assertEqual, assertTrue, assertIn, assertIsNone, assertRaises, and dozens more to keep straight.

pytest replaces all of that with a single assert.

✅ The pytest way: assert for everything
def test_various():
    assert add(2, 3) == 5            # assertEqual
    assert add(1, 1) > 0             # assertGreater
    assert 3 in [1, 2, 3]            # assertIn
    assert add(0, 0) is not None     # assertIsNotNone

What makes this possible is pytest’s assert rewriting. When pytest imports a test file, it rewrites the assert statements internally so that on failure they show all the intermediate values in the expression. A plain Python assert only throws a bare AssertionError, but inside pytest, “what differed and how” is printed right there. That’s why you never need to memorize dedicated assertion methods.

The one place you use a dedicated tool is exception testing — pytest.raises, the counterpart to unittest’s assertRaises.

Testing exceptions
import pytest


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

How to read failure output #

Tests show their value when they fail. Let’s run a deliberately wrong test.

A failing test
def test_add_wrong():
    assert add(2, 3) == 6
Failure output
________________________ test_add_wrong ________________________

    def test_add_wrong():
>       assert add(2, 3) == 6
E       assert 5 == 6
E        +  where 5 = add(2, 3)

test_calc.py:8: AssertionError

Reading it is simple.

  • The > line is where the failing code is
  • The E lines show the actual values. add(2, 3) returned 5, and you can see right there that it differs from the expected 6

When you compare dictionaries or lists, pytest even points out which parts differ.

Collection comparison failure (excerpt)
E       AssertionError: assert {'id': 1, 'role': 'admin'} == {'id': 1, 'role': 'member'}
E         Differing items:
E         {'role': 'admin'} != {'role': 'member'}

If the output gets truncated and you want the full picture, uv run pytest -vv shows the complete comparison. Just learning to read failure output properly cuts your debugging time in half.

Test discovery: how pytest finds your tests #

The reason your test file ran from nothing more than uv run pytest is that pytest collects tests automatically by a fixed set of rules. This is called test discovery.

  • Files: test_*.py or *_test.py
  • Functions: names starting with test_
  • Classes: test_ methods inside classes whose names start with Test and that have no __init__

Anything that doesn’t match these rules is silently ignored. A misnamed file like tset_calc.py simply never runs — so when a test seems to be passing, first suspect “it wasn’t even collected” rather than assuming it passed. That’s why the habit of checking the collected N items count on the first line of the output matters.

As a project grows, moving tests into a tests/ directory is the standard layout.

Recommended layout
myproject/
├── src/
│   └── calc.py
├── tests/
│   └── test_calc.py
└── pyproject.toml

Let’s also configure pytest to look only at tests/.

pyproject.toml
[tool.pytest.ini_options]
testpaths = ["tests"]

For imports from tests/ to work in a src layout, the project itself needs to be installed as a package — and if you use uv run pytest in a uv project, that part is handled automatically.

What should you test? #

Since this is the first post, let’s touch on just two principles.

First, test public behavior. Verify what comes out given what goes in, not which internal variables the function touches along the way. Internal implementation changes when you refactor, but public behavior must hold — and tests are what guards it.

Second, test boundary values. Bugs hide at the edges far more often than in ordinary inputs.

src/textutil.py
def truncate(text: str, limit: int) -> str:
    if len(text) <= limit:
        return text
    return text[:limit] + "..."
tests/test_textutil.py
from textutil import truncate


def test_short_text_unchanged():
    assert truncate("hi", 10) == "hi"


def test_long_text_truncated():
    assert truncate("hello world", 5) == "hello..."


def test_exact_limit():
    assert truncate("hello", 5) == "hello"


def test_empty_string():
    assert truncate("", 5) == ""

The edges are what matter: a string exactly at the limit, an empty string. Ask yourself once, “what’s the weirdest input this function could receive?”, write it as a test, and four tests are enough to serve as a complete behavioral spec for the function.

Recap #

What we covered in this post:

  • The real cost of untested code shows up the moment changes become scary
  • Install with uv add --dev pytest, run with uv run pytest
  • A test is just a function starting with test_ containing an assert
  • No unittest classes or assertEqual-style methods needed — assert rewriting gives you rich failure information from the built-in assert alone
  • In failure output, the > line is the location and the E lines are the actual values
  • Discovery rules are test_*.py files and test_ functions, and checking the collected N items count is a habit worth building
  • Test public behavior and boundary values, not internal implementation

In the next post (#2 Fixtures), we’ll cover fixtures — the tool that gathers the setup and teardown repeated across tests into one place. It’s the beginning of what makes pytest feel like pytest.

X