Python Testing #1 Getting Started with pytest: Why One assert Is All You Need
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.
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.
def add(a: int, b: int) -> int:
return a + bThe test looks like this.
from calc import add
def test_add():
assert add(2, 3) == 5That’s everything. There are only two rules.
- Test function names start with
test_ - The condition you want to check goes in a plain Python
assertstatement
No special class, no dedicated assertion methods. Running it is one command too.
uv run pytest========================= 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.
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.
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 # assertIsNotNoneWhat 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.
import pytest
def test_divide_by_zero():
with pytest.raises(ZeroDivisionError):
1 / 0How to read failure output #
Tests show their value when they fail. Let’s run a deliberately wrong test.
def test_add_wrong():
assert add(2, 3) == 6________________________ 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: AssertionErrorReading it is simple.
- The
>line is where the failing code is - The
Elines show the actual values.add(2, 3)returned5, and you can see right there that it differs from the expected6
When you compare dictionaries or lists, pytest even points out which parts differ.
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_*.pyor*_test.py - Functions: names starting with
test_ - Classes:
test_methods inside classes whose names start withTestand 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.
myproject/
├── src/
│ └── calc.py
├── tests/
│ └── test_calc.py
└── pyproject.tomlLet’s also configure pytest to look only at tests/.
[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.
def truncate(text: str, limit: int) -> str:
if len(text) <= limit:
return text
return text[:limit] + "..."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 withuv run pytest - A test is just a function starting with
test_containing anassert - No unittest classes or
assertEqual-style methods needed — assert rewriting gives you rich failure information from the built-inassertalone - In failure output, the
>line is the location and theElines are the actual values - Discovery rules are
test_*.pyfiles andtest_functions, and checking thecollected N itemscount 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.