Chapter 03

Discovery and running tests — what pytest runs, and how to choose

Which files, functions and classes pytest treats as tests, importing your code from a src/ and tests/ layout, node IDs, and running exactly what you need with -k, -x, --lf, --ff and --co.

45 minPython 3.12
  1. 1Encounter
  2. 2Understand
  3. 3Worked
  4. 4Predict
  5. 5Apply
  6. 6Stretch

The problem we are solving

So far every test has lived in one file, next to the code it tests, and plain pytest has been enough. Real projects do not stay that small. The code moves into a package, the tests move into their own folder, and the very next run looks like this:

text
$ pytest -q

==================================== ERRORS ====================================
_____________________ ERROR collecting tests/test_cart.py ______________________
ImportError while importing test module '/home/you/shop/tests/test_cart.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
/usr/lib/python3.12/importlib/__init__.py:90: in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
tests/test_cart.py:1: in <module>
    from shop.cart import Cart
E   ModuleNotFoundError: No module named 'shop'
=========================== short test summary info ============================
ERROR tests/test_cart.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.01s

Not one test ran. The code is fine; pytest simply could not find it.

Once that is fixed, a second problem arrives: there are now dozens of tests, one of them fails, and every rerun runs all of them again. You want to run only that one — or only the ones that failed last time.

Both problems come from the same place. Before pytest runs anything, it collects: it walks the folders, imports files, and builds a list of tests. This chapter is about how that list is built, and how to choose from it.

By the end of this chapter you can

  • State the rules pytest uses to decide what is a test
  • Lay out a project with src/ and tests/, and make the tests able to import the code
  • Group tests in classes, and read a node ID such as tests/test_cart.py::TestCart::test_total_adds_tax
  • Run one file, one class, one test, or a -k selection of them
  • Use -x, --maxfail, --lf, --ff, --co and --durations in daily work
  • Read pytest's exit code and say what it means

Prerequisites: assert and failure reports.


Before you write the test

In this chapter the question before the first line of test code is not only what to test, but where the test will live and how `pytest` will find it. A test that is never collected is worse than no test: it looks like protection and is not.

1. Know the promise. The code we will test is a small shop package:

  • apply_discount(price, percent) returns the price reduced by percent, rounded to 2 places. A percent outside 0–100 raises ValueError.
  • add_tax(price, rate=0.15) returns the price with tax added, rounded to 2 places.
  • Cart collects (name, price, quantity) items; subtotal() multiplies by quantity; total() is the subtotal with tax.

2. Have the setup ready. A virtual environment with pytest installed; the code laid out in src/shop/; and — the part this chapter is about — a way for the tests to import shop. If that last part is missing, nothing else matters.

3. Plan the cases, and decide where each one lives. One test file per module (tests/test_pricing.py mirrors src/shop/pricing.py), and the cart tests grouped in one class. Writing the node ID into the plan forces you to decide that up front:

| Case | Input | Expected | Node ID | |---|---|---|---| | Normal discount | apply_discount(200.0, 10) | 180.0 | tests/test_pricing.py::test_discount_ten_percent | | Boundary: no discount | apply_discount(200.0, 0) | 200.0 | tests/test_pricing.py::test_discount_zero | | Default tax | add_tax(100.0) | 115.0 | tests/test_pricing.py::test_add_tax_default_rate | | Edge: empty cart | Cart().subtotal() | 0 | tests/test_cart.py::TestCart::test_empty_cart_subtotal | | Quantity counts | 3 pens at 15.0 | 45.0 | tests/test_cart.py::TestCart::test_subtotal_counts_quantity | | Total includes tax | 1 bag at 100.0 | 115.0 | tests/test_cart.py::TestCart::test_total_adds_tax | | Invalid percent | apply_discount(200.0, 150) | ValueError | later — testing exceptions has its own chapter |

4. Decide what not to test. Do not test round() or list.append — Python's own behaviour is not your promise. Do not test that Cart.items is a list of tuples; that is an internal detail you may change tomorrow. Test what a caller sees: subtotal() and total().

Now the chapter builds exactly that plan, and every problem along the way is a discovery problem.

The rules of discovery

pytest does not run whatever it finds. It follows four rules, and anything outside them is ignored without a word:

  1. Files named test_*.py or *_test.py
  2. Functions in those files whose names start with test
  3. Classes whose names start with Test — and which have no __init__ method
  4. Methods inside such a class whose names start with test

