Module 3: Organizing The Suite Layers And Structure
6. Test discovery and `testpaths`
Overview
By the end of this lesson you will understand the mechanism we have been using without opening it: how pytest finds your tests. Every time you ran pytest tests/unit and exactly the seven unit tests appeared, pytest did a walk —from the directory to the files, from the files to the functions— following concrete and configurable rules. This lesson opens that engine: how pytest decides where to start looking, what files count as test files and what functions count as tests, and how you govern that walk with two configuration options —testpaths and python_files—. It is the difference between using pytest as a black box and understanding why it collects what it collects.
This matters because discovery is the silent basis of everything you have built in the module. Your folder structure only works if pytest walks it as you expect: if a test does not appear in the run, or one appears that should not, almost always it is discovery acting according to rules you did not know. A file you named check_pricing.py instead of test_pricing.py and that pytest silently ignores; a function verify_total() that never runs because it does not start with test_; a pytest with no arguments that walks the whole project instead of just tests/ because no one configured where to start. All are the same phenomenon: discovery did exactly what its rules say, and those rules did not match your intent. Knowing them gives you control over what runs and what does not, and saves you the hours of debugging "why does my test not execute" when the answer is a name that does not match the pattern.
Connection to the module: this lesson explains the engine that made the previous five possible. The --collect-only with which you read the shape of the suite is literally "show me what the walk discovered, without running it". The subsets by folder (lesson 5) work because the walk respects the directory you give it. And testpaths is what makes plain pytest know to start with tests/. Here we touch testpaths and python_files only as the options that govern discovery; the complete framework configuration —addopts, registering markers, the per-environment config— is module 4 and module 7. The focus is one: understanding and controlling how pytest walks your tree.
The mail carrier who delivers by rules, not by intent
Think of it this way. A mail carrier receives a pile of envelopes and delivers them through a building following mechanical rules: they look at the floor number written on the envelope, go up to that floor, look at the apartment number, and put it in that mailbox. They do not guess, do not interpret, do not wonder "who might this person have wanted to write to?": they follow what is written on the envelope, to the letter. If you write the floor and the apartment correctly, your letter arrives. If you write "for the lady with the dog" with no number, the carrier does not deliver it —not because they are foolish, but because their rule is to read numbers, and there is no number to read there—. The letter stays undelivered, silently, and you find out when the lady with the dog asks why you did not write to her.
The carrier also needs to know where to start. If you give them only the building's address, they deliver throughout the whole building. If you tell them "just the third floor", they go straight to the third and do not touch the rest. Starting at the right place saves them from walking floors that are not their business.
pytest is that mail carrier, and discovery is its delivery rules. It does not guess what you wanted to be a test: it follows written patterns. A file reaches the test pile if its name matches the python_files pattern (by default, test_*.py); a function is delivered as a test if its name matches python_functions (by default, test_*). A file called check_pricing.py is the letter with no number: pytest ignores it, not because the content is wrong, but because the name does not match the rule. And testpaths is the "start on the third floor": it tells pytest which folder to start with when you do not give it one. This lesson is learning to write the envelopes so the carrier delivers what you want, where you want.
The three questions of discovery
When you run pytest, discovery answers three questions in order. Understanding them is understanding why it collects what it collects.
First: where do I start looking? If you give it an argument —pytest tests/unit— it starts there. If you run plain pytest, with no argument, it looks at the testpaths option in your configuration; if it is there, it starts with the folders it says; if it is not there, it starts with the current directory (and walks everything that hangs from it, which is rarely what you want). Before all that, pytest determines the rootdir: the project's root, which it finds by going up from where you run until it finds a configuration file like pyproject.toml. The rootdir is its reference point for everything else.
Second: what files are test files? From the starting point, pytest walks the directory tree downward, and in each folder it collects the files whose name matches python_files —by default test_*.py (and also *_test.py)—. A file that does not match that pattern, pytest does not even open. conftest.py is the exception: pytest always loads it, because it is not a test file but a configuration/fixtures one.
Third: what inside those files is a test? Inside each test file, pytest collects the functions whose name matches python_functions —by default test_*— and the classes that match python_classes —by default Test*, and inside them the test_* methods—. A function helper() or verify_total() in a test file does not execute as a test: it does not match the pattern, so it is just auxiliary code.
The result of the three questions is the collection tree that --collect-only draws you: directories that contain files that contain functions, exactly the ones that matched the patterns, starting from where testpaths (or your argument) indicated.
Configuring discovery in pyproject.toml
The discovery rules are configurable, and the place where you configure them is a file at the project's root. We use pyproject.toml, under the [tool.pytest.ini_options] table:
# pyproject.toml — pytest discovery configuration
[tool.pytest.ini_options]
# where pytest starts looking when you run `pytest` with no arguments
testpaths = ["tests"]
# what files count as test files
python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
These four lines make explicit what pytest does by default, with one underlying change: testpaths = ["tests"] tells it "when they run you with no arguments, start with tests/". Without this line, plain pytest would walk the whole current directory —including the reservo/ code, any scripts folder, everything— looking for test_*.py files; with it, it goes straight to tests/ and does not look at the rest. This file is also what sets the rootdir: since a pyproject.toml exists, pytest takes it as the project's root.
Worked example: testpaths in action
The best way to see testpaths is to run pytest with no argument at all and read the header, which reports what configuration it loaded and where it starts:
python3 -m pytest
What to expect. On my machine (Python 3.14.0, pytest 9.1.1):
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
rootdir: /tmp/m3
configfile: pyproject.toml
testpaths: tests
collected 12 items
............ [100%]
============================== 12 passed in 0.01s ==============================
Read the three header lines, because they are discovery telling you what it did. rootdir: /tmp/m3 —it found the project's root (the folder with the pyproject.toml)—. configfile: pyproject.toml —it loaded your configuration from there—. testpaths: tests —and therefore, without you passing any argument, it started looking in tests/—. It collected the 12 tests of tests/ and did not touch reservo/. If you deleted the testpaths line from the pyproject.toml, that header would no longer say testpaths: tests, and plain pytest would walk the whole current directory —slower and with the risk of collecting things you did not want—. testpaths is the "start on the third floor" instruction; the header confirms the carrier read it.
When the name does not match: the test pytest ignores
The python_files rule has a consequence that bites everyone at some point: a file with a name that does not match the pattern is invisible to pytest, and there is no warning. Let us see it. I create a file in tests/unit/ with a perfectly valid test function, but I call it checks_overlap.py —with checks_, not test_—:
# tests/unit/checks_overlap.py — does NOT start with "test_", so pytest does NOT discover it
def test_hidden_from_discovery():
assert 1 + 1 == 2
The function is called test_hidden_from_discovery —it matches python_functions—, so if pytest opened the file, it would run it. But the file is called checks_overlap.py, which does not match python_files (test_*.py), so pytest does not even open it. I collect the suite:
python3 -m pytest --collect-only -q
What to expect:
12 tests collected in 0.01s
Still twelve. The checks_overlap.py is not among them —no notice, no error, no warning—. pytest did exactly what its rule says: the file's name did not match the pattern, so the letter stayed undelivered, silently. This is the origin of the classic "I wrote the test and it does not run": almost always the file (or the function) has a name that does not match the pattern. Now, the cure from the other side: if you really want pytest to collect checks_*.py files, you tell it by broadening python_files. I can pass it on the command line with -o (override) to test it without touching the pyproject.toml:
python3 -m pytest --collect-only -q -o "python_files=test_*.py checks_*.py"
What to expect (now python_files includes both patterns):
13 tests collected in 0.01s
Thirteen. The test that was hidden appeared:
tests/unit/checks_overlap.py::test_hidden_from_discovery
The file did not change a single letter; the only thing that changed is that you taught the carrier a new rule —"also deliver the envelopes that start with checks_"—. This demonstrates the underlying lesson: pytest does not discover by intent, it discovers by pattern, and the pattern is configurable. When a test does not run, the question is not "what is wrong with my test?", but "does its name —file and function— match the discovery patterns?". And when you want to change what is collected, you do not rename a hundred files: you adjust the pattern.
A practical piece of advice, though: the test_*.py convention is a universal standard in the pytest world, and the best thing is almost always to follow it instead of broadening it. Name your files test_*.py and your functions test_*, and the default discovery does the right thing with no configuration. Broadening python_files is a tool for special cases (integrating an inherited convention, for example), not a habit. The best discovery configuration is the one you almost never touch because you named everything according to the convention.
Common mistakes
Naming a test without the test_ prefix and believing it runs (of discovery). What happens: someone creates checks_pricing.py or a function verify_refund(), sees it pass when they execute it by hand, and assumes it is part of the suite. Why it happens: the file and the function look like tests and work if you call them yourself; what does not show is that pytest never collects them. How to detect it: run pytest --collect-only and look for your test in the tree; if it is not there, pytest does not discover it —check that the file matches python_files and the function python_functions—. How to fix it: rename them to test_pricing.py and test_refund(), the standard convention. The silence of discovery is dangerous: a test you believe protects you but that never runs is worse than not having a test, because it gives you false confidence.
Running pytest without testpaths and collecting too much (of configuration). What happens: without testpaths, plain pytest walks the whole current directory and collects test_*.py files that are anywhere —including examples, scripts, or folders that are not the suite—. Why it happens: the "start with the current directory" default walks more than one expects. How to detect it: if the header does not show the testpaths: line and the collected count is higher than you expect, pytest is looking through the whole project. How to fix it: define testpaths = ["tests"] in pyproject.toml; the header will start showing testpaths: tests and the walk will be bounded to your suite. It is the "start on the third floor" instruction that keeps the carrier from walking the whole building.
Confusing rootdir with testpaths (of concepts). What happens: someone thinks rootdir is "where the tests are" and gets confused when pytest reports it as the project's root. Why it happens: both appear in the header and sound alike. How to detect it: if you expected rootdir to be tests/ and pytest shows it as the pyproject.toml folder, you are mixing the two concepts. How to fix it: remember them as two different things —rootdir is the project's root (where the configuration lives, pytest's reference point), while testpaths is where to start looking for tests within that root—. The rootdir is almost always the folder containing pyproject.toml; testpaths is the subfolder (tests) where the suite lives. One is the building, the other is the floor to start from.
Exercises
Exercise 1 — Predict what is collected. With the default configuration (python_files = test_*.py, python_functions = test_*), say whether pytest collects each case and why. (a) File test_pricing.py, function test_basic_price(). (b) File pricing_test.py, function test_pro_price(). (c) File test_refund.py, function check_full_refund(). (d) File helpers.py, function test_something(). (e) File conftest.py, fixture focus().
See solution
- (a) Yes, it is collected. The file
test_pricing.pymatchestest_*.pyand the functiontest_basic_pricematchestest_*. Both patterns are met: it is a test. - (b) Yes, it is collected.
pricing_test.pymatches the other default pattern ofpython_files, which also includes*_test.py. The functiontest_pro_pricematches. (If yourpython_fileswere onlytest_*.py, this file would NOT be collected; pytest's default includes both,test_*.pyand*_test.py.) - (c) The function is not collected. The file
test_refund.pyis opened (it matchestest_*.py), but the functioncheck_full_refunddoes not matchpython_functions(test_*), so pytest ignores it: it treats it as auxiliary code, not as a test. The file is collected but with no test inside. - (d) It is not collected.
helpers.pydoes not matchpython_files, so pytest does not even open it —it does not matter that there is a functiontest_somethinginside—. The file rule applies first; if the file does not enter, its content does not either. - (e) It is not a test, but pytest does load it.
conftest.pyis the exception: pytest always loads it, but not as a test file —it uses it for fixtures and configuration—.focus()is not collected as a test (it is a fixture); it stays available for the tests of its folder.
The rule you applied: discovery checks first the file name (python_files) and then the function name (python_functions); both must match for something to run as a test. conftest.py goes through another path —always loaded, never collected—.
Exercise 2 — Diagnose the test that does not run. A coworker swears they wrote a test for the 36-h refund, but running pytest it does not appear and the refund bug reached production. Their file is called tests/unit/refund_checks.py and inside it has a function def test_half_refund_at_36h(): .... Without running anything, diagnose why it is not collected and give two ways to fix it.
See solution
The problem is the file name: refund_checks.py does not match any of the default python_files patterns (test_*.py or *_test.py). It does not start with test_ or end with _test. That is why pytest does not even open the file, and the function test_half_refund_at_36h inside —which would match python_functions— is never discovered. The test exists on disk, passes if you call it by hand, but the suite never runs it: it is the "letter with no number" that the carrier does not deliver, silently. That is why the bug reached production: the test that would have caught it never executed.
Two ways to fix it:
- Rename the file (the recommended one):
tests/unit/refund_checks.py→tests/unit/test_refund.py. Now it matchestest_*.py, pytest opens it, findstest_half_refund_at_36hand runs it. It follows the standard convention, without touching configuration. - Broaden
python_filesinpyproject.tomlto include the pattern of the existing name:python_files = ["test_*.py", "*_checks.py"]. Nowrefund_checks.pymatches the new pattern and is collected. It is useful if there are many files with that inherited convention, but for a loose file it is preferable to rename and stay with the standard.
How they would have detected it earlier: by running pytest --collect-only and looking for their test in the tree. Not finding it, they would have known discovery did not see it, and the bug would not have reached production.
Exercise 3 — Read the header. You are shown two plain-pytest headers of the same project, at two moments. Explain what changed between them and what consequence it has. Header A: rootdir: /app — collected 340 items. Header B: rootdir: /app — configfile: pyproject.toml — testpaths: tests — collected 128 items.
See solution
Between A and B, someone added the testpaths configuration to the project (probably by creating or completing the pyproject.toml). It shows in two things of header B that are not in A: configfile: pyproject.toml appears (pytest now loads configuration from there) and testpaths: tests (it now knows to start with tests/).
The consequence is in the count. In A, without testpaths, plain pytest walked all of /app looking for test_*.py files, and collected 340: surely it included real tests plus test_*.py files scattered in other folders —examples, vendored dependencies, scripts, third-party code that happens to follow the pattern—. In B, with testpaths: tests, pytest starts and stays in tests/, and collects 128: only the real suite. The 212 of difference were noise that the unbounded walk dragged.
The practical consequence: B's run is faster (it walks less), cleaner (it does not collect foreign files that could fail for reasons that are not yours) and more predictable (it always runs the suite, no matter what other test_*.py appear in the project). It is exactly the value of testpaths: bounding discovery to where your suite lives, instead of letting the carrier walk the whole building. The header is the way to audit that discovery starts where it should.
Summary and next step
In this lesson you opened the discovery engine. You saw the three questions pytest answers when collecting: where it starts (an argument, or testpaths, or the current directory —after setting the rootdir from the pyproject.toml—), what files are tests (the ones that match python_files, by default test_*.py), and what functions are tests (the ones that match python_functions, by default test_*). You confirmed it by running: the plain-pytest header showed rootdir, configfile: pyproject.toml and testpaths: tests, and therefore started in tests/ and collected the 12. And you saw the rule bite: a checks_overlap.py file with a valid test inside was silently ignored (still 12) until you broadened python_files and it appeared (13) —without changing a single letter of the file, only teaching the carrier a new rule—.
The analogy that holds it: the mail carrier who delivers by what is written on the envelope, not by intent. pytest discovers by pattern, not by what you wanted; the pattern is configurable, and the test_*.py convention is what makes the default hit the mark without touching anything.
Before moving on you should be able to: enumerate the three questions of discovery; explain why a badly named file is ignored without warning and how to detect it with --collect-only; and distinguish rootdir from testpaths by reading the header.
What comes next closes the module's arc. In lesson 7 you will see the idea that crowns everything: structure as living documentation. How the shape of the suite —its layers, its features, its tree— tells a newcomer the architecture of the system without a line of prose; why it is the first README no one has to write; and how to detect when a structure has started to lie, because it no longer reflects the system it claims to describe.
Resources
- pytest — Configuration:
testpaths— the official reference of the option that tells pytest where to start looking when you run it with no arguments; the "start on the third floor" of this lesson. - pytest —
python_files,python_classes,python_functions— the patterns that define what files and functions count as tests; what decides whether the carrier delivers your envelope. - pytest — How to configure pytest (
pyproject.toml) — how and where to put[tool.pytest.ini_options]; the file that sets therootdirand hoststestpathsandpython_files.