pytest fundamentals: discovery, plain asserts, and reading the output#

← Back to Real pytest

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:

  1. 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 the test_ prefix).
  2. 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_*.py or *_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 to return True or 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 named test_*. No base class, no decorator, no import of some registry to add yourself to.
  • You write a plain Python assert, nothing more specialized. pytest rewrites assert statements at import time so that a failure shows you the actual values on both sides, not just a bare AssertionError.
  • Running the bare pytest command 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 print statement.
# 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