pytest fundamentals: discovery, plain asserts, and reading the output#
How pytest finds anything at all#
Every testing setup you've seen informally in this program (Week 7's loose assert functions, or your own ad hoc checks) has had you run the file directly: python some_script.py. pytest doesn't work that way. You don't tell it which function to run; you point it at a directory (or run it bare, with no arguments, from inside your project), and it goes looking for tests itself, using two plain naming rules:
- A file counts as a test file if its name starts with
test_or ends with_test(test_wallet.py,wallet_test.py, either works; this program's convention, and the one you'll see almost everywhere, is thetest_prefix). - Inside a test file, a function counts as a test if its name starts with
test_.
That's the entire mechanism. There's no decorator marking a function as a test, no list you add it to, no class it has to inherit from. Name the file right, name the function right, and pytest collects it automatically the moment it walks past that file. This also means a helper function you write to keep a test file organized, make_fake_holder(...) or similar, needs a name that does not start with test_, or pytest will try to collect and run it as if it were a test itself, usually failing in a confusing way since it probably doesn't return a pass/fail result the way a real test does.
Plain assert, made readable#
Every test you write in pytest is, at its core, a sequence of setup followed by one or more plain assert statements. That's a deliberate design choice, and it's worth noticing what pytest isn't asking you to do: it doesn't want self.assertEqual(a, b), or assert_that(a, equals(b)), or any other specialized assertion method from a class you have to inherit from. You already know how to write assert wallet.balance == 50; that's the entire vocabulary.
The part that makes this actually pleasant to use is what happens on failure. Python's own bare assert normally gives you almost nothing: AssertionError, full stop, with no indication of what the two sides of the comparison actually were. pytest rewrites assert statements as it imports your test files (this is called "assertion rewriting"), so that when one fails, you get a real breakdown: the expression that was evaluated, and what each piece of it actually equaled at the moment of failure. Try changing 50 to 500 in the snippet above and running it; instead of a bare AssertionError, you'd see something like assert 50 == 500, spelled out from pytest's own introspection of the comparison, not anything you had to ask for.
Running it and reading what comes back#
From your project's root (or any folder above your test files), run:
pytest
with no arguments. pytest walks the current directory, collects every matching file and function, runs each one, and prints a summary line per file (a . for each pass, an F for each failure) followed by a final count: 3 passed in 0.02s, for instance. Add -v (pytest -v) for a more verbose version that names every individual test instead of just printing dots.
When something fails, pytest prints a traceback pointing at the exact assert line inside your test function, plus the rewritten breakdown described above. Read that output before you do anything else: it usually tells you precisely which value was wrong and what it was instead, which is most of what you need to form a hypothesis about the bug, the same "read the error, form a hypothesis, confirm it" habit the program has been building since the Foundations checkpoint.
What to watch for#
- A helper function accidentally named
test_something. pytest will try to collect and run it, and if it doesn't take the shape of a real test (no assertions, or the wrong number of arguments if it expects a fixture you haven't given it), you'll get a confusing collection error instead of your actual tests running. - A test file that isn't named
test_*.pyor*_test.py. pytest's default discovery simply skips it, silently; there's no error telling you "this file looks like it might have tests in it." If a test file isn't showing up in your results at all, check its name first. - Forgetting that a test function returning a value (instead of asserting something) does nothing. A test's job is to
assert, not toreturn Trueor similar; pytest doesn't look at return values to decide pass or fail.
TL;DR#
This page covers how pytest finds your tests without you registering them anywhere, why a plain assert is all you ever need to write, and how to run pytest and read what it tells you.
- pytest finds tests by name alone: any file named
test_*.py(or*_test.py), and inside it, any function namedtest_*. No base class, no decorator, no import of some registry to add yourself to. - You write a plain Python
assert, nothing more specialized. pytest rewritesassertstatements at import time so that a failure shows you the actual values on both sides, not just a bareAssertionError. - Running the bare
pytestcommand from your project root walks the current directory (and everything under it), collects every file and function matching those names, and runs them all. - A failing test's output points at the exact line and the exact values involved, which is usually enough to see what went wrong without adding a single
printstatement.
# test_wallet.py -- the filename alone is how pytest finds this file at all
class Wallet:
def __init__(self, owner):
self.owner = owner
self.balance = 0
def deposit(self, amount):
self.balance += amount
def test_deposit_increases_balance(): # and test_ is how pytest finds this function inside it
wallet = Wallet(owner="ada")
wallet.deposit(50)
assert wallet.balance == 50 # a plain assert -- pytest rewrites this to show both sides if it fails
# run with: pytest
# pytest walks the current directory, collects both of the above by name, and prints
# something like "1 passed in 0.01s", or, on failure, a traceback naming this exact
# line and showing what wallet.balance actually was