pytest didn't win by adding features unittest lacked ā it won by removing ceremony. Plain assert statements, functions instead of mandatory classes, and automatic discovery turned testing from something that felt like extra work into something that's genuinely fast to write. This lesson covers the fundamentals.
1Functions, Not Mandatory Classes: Removing Ceremony
The standard library's unittest module, modeled closely on Java's JUnit, requires every test to be a method on a class inheriting from unittest.TestCase, and uses a family of specific assertion methods (self.assertEqual(a, b), self.assertTrue(x), self.assertIn(item, container)) rather than plain Python assert. This works, but it's genuine ceremony: writing the simplest possible test ā verify one function's output for one input ā requires a class definition, inheritance, and remembering which specific assertion method applies to which kind of check.
pytest's core insight was that almost none of that ceremony is actually necessary. def test_square_of_positive_number(): assert square(4) == 16 is a complete, valid, fully-functional pytest test ā no class, no inheritance, no special assertion method vocabulary to memorize. Plain Python assert, the same statement used everywhere else in the language, is the entire assertion mechanism.
This reduction in ceremony has a real, compounding effect on testing habits: when writing a test costs almost nothing beyond writing the assertion itself, engineers write more tests, more readily, for smaller pieces of behavior ā exactly the opposite of what happens when each test requires meaningful boilerplate that makes writing 'just one more small test' feel like more trouble than it's worth.
# test_math_utils.py
def square(x: int) -> int:
return x * x
def test_square_of_positive_number():
assert square(4) == 16
def test_square_of_negative_number():
assert square(-3) == 9Complete, valid pytest test ā no class, no special methods
2Assertion Rewriting: Detailed Failures From Plain assert
The obvious objection to using plain assert for testing is that a bare assert statement's default failure message is nearly useless ā AssertionError with no detail about *what* was compared or *why* it failed. This is precisely why unittest's assertEqual(a, b) exists: it can inspect both values and produce a message like "16 != 17", information a bare assert a == b genuinely cannot provide on its own.
pytest solves this without requiring specialized assertion methods through assertion rewriting: when pytest imports a test file, it doesn't run your assert statements as-is ā it rewrites them at the bytecode level, inserting instrumentation that captures the actual runtime value of every sub-expression involved in the comparison. This is genuinely different from unittest's approach; it's not a workaround, it's pytest actively modifying how Python's assert statement itself behaves specifically within pytest's test-collection process, invisible to the test author who simply writes ordinary assert syntax.
The practical payoff: assert square(4) == 17 failing produces output showing assert 16 == 17 and where 16 = square(4) ā the actual computed value, not just 'the assertion was False' ā giving you immediately actionable debugging information from code that looks like the simplest possible Python assertion, with zero additional effort required from the test author.
def test_square_wrong():
assert square(4) == 17
# pytest output on failure:
# AssertionError: assert 16 == 17
# + where 16 = square(4)
# -- shows the ACTUAL computed value, not just 'assertion failed'+ where 16 = square(4) ā the actual value, not just a generic failure
3Automatic Discovery: Just Run pytest
unittest (and many other testing frameworks historically) require some form of explicit test registration or a TestSuite assembling which tests should run. pytest instead uses a naming convention for fully automatic discovery: any file matching test_*.py or *_test.py, containing any function (or method on a class, though this is less common in idiomatic pytest) whose name starts with test_, is automatically found and executed the moment you run the bare pytest command in a project's root ā no configuration, no manual registration, no import statements listing every test file.
This convention-over-configuration approach is what makes pytest (the command) 'just work' the moment tests exist in a project following the naming pattern, and it directly interacts with the src-layout project structure covered in the Professional Project Structure module: pytest, run from a project root, automatically discovers tests in the sibling tests/ directory without any explicit path configuration needed for the common case.
The practical workflow this enables: adding a new test is as simple as writing a new test_* function in an existing (or new) test_*.py file ā there's no separate step of 'registering' it anywhere else, no risk of a test silently never running because someone forgot to add it to a suite. The discovery mechanism guarantees that if a function is named correctly and lives in a correctly-named file, pytest will find and run it.
$ pytest
====== test session starts ======
collected 12 items
test_math_utils.py .......... [100%]
====== 12 passed in 0.08s ======
# Just run 'pytest' -- it finds and runs every test_*.py file automaticallyFinds and runs every test_*.py / test_* function automatically
4Step-by-Step Breakdown
unittest requires self.assertEqual(a, b). pytest lets you write assert a == b ā and still gives you a more detailed failure message. Let's see why that matters.
A pytest test is just a function starting with test_ -- no class, no inheritance, no boilerplate required.
Just 'assert' -- no special assertEqual/assertTrue/assertIn methods to memorize. pytest REWRITES the assert statement to give detailed failure output.
Checkpoint: Why does pytest's failure output show 'where 16 = square(4)' instead of just 'assertion failed'?
- āpytest rewrites the assert statement at import time to capture and display the actual values involved in the comparison
- āThe test author manually added a print statement before the assert
pytest DISCOVERS tests automatically -- files named test_*.py, functions named test_* -- no manual test registration needed.
Checkpoint: What determines which files and functions pytest automatically discovers and runs as tests?
- āFiles matching test_*.py (or *_test.py) and functions/methods starting with test_
- āA manually maintained list of test functions in a config file
pytest's basics get you writing tests fast; Fixtures are the next tool for handling setup and teardown without repeating yourself.
Run a Real Assertion-Based Test. Finish run_test(): pytest's only real magic is rewriting assert for a helpful failure message.
Level Up š
Advanced cheat sheets, SEO tricks, and interview prep for this topic.
Browser Support
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Best Practices
Write tests as plain functions with descriptive names, not classes, unless grouping genuinely helps
pytest's function-based tests remove unnecessary ceremony ā reach for a test class specifically when sharing setup/state across a related group of tests genuinely benefits from it (often better served by fixtures instead).
Trust plain assert statements rather than reaching for a specialized assertion library
pytest's assertion rewriting already provides detailed failure output from ordinary Python assert syntax ā there's rarely a need for assertEqual-style methods or a third-party assertion library layered on top.
Frequent Bugs
Naming a test function or file in a way that doesn't match pytest's discovery convention (e.g. checkSquare instead of test_square, or utils_tests.py instead of test_utils.py), causing the test to silently never run.
Always name test files as test_*.py (or *_test.py) and test functions/methods starting with test_, matching pytest's automatic discovery convention exactly.
Real-World Examples
A Minimal Test Suite for a Utility Module
A newly-written utility module for parsing currency strings needs a test suite covering its core cases, written with minimal ceremony so the team is more likely to keep expanding it.
# test_currency_utils.py
from currency_utils import parse_currency
def test_parse_currency_with_dollar_sign():
assert parse_currency("$19.99") == 19.99
def test_parse_currency_with_comma_separator():
assert parse_currency("$1,234.56") == 1234.56
def test_parse_currency_raises_on_invalid_input():
import pytest
with pytest.raises(ValueError):
parse_currency("not a price")