Why rules at all, instead of "run every function"? Because your project is full of functions that are not tests — helpers, the code under test itself — and pytest needs a way to tell them apart without you registering anything.

The fastest way to believe the rules is to break them on purpose. test_rules.py:

python
def test_found():
    assert True


def check_not_found():
    assert False


class TestGroup:
    def test_method_found(self):
        assert True

    def helper(self):
        assert False


class CartTests:
    def test_ignored(self):
        assert False


class TestWithInit:
    def __init__(self):
        self.value = 1

    def test_never_runs(self):
        assert self.value == 1

Next to it, rules_test.py holds one passing test_suffix_style, and helpers.py holds a test_in_helpers that does assert False. Now pytest -v:

text
collecting ... collected 3 items

rules_test.py::test_suffix_style PASSED                                  [ 33%]
test_rules.py::test_found PASSED                                         [ 66%]
test_rules.py::TestGroup::test_method_found PASSED                       [100%]

=============================== warnings summary ===============================
test_rules.py:22
  /home/you/disc/test_rules.py:22: PytestCollectionWarning: cannot collect test class 'TestWithInit' because it has a __init__ constructor (from: test_rules.py)
    class TestWithInit:

-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
========================= 3 passed, 1 warning in 0.01s =========================

There are five assert False lines in that folder, and the run is green. What was skipped, and why:

  • check_not_found — the name does not start with test.
  • TestGroup.helper — inside a test class, but its own name does not start with test. Helper methods are allowed; they are just not tests.
  • CartTests — the class name has to start with Test.
  • TestWithInit — pytest must create the class itself, with no arguments, once per test. A class with its own __init__ cannot be trusted to allow that, so it is skipped — with a warning, the only noise in the whole run.
  • helpers.py — the file name matches neither pattern, so it is never even imported.
The silent part is the dangerous part. If you write def tset_total(): by mistake, the suite stays green forever. When a new test "passes" suspiciously easily, check that it was collected at all — --co, below, is how.

A real layout: src/ and tests/

text
shop/
├── pyproject.toml
├── src/
│   └── shop/
│       ├── __init__.py
│       ├── cart.py
│       └── pricing.py
└── tests/
    ├── test_cart.py
    └── test_pricing.py

The code lives in src/shop/; the tests live in tests/ and never ship with the package. This is the src layout. Its point is that the code cannot be imported "by accident" just because you happen to be standing in the project folder — so the tests import it the same way a real user would.

src/shop/pricing.py:

python
def apply_discount(price, percent):
    if not 0 <= percent <= 100:
        raise ValueError(f"percent must be between 0 and 100: {percent}")
    return round(price * (100 - percent) / 100, 2)


def add_tax(price, rate=0.15):
    return round(price * (1 + rate), 2)

src/shop/cart.py:

python
from shop.pricing import add_tax


class Cart:
    def __init__(self):
        self.items = []

    def add(self, name, price, quantity=1):
        self.items.append((name, price, quantity))

    def subtotal(self):
        return sum(price * quantity for _, price, quantity in self.items)

    def total(self):
        return add_tax(self.subtotal())

tests/test_pricing.py — the first three rows of the plan:

python
from shop.pricing import add_tax, apply_discount


def test_discount_ten_percent():
    assert apply_discount(200.0, 10) == 180.0


def test_discount_zero():
    assert apply_discount(200.0, 0) == 200.0


def test_add_tax_default_rate():
    assert add_tax(100.0) == 115.0

That from shop.pricing import ... is exactly what produced the ModuleNotFoundError at the top of the chapter.

Why shop cannot be found

import shop succeeds only if some folder on sys.path contains a shop folder. The package lives in src/, and src/ is not on sys.path. pytest adds the folder of each test file (here tests/) so a test can import its neighbours — but nothing adds src/.

The wrong way is to patch sys.path inside the test file:

python
import sys

sys.path.insert(0, "src")

from shop.cart import Cart


def test_empty_cart_subtotal():
    assert Cart().subtotal() == 0

From the project folder it passes. Then someone runs it from inside tests/:

text
$ cd tests
$ pytest -q

