Module 4: Markers And Configuration
5. The central config file
Overview
By the end of this lesson you will understand the pytest configuration file as what it really is: the contract of the framework, the only place that defines how the suite runs for the whole team. In lesson 4 you opened it through a crack —a single key, markers, to register the vocabulary—. Here you open it whole: you are going to see what other options live in [tool.pytest.ini_options] (testpaths, python_files, filterwarnings, besides markers), how pytest finds its configuration file among several possible formats, and how to read the header of each run to confirm exactly which configfile was loaded. You are going to go from "I put an option in a file" to "this file governs the suite, and I know how to read the proof that it applied".
This matters because, without a central config file, each person runs the suite their own way. One types pytest tests --strict-markers -p no:cacheprovider; another just pytest; a third forgets the tests and runs from a folder where pytest finds nothing. They get different results, argue about "on my machine it does pass", and no one knows what the correct way to run the suite is because there is no correct way written anywhere. The config file solves that at the root: testpaths sets where to start looking, python_files sets what files count as tests, markers sets the vocabulary, filterwarnings sets which warnings are tolerated and which kill the run. One file, one behavior, for everyone. When someone new joins the project, plain pytest already does the right thing —because the contract is written and pytest reads it on its own.
Connection to the module: this lesson widens the configuration layer that lesson 4 started. There you registered markers; here you see the complete file where that registration is only one option among several. Lesson 6 adds the missing piece —addopts, the default options pytest applies on every run, with which --strict-markers of lesson 4 will activate on its own—. Remember the boundary of lesson 1: here the config file is single and fixed —it defines one behavior, the same everywhere—; making the config vary according to the environment (local versus CI, the --env option) is module 7, a layer mounted on top of this. Here we build the base contract that holds the same for everyone.
The house rules, posted on the wall
Think of it this way. A shared workshop where ten people come to work can function in two ways. In the first, there are no written rules: everyone decides where to leave the tools, at what time to clean up, what can be used and what not. At first it looks like freedom, but soon it is chaos —one keeps the hammer in a drawer, another looks for it for half an hour, a third swears "it always goes on the table"—. There is no correct answer to "how do we work here?", because no one wrote it, so there are ten answers, one per person, and none is authoritative.
In the second way, there is a rulebook posted on the wall: a single sign that says where each tool goes, how the workshop is left, what can be used. Anyone who comes in reads it and works the same as the others —not because they memorized it, but because it is in plain sight and is the single source of truth—. If someone doubts "is this done this way?", they look at the sign; if the team wants to change a rule, it changes the sign once and everyone sees it. The rulebook does not take anyone's freedom in what matters; it takes away the thousand small arbitrary decisions that made each person work differently. The pytest configuration file is that sign: a single place that answers "how is the suite run here?", visible to everyone, and that pytest reads automatically on every run.
The options that live in the file
Let us open Reservo's file with the options this module needs. It is the same pyproject.toml of lesson 4, now with more than one key:
# pyproject.toml — the contract of the Reservo suite
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
filterwarnings = ["error"]
markers = [
"smoke: the critical happy path; run before deploying.",
"slow: the test takes long; excluded with -m 'not slow' while developing.",
"integration: assembles several real pieces of Reservo together.",
]
Let us go option by option, because each one sets a decision that before was left to the chance of who ran the command.
testpaths = ["tests"] — where to start looking. Without this key, plain pytest looks for tests from the current directory downward —and if you run it from a subfolder, it looks only there—. With testpaths, you tell pytest: "when they run you without passing a path, start with tests/". It is what makes pytest (with no arguments) collect the whole Reservo suite, without anyone having to remember to write pytest tests. Watch the nuance: testpaths only applies when you do not pass a path on the command line —if you run pytest tests/unit, that argument wins and testpaths is not used—. It is a default of where to look, not an imposition.
python_files = ["test_*.py"] — what files are tests. Pytest, by default, considers a test any file named test_*.py or *_test.py. This key makes that rule explicit —here, only test_*.py—. Setting it in the contract makes clear to the whole team what name a file must have for pytest to pick it up: if someone creates check_pricing.py expecting it to run, the contract explains why it does not. It is documentation as well as configuration: the name pattern stops being a tacit convention and becomes written.
filterwarnings = ["error"] — what to do with warnings. By default, a warning is a notice that accumulates at the end and is easily ignored —you saw it with the PytestUnknownMarkWarning of lesson 2—. filterwarnings = ["error"] changes that policy: it turns every warning into an error. It is a decision of rigor —"in this project, a warning is not optional; either you fix it or the suite does not pass"—, and we will see it in action below, because it has a nice interaction with the markers of lesson 4.
markers = [...] — the registered vocabulary. You already know it from lesson 4: the list of valid markers, each with its description. Here just note that it lives next to the others, as one more option of the contract. The config file is not "the place for the markers"; it is the place for all the configuration, and the markers are one part.
Worked example: the header that proves the config applied
Writing the file is not enough; you have to know how to read the proof that pytest loaded it. That proof is in the header —the first lines of each run—. Let us run the whole suite without passing any path:
python3 -m pytest
What to expect. On my machine (Python 3.14.0, pytest 9.1.1), with the pyproject.toml above:
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
rootdir: /path/to/reservo
configfile: pyproject.toml
testpaths: tests
collected 13 items
...
============================== 13 passed in 1.22s ==============================
Read the header line by line, because each one confirms something of the contract. rootdir: /path/to/reservo is the root pytest computed for the project —the directory from which it interprets everything, normally where the config file lives—. configfile: pyproject.toml is the direct proof: pytest found and loaded your pyproject.toml. If this line said something else —or did not appear—, your config would not be applying, and you would know something is wrong. testpaths: tests confirms the testpaths key was read: that is why collected 13 items without having written tests in the command —pytest started looking in tests/ because the contract told it to—. Three header lines, three confirmations that the file governs the run. This header is your receipt: every time you doubt whether the config applied, look at it.
And notice the result: 13 passed, without a single warning line. Compared with lesson 2 —where the same suite gave 13 passed, 9 warnings—, here the warnings disappeared because we registered the markers. The contract cleaned the output: the suite passes green, without noise.
How pytest finds its file
A practical detail that saves hours of confusion: how does pytest know which file to read, if there are several possible formats? Pytest accepts the configuration in several files —pyproject.toml (under [tool.pytest.ini_options]), pytest.ini, tox.ini (under [pytest]), setup.cfg (under [tool:pytest])—, and it looks for them going up from the directory where you ran it until it finds the first one that has pytest config. The one it finds determines the rootdir and appears in the header as configfile.
For Reservo we chose pyproject.toml because it is the standard file of a modern Python project —the same where the dependencies and the packaging would live—, so the test config sits next to the rest of the project config. But the classic dedicated format, pytest.ini, is equally valid and sometimes clearer because it does not share a file with anything else. The same Reservo contract would look like this in pytest.ini:
# pytest.ini — the same contract, in the dedicated format
[pytest]
testpaths = tests
markers =
smoke: the critical happy path; run before deploying.
slow: the test takes long; excluded with -m 'not slow' while developing.
integration: assembles several real pieces of Reservo together.
Notice the syntax differences, because they are easy to confuse. In pyproject.toml it is TOML: the section is called [tool.pytest.ini_options], the values are lists in brackets with quotes (["tests"]), and the strings carry quotes. In pytest.ini it is INI format: the section is called [pytest], testpaths is a simple value with no brackets or quotes (tests), and markers is a list where each element goes on its own indented line. The content is the same contract; only the file's grammar changes. If you run the suite with this pytest.ini instead of the pyproject.toml, the header confirms it:
configfile: pytest.ini
testpaths: tests
configfile: pytest.ini —pytest loaded the other file—, and testpaths: tests the same as before. The lesson of the header repeats: it always tells you which file won. And a useful warning: if you have two config files at once (a pyproject.toml and a pytest.ini), pytest does not combine them —it chooses one according to its precedence order—, so having two is a classic source of "I changed the config and nothing happened". Keep only one, and use the header to verify which it is.
filterwarnings with teeth: when a warning kills the run
Let us go back to filterwarnings = ["error"], because it connects with lesson 4 in a way worth seeing executed. With the three markers registered, the suite passes clean —there are no warnings to turn into errors—. But suppose someone adds a test with an unregistered marker, say @pytest.mark.wip:
# tests/unit/test_overlaps.py — someone adds an unregistered marker
import pytest
@pytest.mark.wip
def test_nested_interval_overlaps():
assert overlaps(_at(9), _at(12), _at(10), _at(11)) is True
Without filterwarnings, that @pytest.mark.wip would produce only the usual PytestUnknownMarkWarning —an ignorable notice—. But with filterwarnings = ["error"] in the contract, that warning becomes an error and stops the run:
python3 -m pytest tests/unit/test_overlaps.py
What to expect:
collected 0 items / 1 error
==================================== ERRORS ====================================
_________________ ERROR collecting tests/unit/test_overlaps.py _________________
tests/unit/test_overlaps.py:16: in <module>
@pytest.mark.wip
^^^^^^^^^^^^^^^
.../_pytest/mark/structures.py:628: in __getattr__
warnings.warn(
E pytest.PytestUnknownMarkWarning: Unknown pytest.mark.wip - is this a typo? ...
=========================== short test summary info ============================
ERROR tests/unit/test_overlaps.py - pytest.PytestUnknownMarkWarning: Unknown ...
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
=============================== 1 error in 0.06s ===============================
Look at what happened: the PytestUnknownMarkWarning —which is normally a notice— is now an ERROR that interrupts the collection (Interrupted: 1 error during collection). filterwarnings = ["error"] gave the warning teeth: what before accumulated at the end and was ignored, now kills the run. Notice that this is a second path to the same destination of lesson 4 —catching an unregistered marker—, but by a different route: lesson 4 used --strict-markers (which rejects markers outside the catalog); here it is filterwarnings = ["error"] (which turns the warning of the unknown marker into an error). The two tools can be used together, and in a serious project they are: --strict-markers for the markers, filterwarnings = ["error"] for all the warnings (markers, deprecations, and any other), so no notice is left unresolved.
Common mistakes
Writing the config and not verifying it loaded (of blind faith). What happens: someone creates the pyproject.toml with [tool.pytest.ini_options], runs the suite, sees it pass, and assumes the config applied —when in reality pytest ignored it because of a syntax error, a misspelled section name, or because they ran it from another folder—. Why it happens: a suite that passes looks the same with config or without config; green does not prove the contract was read. How to detect it: look at the header. If configfile: does not appear, or points at a file that is not yours, or testpaths: does not reflect what you wrote, your config is not applying. How to fix it: use the header as a receipt on every doubt —configfile: pyproject.toml and testpaths: tests are the proof that pytest loaded your file—. Do not trust that "it should be applying"; verify it.
Putting the config in the wrong section of the pyproject.toml (of section). What happens: someone writes the pytest options under [tool.pytest] or [pytest] inside the pyproject.toml, instead of [tool.pytest.ini_options], and pytest does not read them —the suite runs with the default config and no one understands why testpaths does not work—. Why it happens: the name [tool.pytest.ini_options] is long and unintuitive (why ini_options in a TOML file?), so it is easy to shorten it wrong. How to detect it: if you wrote options in pyproject.toml and the header does not show configfile: pyproject.toml, pytest did not find valid config there —probably because of the section—. How to fix it: in pyproject.toml, the section is exactly [tool.pytest.ini_options] (the ini_options is historical, not an error). In pytest.ini it is [pytest]; in tox.ini also [pytest]. Each file has its section name, and getting it wrong makes pytest ignore the config silently.
Having two config files competing (of duplication). What happens: the project has an old pytest.ini and someone adds config to pyproject.toml, or the reverse; the changes "have no effect" because pytest is reading the other file. Why it happens: pytest does not combine config files —it chooses one according to its precedence order—, so the one that does not win ends up as a phantom file that seems to configure but configures nothing. How to detect it: if you edit an option and the suite does not change, look at the header: configfile: tells you which file is winning, and if it is not the one you edited, there is the problem. How to fix it: have a single config file in the project. If you migrate from pytest.ini to pyproject.toml, delete the pytest.ini; do not leave the two "just in case", because one of them will lie.
Exercises
Exercise 1 — Read the header as a receipt. A coworker swears they configured testpaths = ["tests"] but says that plain pytest "finds no test". They send you the header of their run:
platform linux -- Python 3.14.0, pytest-9.1.1
rootdir: /home/dev/reservo
collected 0 items
Answer: (a) What line is missing in this header, and what does its absence tell you? (b) Why is collected 0 items consistent with that absence? (c) Name two plausible causes of their config not loading. (d) What should appear in the header when they fix it?
See solution
- (a) The
configfile:line is missing (and thetestpaths:one). Its absence says that pytest found no configuration file —it is not loading theirpyproject.tomlor any other—. A healthy header with config would showconfigfile: pyproject.toml; that it does not appear is the sign that the contract is not being read. - (b) Because without
configfile, there is notestpaths. If pytest did not load the config, it does not knowtestpaths = ["tests"], so it looks for tests from the current directory with the default rules. If they ran it from a folder with no tests (or the config that would settestpathswas not read), it collects zero. The0 itemsis the symptom thattestpathsnever applied. - (c) Two plausible causes: (1) the config is in the wrong section —they wrote
[tool.pytest]or[pytest]in thepyproject.tomlinstead of[tool.pytest.ini_options], so pytest does not recognize it—; (2) they are running pytest from a directory where there is nopyproject.tomlupward —the file exists but not on the path pytest walks to find it—. (Also: a TOML syntax error that invalidates the file.) - (d) When they fix it, the header should show
configfile: pyproject.tomlandtestpaths: tests, followed bycollected 13 items. Those three lines together are the proof that the config loaded andtestpathsworked.
The lesson: the header is not decorative, it is diagnostic. The absence of configfile: is as informative as its presence —it tells you the contract is not being read—.
Exercise 2 — Translate between formats. You have this fragment of pyproject.toml and you need to express exactly the same in pytest.ini (because the project is going to migrate to the dedicated format). Write it.
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
markers = [
"smoke: the critical happy path.",
"slow: the test takes long.",
]
See solution
In pytest.ini (INI format), the same contract is written like this:
[pytest]
testpaths = tests
python_files = test_*.py
markers =
smoke: the critical happy path.
slow: the test takes long.
The differences you had to apply: (1) the section changes from [tool.pytest.ini_options] to [pytest]; (2) the simple values lose the brackets and the quotes (["tests"] → tests, ["test_*.py"] → test_*.py); (3) the markers list stops being a TOML array with quotes and commas, and becomes a multiline value where each marker goes on its own indented line, without quotes or commas. The content —what tests, what file pattern, what markers— is identical; only the file's grammar changed. If after the migration you run the suite, the header should say configfile: pytest.ini (and no longer pyproject.toml), confirming that pytest changed file.
A reminder: do not leave the two files at once. If you migrate to pytest.ini, delete the [tool.pytest.ini_options] section from the pyproject.toml (or the whole file if it had nothing else), or you will have two configs competing.
Exercise 3 — Design the minimal contract of a new suite. You join a project (not Reservo) whose suite lives in a test/ folder (singular), uses files named *_test.py (the suffix, not the prefix), has two markers smoke and slow, and the team wants any warning to break the run. Write the complete [tool.pytest.ini_options] that sets this contract, and explain in one phrase what line covers each requirement.
See solution
[tool.pytest.ini_options]
testpaths = ["test"]
python_files = ["*_test.py"]
filterwarnings = ["error"]
markers = [
"smoke: quick check of the critical path.",
"slow: the test takes long; excluded with -m 'not slow'.",
]
What each line covers:
testpaths = ["test"]→ the tests live intest/(singular), so plainpytestmust start looking there, not in the defaulttests/this project does not use.python_files = ["*_test.py"]→ the files use the suffix_test.py, not the prefixtest_. Without this line, pytest would pick uptest_*.pyby default and also*_test.py, but setting it explicit documents the project's real convention and avoids ambiguity.filterwarnings = ["error"]→ any warning becomes an error, meeting "any warning breaks the run".markers = [...]→ registerssmokeandslow(with description), the project's vocabulary; and along the way silences their unknown-marker warnings.
What you practiced is reading process requirements ("the tests are in test/", "warnings should break") and translating them to concrete contract keys. That file is the first thing you write when arriving at a project: it sets, in a single place, the answers to "where are the tests, what files count, what markers there are, what is done with the warnings?".
Summary and next step
In this lesson you opened the pytest configuration file completely and understood it as the framework's contract: the only place that defines, for the whole team, how the suite runs. You saw the options that live in [tool.pytest.ini_options] —testpaths (where to start looking), python_files (what files are tests), filterwarnings (what is done with the warnings) and markers (the registered vocabulary of lesson 4)—, each one setting a decision that without the file was left to the chance of who ran the command. You learned how pytest finds its config among several formats (pyproject.toml with [tool.pytest.ini_options], pytest.ini with [pytest], and others), why it is convenient to have only one, and —most practically— how to read the header as a receipt: configfile: pyproject.toml proves which file was loaded, and testpaths: tests proves the key applied. And you saw filterwarnings = ["error"] with teeth, turning the warning of an unregistered marker into an error that interrupts the run —a second path to the same rigor of lesson 4—.
Before moving on you should be able to: name four options of the config file and what each one sets; read a header and say whether the config loaded (and which file won); and translate a contract between pyproject.toml and pytest.ini.
What comes next is the piece that makes the contract apply without typing it. So far, options like --strict-markers had to be written on the command line every time. In lesson 6 you will meet addopts: the options pytest applies on every run, as if you had always typed them. You are going to put -ra --strict-markers there so plain pytest already runs as the team agreed —with the strict check active without anyone remembering it—, and you are going to see the important case of -m: how addopts and the command-line arguments compose, and why a -m you type wins over the -m in addopts. It is the step that turns the contract from "a file you have to know how to invoke" into "the suite already runs right on its own".
Resources
- pytest — Configuration file formats — the official reference of where the config lives (
pyproject.toml,pytest.ini,tox.ini,setup.cfg), how pytest finds it and what therootdiris; the basis of this lesson. - pytest — Configuration options reference — the complete catalog of options (
testpaths,python_files,filterwarnings,markersand many more) with the exact syntax of each. - pytest — How to capture warnings: controlling warnings — how
filterwarningsworks and what["error"]means; the detail behind the warning we turned into an error.