==================================== ERRORS ====================================
________________________ ERROR collecting test_cart.py _________________________
ImportError while importing test module '/home/you/shop/tests/test_cart.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
/usr/lib/python3.12/importlib/__init__.py:90: in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
test_cart.py:5: in <module>
    from shop.cart import Cart
E   ModuleNotFoundError: No module named 'shop'
=========================== short test summary info ============================
ERROR test_cart.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.01s

"src" is a path relative to wherever you happen to be, so the test now depends on the folder you type the command in. And every test file needs the same three lines. There are two right ways.

Right way 1: tell pytest where the code is. In pyproject.toml:

toml
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]

pythonpath adds src/ to sys.path before any test is imported — resolved from the project root, not from wherever you stand. testpaths says where to look when you type plain pytest. Now:

text
$ pytest
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/shop
configfile: pyproject.toml
testpaths: tests
collected 6 items

tests/test_cart.py ...                                                   [ 50%]
tests/test_pricing.py ...                                                [100%]

============================== 6 passed in 0.01s ===============================

configfile: pyproject.toml in the header is the proof that pytest read your settings. Configuration files get a chapter of their own later; these two lines are all you need now.

Right way 2: install the package. If pyproject.toml describes a real package (a [build-system] and a [project] table), run pip install -e . once. That installs the package in editable mode: import shop now works everywhere — tests, REPL, scripts — and edits to src/ are picked up without reinstalling. After it, pytest -q gives the same 6 passed with no pythonpath at all. Most packaged projects do this; pythonpath is the quicker choice when the project is not a package.

Grouping tests in classes

tests/test_cart.py puts the cart rows of the plan in one class:

python
from shop.cart import Cart


class TestCart:
    def test_empty_cart_subtotal(self):
        assert Cart().subtotal() == 0

    def test_subtotal_counts_quantity(self):
        cart = Cart()
        cart.add("pen", 15.0, quantity=3)
        assert cart.subtotal() == 45.0

    def test_total_adds_tax(self):
        cart = Cart()
        cart.add("bag", 100.0)
        assert cart.total() == 115.0
text
$ pytest -q tests/test_cart.py
...                                                                      [100%]
3 passed in 0.01s

There is no base class to inherit from — TestCart is a plain class, and self is just the required first parameter. Why bother? Because the class gives related tests one name, and that name becomes something you can select: run "all the cart tests" with one argument.

What the class is not for is sharing state. pytest creates a fresh instance for every test method, so a value stored on self in one test is gone in the next. That is deliberate — tests that depend on each other's leftovers pass or fail depending on the order they run in. Each test above builds its own Cart(). Fixtures, in a later chapter, are the proper tool for shared setup.

Node IDs: the address of a test

Ask pytest to list what it collected without running anything — --collect-only, or --co for short:

text
$ pytest --co -q
tests/test_cart.py::TestCart::test_empty_cart_subtotal
tests/test_cart.py::TestCart::test_subtotal_counts_quantity
tests/test_cart.py::TestCart::test_total_adds_tax
tests/test_pricing.py::test_discount_ten_percent
tests/test_pricing.py::test_discount_zero
tests/test_pricing.py::test_add_tax_default_rate

6 tests collected in 0.01s

Compare it with the plan's last column: every row is there, at the address you chose. Each line is a node ID — the file path, then ::, then the class if there is one, then :: and the test name. It is the same string that appears after FAILED in a failure summary, so you can copy it from a failure and paste it straight back into the command line.

Choosing what to run

By path or node ID

The wrong way to run one test is to comment out the others, or to rename them so pytest skips them — and then forget to undo it. The right way is to give pytest an address:

text
$ pytest -q tests/test_pricing.py
...                                                                      [100%]
3 passed in 0.01s
$ pytest -q tests/test_cart.py::TestCart
...                                                                      [100%]
3 passed in 0.01s
$ pytest -q tests/test_cart.py::TestCart::test_total_adds_tax
.                                                                        [100%]
1 passed in 0.01s

A folder, a file, a class, a single test — each level of the node ID narrows the selection, and the code is never touched.

By name: -k

-k takes an expression and keeps the tests whose names match it:

text
$ pytest -q -k discount
..                                                                       [100%]
2 passed, 4 deselected in 0.01s

The matching is a case-insensitive substring search, and words combine with and, or, not and parentheses. Quote the expression so the shell passes it as one argument:

text
$ pytest -q -k "discount or tax"
....                                                                     [100%]
4 passed, 2 deselected in 0.01s
$ pytest -q -k "TestCart and not tax"
..                                                                       [100%]
2 passed, 4 deselected in 0.01s

This is why test names are worth choosing with care: test_discount_zero can be found by discount, by zero, or by both. A name like test_2 can be found by nothing.

One detail catches everyone once. -k matches the test's name and the names above it — its class and its file:

text
$ pytest --co -q -k "not cart"
tests/test_pricing.py::test_discount_ten_percent
tests/test_pricing.py::test_discount_zero
tests/test_pricing.py::test_add_tax_default_rate

3/6 tests collected (3 deselected) in 0.01s

None of the three TestCart tests has cart in its own name, yet all three were dropped — they live in test_cart.py. When a -k selection surprises you, add --co and look before you run.

Selecting by a label you attach yourself, rather than by name, is what markers and -m are for, in chapter twelve.

When something fails

Break the code on purpose: change the default rate in add_tax from 0.15 to 0.18. Two tests now fail. (--tb=no hides the failure reports so the shape of the run is visible; you read those reports in the last chapter.)

text
$ pytest -q --tb=no
..F..F                                                                   [100%]
=========================== short test summary info ============================
FAILED tests/test_cart.py::TestCart::test_total_adds_tax - assert 118.0 == 115.0
FAILED tests/test_pricing.py::test_add_tax_default_rate - assert 118.0 == 115.0
2 failed, 4 passed in 0.01s

-x stops at the first failure. When one broken function makes ten tests fail, the first report is the one worth reading; the other nine are echoes:

text
$ pytest -q -x --tb=no
..F
=========================== short test summary info ============================
FAILED tests/test_cart.py::TestCart::test_total_adds_tax - assert 118.0 == 115.0
!!!!!!!!!!!!!!!!!!!!!!!!!! stopping after 1 failures !!!!!!!!!!!!!!!!!!!!!!!!!!!
1 failed, 2 passed in 0.01s

--maxfail=N stops after N failures. -x is exactly --maxfail=1.

--lf (last failed) reruns only what failed last time. pytest remembers failures in a .pytest_cache folder in the project:

text
$ pytest -q --lf --tb=no
FF                                                                       [100%]
=========================== short test summary info ============================
FAILED tests/test_cart.py::TestCart::test_total_adds_tax - assert 118.0 == 115.0
FAILED tests/test_pricing.py::test_add_tax_default_rate - assert 118.0 == 115.0
2 failed, 2 deselected in 0.01s

Only the two failures ran. Put the rate back, and --lf runs those two again and they pass. Run --lf once more with nothing left failing, and pytest prints run-last-failure: no previously failed tests, not deselecting items. and runs everything.

--ff (failed first) runs the failures first, then all the rest. The quick answer comes at the top, the full safety check after:

text
$ pytest -q --ff --tb=no
FF....                                                                   [100%]
=========================== short test summary info ============================
FAILED tests/test_cart.py::TestCart::test_total_adds_tax - assert 118.0 == 115.0
FAILED tests/test_pricing.py::test_add_tax_default_rate - assert 118.0 == 115.0
2 failed, 4 passed in 0.01s

FF.... — the two failures ran first, before the four that passed.

A good loop while fixing a bug: pytest --lf -x until it is green, then plain pytest once. Why the last step? Because --lf only reruns what was red — it cannot tell you that your fix broke something that used to be green.

Output from your tests: capturing and -s

print inside a test seems to do nothing. (--tb=short below just makes the failure report shorter.) pytest captures everything a test writes to the screen and shows it only if that test fails:

python
def test_quiet():
    print("subtotal is", 45.0)
    assert 45.0 == 45.0


def test_loud():
    print("subtotal is", 40.0)
    assert 40.0 == 45.0
text
$ pytest -q --tb=short
.F                                                                       [100%]
=================================== FAILURES ===================================
__________________________________ test_loud ___________________________________
test_print.py:8: in test_loud
    assert 40.0 == 45.0
E   assert 40.0 == 45.0
----------------------------- Captured stdout call -----------------------------
subtotal is 40.0
=========================== short test summary info ============================
FAILED test_print.py::test_loud - assert 40.0 == 45.0
1 failed, 1 passed in 0.01s

The passing test's output vanished; the failing test's output is attached to its report under Captured stdout call. With hundreds of tests, that is what you want — silence when all is well, evidence when it is not. To see everything as it happens, -s turns capturing off:

text
$ pytest -q -s --tb=no
subtotal is 45.0
.subtotal is 40.0
F
=========================== short test summary info ============================
FAILED test_print.py::test_loud - assert 40.0 == 45.0
1 failed, 1 passed in 0.01s

Exit codes

When pytest finishes, it hands the shell a number. CI systems decide "green" or "red" from that number alone:

| Code | Meaning | |---|---| | 0 | All collected tests passed | | 1 | Tests ran, and at least one failed | | 2 | The run was interrupted — an error during collection, or Ctrl+C | | 3 | An internal error inside pytest or a plugin | | 4 | A usage error — an unknown option, or a path or node ID that does not exist | | 5 | No tests were collected |

5 deserves attention. A -k that matches nothing, or a folder with no correctly named files, is not a pass — and CI treats it as a failure, which is exactly right:

text
$ pytest -q -k refund

6 deselected in 0.01s
$ echo $?
5

The ModuleNotFoundError at the start of the chapter ended in Interrupted: 1 error during collection — exit code 2. A collection error is not a failing test; pytest never got as far as running tests.


A complete example

The plan is implemented and green. Now a slow test joins tests/test_cart.py, in a class of its own:

python
from shop.cart import Cart


class TestBigCart:
    def test_many_items(self):
        cart = Cart()
        for _ in range(300_000):
            cart.add("pen", 1.0)
        assert cart.subtotal() == 300_000.0

The file still holds TestCart above this class. First check what pytest sees, before running anything:

text
$ pytest --co -q
tests/test_cart.py::TestCart::test_empty_cart_subtotal
tests/test_cart.py::TestCart::test_subtotal_counts_quantity
tests/test_cart.py::TestCart::test_total_adds_tax
tests/test_cart.py::TestBigCart::test_many_items
tests/test_pricing.py::test_discount_ten_percent
tests/test_pricing.py::test_discount_zero
tests/test_pricing.py::test_add_tax_default_rate

7 tests collected in 0.01s

Seven tests, each with an address. Run them all and ask for the slowest three:

text
$ pytest -q --durations=3
.......                                                                  [100%]
============================= slowest 3 durations ==============================
0.04s call     tests/test_cart.py::TestBigCart::test_many_items

(2 durations < 0.005s hidden.  Use -vv to show these durations.)
7 passed in 0.12s

Only one test was slow enough to list. On a real project, --durations=10 is how you find the handful of tests that make the whole suite feel slow. While working on the cart, leave the slow one out:

text
$ pytest -q -k "cart and not many"
...                                                                      [100%]
3 passed, 4 deselected in 0.01s

Three things made this work. pythonpath = ["src"] lets from shop.cart import Cart succeed at all. TestBigCart is collected because its name starts with Test and it has no __init__. And -k "cart and not many" kept exactly the TestCart tests: cart matched everything in test_cart.py through the file name, and not many removed the slow one.


When it breaks

ModuleNotFoundError: No module named 'shop' during collection The code under test is not on sys.path. With a src/ layout, add pythonpath = ["src"] under [tool.pytest.ini_options], or install the package with pip install -e .. The run ends with Interrupted: 1 error during collection and exit code 2.

It works with python -m pytest but not with pytest python -m pytest also puts the current folder on sys.path; plain pytest does not. So a module in the project root is importable one way and not the other. Do not rely on the difference — set pythonpath, or install the package.

PytestCollectionWarning: cannot collect test class 'TestCart' because it has a __init__ constructor Test classes must not define __init__. Remove it and build what each test needs inside the test.

no tests ran, exit code 5 Nothing matched the discovery rules. Check the file name (test_*.py or *_test.py), the function names (test...), the class names (Test...). If you used -k, it may match nothing.

ERROR: not found: .../tests/test_cart.py::test_total_adds_tax The node ID is wrong. That test lives inside TestCart, so its address is tests/test_cart.py::TestCart::test_total_adds_tax. Copy node IDs from pytest --co -q rather than typing them. Exit code 4.

ERROR: file or directory not found: tests/test_carts.py A typo in the path. Exit code 4.

pytest: error: unrecognized arguments: --maxfial=2 A misspelt option; pytest --help lists the real ones. Exit code 4.