Configuration and warnings — the rules in one file
Stop typing the same flags on every run: put the project's rules in pyproject.toml or pytest.ini — testpaths, addopts, pythonpath, markers, xfail_strict, log_cli — and test and control warnings with pytest.warns, deprecated_call and filterwarnings.
- 1Encounter
- 2Understand
- 3Worked
- 4Predict
- 5Apply
- 6Stretch
The problem we are solving
The shop project now has the shape most real projects share: code in src/, tests in tests/.
shop/
├── pyproject.toml
├── src/
│ └── shop/
│ ├── __init__.py
│ └── pricing.py
└── tests/
└── test_pricing.pyYou have learned to run it properly, and "properly" has become a long line:
PYTHONPATH=src pytest -ra --strict-markers testsA teammate clones the repository and types what anyone would type:
$ pytest
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/shop
collected 0 items / 1 error
==================================== ERRORS ====================================
____________________ ERROR collecting tests/test_pricing.py ____________________
ImportError while importing test module '/home/you/shop/tests/test_pricing.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_pricing.py:1: in <module>
from shop.pricing import line_total
E ModuleNotFoundError: No module named 'shop'
=========================== short test summary info ============================
ERROR tests/test_pricing.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
=============================== 1 error in 0.01s ===============================Nothing is wrong with the code. What is wrong is that the way to run the tests lives in your head and your shell history instead of in the project. CI will run a third variation, and three people will see three results from one commit.
Those flags are not your preference; they are the project's rules. This chapter moves them into a file pytest reads every time — and then uses the same file to settle a second, quieter problem: warnings that scroll past and are never read.
By the end of this chapter you can
- Put pytest settings in
pyproject.toml(orpytest.ini) and confirm from the header which file was used - Choose
testpaths,addopts,pythonpath,minversion,markersandxfail_strictdeliberately, and say why - Turn on live logging with
log_cliwhen you need to watch a run - Assert that code warns with
pytest.warnsandpytest.deprecated_call, usingmatchand the recorded list - Control warnings with
filterwarningsin config, as a mark and with-W— and make them errors in CI
Prerequisites: markers, skip and xfail.
Before you write the test
This chapter writes fewer tests than usual and more rules about how tests run. The thinking that comes first is the same: decide what is promised, then plan the cases that check the promise.
What is promised. Two kinds of promise are in play.
- How the suite runs. Anyone who types plain
pytestgets the same tests, the same import path and the same strictness as CI. A misspelt marker stops the run. Anxfailthat starts passing is reported, not hidden. - What the code says besides its return value.
apply_discount(total, percent)returns a number, and forpercent > 50it also emits aUserWarningsaying the discount "looks unusually large".total_with_vat()still works but emits aDeprecationWarningpointing atline_total(). A warning is output; when it is part of the contract, it gets tested like output.
What must be in place. A virtual environment with pytest 9 (pytest --version), the code importable from the tests (that is pythonpath below), and one config file at the project root. No extra plugins.
The plan for the warning behaviour:
| case | input | expected | | --- | --- | --- | | happy path | apply_discount(200.0, 10) | 180.0, no warning | | boundary, still quiet | apply_discount(200.0, 50) | 100.0, no warning | | just past the boundary | apply_discount(200.0, 60) | 80.0 and one UserWarning mentioning "unusually large" | | deprecated name | total_with_vat(100.0, 1) | 115.0 and a DeprecationWarning mentioning line_total | | a library's own deprecation | anything that triggers it | does not fail our suite |
For the configuration itself the "test" is a run: plain pytest from the root and from inside tests/ must both collect only tests/ and pass, and a misspelt marker must stop the run.
What not to test. Not that Python's warnings module works, nor that pytest reads its config — that is someone else's tested code. Not the whole text of a warning: match the phrase a reader acts on ("unusually large"), so rewording the rest breaks nothing. And not a third-party library's deprecations — those are filtered, which is a configuration decision, not a test.
The boundary row is the one people forget, because a missing warning makes no noise. By the end of the chapter one config line turns it into a check you get for free.
One file, read before anything else
pytest reads its settings before it collects a single test. The usual home is the pyproject.toml the project already has, in a table called [tool.pytest.ini_options]:
[project]
name = "shop"
version = "0.1.0"
[tool.pytest.ini_options]
pythonpath = ["src"]pythonpath lists directories, relative to the project root, that pytest adds to sys.path before importing tests. The code under test is ordinary:
def line_total(price, quantity):
if quantity < 1:
raise ValueError("quantity must be at least 1")
return round(price * quantity * 1.15, 2)$ PYTHONPATH=src python -c "from shop.pricing import line_total; print(line_total(15.0, 3))"
51.75
$ 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
collected 2 items
tests/test_pricing.py .. [100%]
============================== 2 passed in 0.01s ===============================Read the header. configfile: pyproject.toml is pytest telling you which file it used. Whenever settings seem to be ignored, check this line first.
Why not fix the import inside the test file? It is the obvious wrong way, and it works — sometimes:
import sys
sys.path.insert(0, "src") # relative to wherever pytest was started
from shop.pricing import line_total
def test_three_items():
assert line_total(15.0, 3) == 51.75$ pytest -q
. [100%]
1 passed in 0.01s
$ cd tests && pytest -q
E ModuleNotFoundError: No module named 'shop'
=========================== short test summary info ============================
ERROR test_pricing.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.01s"src" there is relative to the current directory, so the test passes or breaks depending on where you stand. pythonpath in the config is relative to the project root, which does not move. Import set-up belongs to the project, so it goes in the project's file once, not at the top of every test module.
Which file wins, and where the root is
pytest accepts several files. In each directory it checks, in this order: pytest.toml, .pytest.toml, pytest.ini, .pytest.ini, pyproject.toml, tox.ini, setup.cfg. It starts at the paths on the command line (or the current directory) and walks upward; the first file that actually contains a pytest section wins. A pyproject.toml without a pytest table does not count.
That directory becomes the rootdir — the header's rootdir: line — and relative paths in the config are relative to it. So standing inside tests/ still finds the root:
$ cd tests && 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
collected 2 itemsThe same settings in pytest.ini use INI syntax, a [pytest] section, and no quotes or brackets:
[pytest]
pythonpath = src
testpaths = tests
addopts = -ra --strict-markers
markers =
slow: takes more than a second; deselect with -m 'not slow'Pick one file. If both exist, pytest.ini wins completely — nothing is merged — and the header says so. Here a two-line pytest.ini (minversion = 9.0) was added beside the pyproject.toml:
$ pytest
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/shop
configfile: pytest.ini (WARNING: ignoring pytest config in pyproject.toml!)
collected 0 items / 1 error…and the ModuleNotFoundError is back, because pythonpath lived in the file that is now ignored.
testpaths — where to look when you say nothing
Somebody left a scratch file, notes/test_scratch.py, trying out rounding. Plain pytest collects everything under the root, so it runs too:
$ pytest -q --tb=no
F.. [100%]
=========================== short test summary info ============================
FAILED notes/test_scratch.py::test_try_rounding - assert 2.67 == 2.68
1 failed, 2 passed in 0.01stestpaths says where the real suite lives:
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]$ pytest -q
.. [100%]
2 passed in 0.01sIn the full header you would now also see testpaths: tests. It is a default, not a fence: it applies only when you run from the root without naming paths, so pytest notes still runs the scratch file. On a large repository it also speeds up collection, because pytest stops walking docs/, build/ or a virtual environment folder.
addopts and markers — rules everybody gets
addopts is text pytest puts in front of whatever you type. The project's two rules from the opening go here:
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]
addopts = "-ra --strict-markers"-ra prints a short summary line for every test that did not plainly pass — skipped, xfailed, xpassed, failed — so a skip can never go unnoticed. --strict-markers makes an unregistered marker an error. A new file, tests/test_discounts.py, has a test marked @pytest.mark.slow (and one skipped off Windows), and the marker is not registered yet:
$ pytest -q
==================================== ERRORS ====================================
___________________ ERROR collecting tests/test_discounts.py ___________________
'slow' not found in `markers` configuration option
=========================== short test summary info ============================
ERROR tests/test_discounts.py - Failed: 'slow' not found in `markers` configu...
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.01sThat is the point of the flag. Without it, a typo such as @pytest.mark.slwo earns only a PytestUnknownMarkWarning in the summary, the run stays green, and -m "not slow" keeps running the test you meant to exclude. Register the marker, one line each, with a description that pytest --markers will show:
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]
addopts = "-ra --strict-markers"
markers = [
"slow: takes more than a second; deselect with -m 'not slow'",
]$ pytest -q
.s.. [100%]
=========================== short test summary info ============================
SKIPPED [1] tests/test_discounts.py:14: Windows rounding check
3 passed, 1 skipped in 0.01s
$ pytest --markers
@pytest.mark.slow: takes more than a second; deselect with -m 'not slow'The SKIPPED line is -ra at work: a skip now explains itself on every run.
What does not belong in addopts. The wrong way is addopts = "-v -x --pdb". Those are your habits for today's debugging; in the shared file they are forced on everyone, CI included — where --pdb waits forever for a keyboard nobody is at. Put only the project's rules in addopts. What you type is added after it, so pytest -x still works for you alone, and -o overrides any setting for one run:
$ pytest -q -o addopts=""
.s.. [100%]
3 passed, 1 skipped in 0.01sNo skip line this time, because -ra was dropped for that run.
minversion and xfail_strict
minversion stops the run at once when someone's pytest is too old for the file. Asking for a version that does not exist shows the message:
$ pytest
ERROR: /home/you/mv/pytest.ini: 'minversion' requires pytest-10.0, actual pytest-9.1.1'A clear error at start-up beats an older pytest half-understanding the settings.
xfail_strict deals with last chapter's loose end: an xfail that starts passing. Here a bug was marked as expected to fail, and since then someone fixed it:
import pytest
def refund(amount):
return round(amount * 0.9, 2)
@pytest.mark.xfail(reason="bug #41: refunds lose a cent")
def test_refund_keeps_cents():
assert refund(10.0) == 9.0$ pytest -q
X [100%]
1 xpassed in 0.01sA green run, an X nobody reads, and a marker that now lies. With xfail_strict = true in the config:
$ pytest -q
F [100%]
=================================== FAILURES ===================================
___________________________ test_refund_keeps_cents ____________________________
[XPASS(strict)] bug #41: refunds lose a cent
=========================== short test summary info ============================
FAILED test_refunds.py::test_refund_keeps_cents - [XPASS(strict)] bug #41: re...
1 failed in 0.01sThe good news now arrives as a failure that tells you to remove the marker — which is why it is worth setting for the whole project rather than test by test. pytest 9.1 also accepts the newer name strict_xfail, and a single strict = true that switches on every strictness option at once (markers and xfail included); xfail_strict keeps working.
Live logs: log_cli
By default pytest captures log records and shows them only for failing tests. To watch a passing run tell its story, turn on live logging:
[tool.pytest.ini_options]
log_cli = true
log_cli_level = "INFO"Each test's INFO and higher records are then printed under a live log call heading as it runs; the complete example below shows it. Live logs are noisy, so many projects keep only log_cli_level in the file and switch the output on when needed with pytest -o log_cli=true.
pytest 9's native table: [tool.pytest]
[tool.pytest.ini_options] has an odd name for a reason: it is INI settings sitting in a TOML file, so every value is turned into text. pytest 9 adds a native table, [tool.pytest], where values keep their TOML types — real lists, real booleans:
[tool.pytest]
minversion = "9.0"
pythonpath = ["src"]
testpaths = ["tests"]
addopts = ["-ra", "--strict-markers"]
markers = [
"slow: takes more than a second; deselect with -m 'not slow'",
]
strict_xfail = true$ pytest -q
.s.. [100%]
=========================== short test summary info ============================
SKIPPED [1] tests/test_discounts.py:14: Windows rounding check
3 passed, 1 skipped in 0.01saddopts is now a list. Two rules: use one table or the other, never both in one file (pytest refuses to start), and remember that pytest before 9 does not read [tool.pytest] at all. This chapter keeps [tool.pytest.ini_options] because it works on every pytest you are likely to meet; on a project that only runs pytest 9, the native table is the recommended form. pytest 9 also reads a standalone pytest.toml with a [pytest] table in the same native style.
The cache, briefly
After a run there is a .pytest_cache/ folder at the root. It remembers which tests failed last time, which is what powers --lf (last failed) and --ff (failed first). Two flags are worth knowing. pytest --cache-clear empties it before the run — useful when --lf keeps choosing tests that no longer exist. pytest -p no:cacheprovider switches the cache plugin off entirely, so nothing is written, which suits a read-only checkout. The cost is that the plugin's options disappear with it:
$ pytest -q -p no:cacheprovider --lf
ERROR: usage: pytest [options] [file_or_dir] [file_or_dir] [...]
pytest: error: unrecognized arguments: --lf
inifile: /home/you/mv/pytest.ini
rootdir: /home/you/mvWarnings: the section at the bottom of the run
A warning is a message that something is not yet wrong. Python prints it and carries on:
import warnings
def line_total(price, quantity):
return round(price * quantity * 1.15, 2)
def total_with_vat(price, quantity):
warnings.warn(
"total_with_vat() is deprecated; use line_total()",
DeprecationWarning,
stacklevel=2,
)
return line_total(price, quantity)
def apply_discount(total, percent):
if percent > 50:
warnings.warn(f"discount of {percent}% looks unusually large", UserWarning)
return round(total * (100 - percent) / 100, 2)$ python -c "from pricing import apply_discount; print(apply_discount(200.0, 60))"
/home/you/wn/pricing.py:19: UserWarning: discount of 60% looks unusually large
warnings.warn(f"discount of {percent}% looks unusually large", UserWarning)
80.0stacklevel=2 makes the deprecation point at the caller's line, the line someone has to change. pytest catches warnings raised during tests and gathers them into one section at the end:
from pricing import total_with_vat
def test_old_name_still_works():
assert total_with_vat(100.0, 1) == 115.0$ pytest -q
. [100%]
=============================== warnings summary ===============================
test_old_api.py::test_old_name_still_works
/home/you/wn/test_old_api.py:5: DeprecationWarning: total_with_vat() is deprecated; use line_total()
assert total_with_vat(100.0, 1) == 115.0
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
1 passed, 1 warning in 0.01sThe test passed, and the last line says 1 passed, 1 warning. In a big suite this section grows to dozens of entries, everyone learns to scroll past it, and one day the deprecated function is removed and the "passing" suite breaks. There are two things you can do with a warning: expect it, when it is part of the behaviour, or refuse it, when it is not.
pytest.warns — expecting a warning
When a warning is part of the contract — the plan's "just past the boundary" row — test it the way you test an exception with pytest.raises:
import pytest
from pricing import apply_discount
def test_large_discount_warns():
with pytest.warns(UserWarning, match="unusually large"):
result = apply_discount(200.0, 60)
assert result == 80.0
def test_small_discount_is_quiet():
assert apply_discount(200.0, 10) == 180.0$ pytest -q test_discount.py
.. [100%]
2 passed in 0.01sThe block passes when at least one UserWarning (or subclass) is emitted inside it and its message matches match, a regular expression searched in the text, as with pytest.raises. Both halves fail loudly. Here the code is quiet at 50, and the second test looks for the wrong words:
import pytest
from pricing import apply_discount
def test_fifty_percent_warns():
with pytest.warns(UserWarning):
apply_discount(200.0, 50)
def test_wrong_message():
with pytest.warns(UserWarning, match="too large"):
apply_discount(200.0, 60)$ pytest -q test_discount_bad.py
FF [100%]
=================================== FAILURES ===================================
___________________________ test_fifty_percent_warns ___________________________
def test_fifty_percent_warns():
> with pytest.warns(UserWarning):
E Failed: DID NOT WARN. No warnings of type (<class 'UserWarning'>,) were emitted.
E Emitted warnings: [].
test_discount_bad.py:7: Failed
______________________________ test_wrong_message ______________________________
def test_wrong_message():
> with pytest.warns(UserWarning, match="too large"):
E Failed: Regex pattern did not match any of the 1 warnings emitted.
E Regex: 'too large'
E Emitted warnings: [UserWarning('discount of 60% looks unusually large')].
test_discount_bad.py:12: Failed
=============================== warnings summary ===============================
test_discount_bad.py::test_wrong_message
/home/you/wn/pricing.py:19: UserWarning: discount of 60% looks unusually large
warnings.warn(f"discount of {percent}% looks unusually large", UserWarning)
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
=========================== short test summary info ============================
FAILED test_discount_bad.py::test_fifty_percent_warns - Failed: DID NOT WARN....
FAILED test_discount_bad.py::test_wrong_message - Failed: Regex pattern did n...
2 failed, 1 warning in 0.01sBoth reports list the warnings that were emitted, usually all you need to fix the test. A warning that did not match is not swallowed either; it goes on to the warnings summary.
Why always give match. pytest.warns(UserWarning) alone is the weak form: any UserWarning from anywhere in the block satisfies it — including one from a library you happen to call, for an unrelated reason. The test could keep passing after the warning you care about has gone. A matched phrase pins the test to this warning.
To check how many warnings fired, or what they said, keep the record with as:
import pytest
from pricing import apply_discount
def test_each_large_discount_warns_once():
with pytest.warns(UserWarning) as record:
apply_discount(100.0, 60)
apply_discount(100.0, 10)
apply_discount(100.0, 75)
assert len(record) == 2
print(record[0].message)
print(record[1].category.__name__, record[1].lineno)$ pytest -q -s test_record.py
discount of 60% looks unusually large
UserWarning 19
.
1 passed in 0.01srecord lists every warning caught in the block — two, because the 10% call is quiet. Each entry has .message, .category, .filename and .lineno.
pytest.deprecated_call — a promise to your users
Deprecating a function is a promise: "this still works, and you have been told". pytest.deprecated_call() checks both halves:
import pytest
from pricing import total_with_vat
def test_old_name_still_works():
with pytest.deprecated_call():
assert total_with_vat(100.0, 1) == 115.0$ pytest -q test_old_api.py
. [100%]
1 passed in 0.01sIt is pytest.warns with the categories filled in — DeprecationWarning, PendingDeprecationWarning and FutureWarning — and it takes match too. The entry in the warnings summary is gone: the warning was expected, so it was consumed.
filterwarnings — refusing warnings
Expected warnings are now tests. The rest are noise or early alarms, and the CI habit worth adopting is: every unexpected warning is an error. One line:
[tool.pytest.ini_options]
filterwarnings = [
"error",
]Here is a suite with one clean test, one still calling the deprecated name, and one calling currency.rate() from a library you cannot edit, which emits its own DeprecationWarning:
from currency import rate
from pricing import line_total, total_with_vat
def test_line_total():
assert line_total(100.0, 1) == 115.0
def test_old_name():
assert total_with_vat(100.0, 1) == 115.0
def test_rate():
assert rate("USD") == 1.0$ pytest -q --tb=short
.FF [100%]
=================================== FAILURES ===================================
________________________________ test_old_name _________________________________
test_shop.py:10: in test_old_name
assert total_with_vat(100.0, 1) == 115.0
^^^^^^^^^^^^^^^^^^^^^^^^
pricing.py:9: in total_with_vat
warnings.warn(
E DeprecationWarning: total_with_vat() is deprecated; use line_total()
__________________________________ test_rate ___________________________________
test_shop.py:14: in test_rate
assert rate("USD") == 1.0
^^^^^^^^^^^
currency.py:6: in rate
warnings.warn("rate() will need an API key from v3", DeprecationWarning)
E DeprecationWarning: rate() will need an API key from v3
=========================== short test summary info ============================
FAILED test_shop.py::test_old_name - DeprecationWarning: total_with_vat() is ...
FAILED test_shop.py::test_rate - DeprecationWarning: rate() will need an API ...
2 failed, 1 passed in 0.01sEach warning is now raised as an exception, with a traceback to the line that caused it. Every failure gets a decision instead of blanket silence.
The wrong way is "ignore::DeprecationWarning": one line, everything green, and your own deprecation hidden along with the library's. The right way is to narrow each exception. A filter has the shape action:message:category:module — the same as Python's own -W option — where message matches the start of the warning text and module is the module the warning is attributed to:
[tool.pytest.ini_options]
filterwarnings = [
"error",
"ignore::DeprecationWarning:currency",
]Order matters: later lines take precedence, so error goes first and the exceptions after it. The library's warning is now ignored only when it comes from currency. For one test, the same filter can be attached as a mark — here on test_old_name, the rest of the file unchanged:
import pytest
from pricing import total_with_vat
@pytest.mark.filterwarnings("ignore:total_with_vat:DeprecationWarning")
def test_old_name():
assert total_with_vat(100.0, 1) == 115.0$ pytest -q
... [100%]
3 passed in 0.01sThat mark only permits the warning. If you mean "this must warn", pytest.deprecated_call() is the stronger choice, because it also fails when the warning disappears. Use the mark for warnings you tolerate and warns for warnings you promise.
The same filters can be given for one run with -W, handy for trying a policy before writing it down. On the earlier test_old_api.py with no config at all:
$ pytest -q -W error::DeprecationWarning --tb=no -ra
F [100%]
=========================== short test summary info ============================
FAILED test_old_api.py::test_old_name_still_works - DeprecationWarning: total...
1 failed in 0.01sWhen sources disagree, the most specific wins: a mark on the test beats -W, and -W beats the config file. On the three-test suite, -W overrides the config's ignore for currency, while the marked test keeps its exemption:
$ pytest -q -W error::DeprecationWarning --tb=no -ra
..F [100%]
=========================== short test summary info ============================
FAILED test_shop.py::test_rate - DeprecationWarning: rate() will need an API ...
1 failed, 2 passed in 0.01sOne more thing error buys you. Look back at the plan's boundary row, "no warning at 50". With error in the config, a plain test of apply_discount(200.0, 50) already checks it: if that call ever warned, the warning would be an exception and the test would fail. Quiet cases need no special code.
A complete example
The shop project with every rule in one place. pyproject.toml:
[project]
name = "shop"
version = "0.1.0"
[tool.pytest.ini_options]
minversion = "9.0"
testpaths = ["tests"]
pythonpath = ["src"]
addopts = "-ra --strict-markers"
markers = [
"slow: takes more than a second; deselect with -m 'not slow'",
]
xfail_strict = true
filterwarnings = [
"error",
]
log_cli_level = "INFO"src/shop/pricing.py:
import logging
import warnings
log = logging.getLogger(__name__)
def line_total(price, quantity):
if quantity < 1:
raise ValueError("quantity must be at least 1")
return round(price * quantity * 1.15, 2)
def total_with_vat(price, quantity):
warnings.warn(
"total_with_vat() is deprecated; use line_total()",
DeprecationWarning,
stacklevel=2,
)
return line_total(price, quantity)
def apply_discount(total, percent):
if percent > 50:
warnings.warn(f"discount of {percent}% looks unusually large", UserWarning)
log.info("discount %d%% on %.2f", percent, total)
return round(total * (100 - percent) / 100, 2)$ PYTHONPATH=src python -c "from shop.pricing import apply_discount; print(apply_discount(200.0, 10))"
180.0tests/test_pricing.py — the plan from the start of the chapter, row by row:
import pytest
from shop.pricing import apply_discount, line_total, total_with_vat
def test_line_total():
assert line_total(100.0, 1) == 115.0
def test_old_name_is_deprecated():
with pytest.deprecated_call(match="use line_total"):
assert total_with_vat(100.0, 1) == 115.0
def test_large_discount_warns():
with pytest.warns(UserWarning, match="unusually large") as record:
assert apply_discount(200.0, 60) == 80.0
assert len(record) == 1
def test_fifty_percent_is_quiet():
assert apply_discount(200.0, 50) == 100.0
@pytest.mark.slow
def test_many_lines():
total = sum(line_total(1.0, 1) for _ in range(10_000))
assert round(total, 2) == 11500.0
@pytest.mark.xfail(reason="bug #41: zero quantity should be free, not an error")
def test_zero_quantity():
assert line_total(15.0, 0) == 0.0$ pytest
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/full
configfile: pyproject.toml
testpaths: tests
collected 6 items
tests/test_pricing.py .....x [100%]
=========================== short test summary info ============================
XFAIL tests/test_pricing.py::test_zero_quantity - bug #41: zero quantity should be free, not an error
========================= 5 passed, 1 xfailed in 0.01s =========================
$ pytest -m "not slow" -o log_cli=true
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/full
configfile: pyproject.toml
testpaths: tests
collected 6 items / 1 deselected / 5 selected
tests/test_pricing.py::test_line_total PASSED [ 20%]
tests/test_pricing.py::test_old_name_is_deprecated PASSED [ 40%]
tests/test_pricing.py::test_large_discount_warns
-------------------------------- live log call ---------------------------------
INFO shop.pricing:pricing.py:25 discount 60% on 200.00
PASSED [ 60%]
tests/test_pricing.py::test_fifty_percent_is_quiet
-------------------------------- live log call ---------------------------------
INFO shop.pricing:pricing.py:25 discount 50% on 200.00
PASSED [ 80%]
tests/test_pricing.py::test_zero_quantity XFAIL (bug #41: zero quant...) [100%]
=========================== short test summary info ============================
XFAIL tests/test_pricing.py::test_zero_quantity - bug #41: zero quantity should be free, not an error
================== 4 passed, 1 deselected, 1 xfailed in 0.01s ==================Three things are worth noticing.
First, nothing in either command is a project rule. The rules — where the tests are, what is importable, strict markers, strict xfail, warnings as errors — are all in the file, so the teammate who types bare pytest gets exactly the first run, and so does CI.
Second, there is no warnings summary. Both warnings the code emits are expected, so warns and deprecated_call consumed them; any other warning would have failed a test. A clean run is now actually clean.
Third, test_fifty_percent_is_quiet contains no warning code, yet it guards the boundary: under error, a warning at 50% would fail it.
When it breaks
ModuleNotFoundError: No module named 'shop' during collection The code is not importable from the tests. Add pythonpath = ["src"] to the config, and check the header's configfile: line to be sure that file is the one being read.
configfile: pytest.ini (WARNING: ignoring pytest config in pyproject.toml!) Two config files, and the one you edited lost. Nothing is merged. Delete one.
'slow' not found in markers configuration option --strict-markers (or strict = true) is on, and the marker is unregistered or misspelt. Register it under markers, or fix the spelling.
PytestConfigWarning: Unknown config option: testpath A misspelt setting — testpath for testpaths. pytest only warns, so the setting silently does nothing. With strict_config = true (or strict = true) it becomes ERROR: Unknown config option: testpath and the run stops, which is what you want.
ERROR: ... Cannot use both [tool.pytest] (native TOML types) and [tool.pytest.ini_options] (string-based INI format) simultaneously. pyproject.toml has both tables. Move everything into one.
TypeError: ... config option 'addopts' expects a list for type 'args', got str: '-ra --strict-markers' In the native [tool.pytest] table addopts is a list: addopts = ["-ra", "--strict-markers"]. The string form belongs to [tool.pytest.ini_options].
ERROR: ... 'minversion' requires pytest-10.0, actual pytest-9.1.1' The installed pytest is older than the project needs. Upgrade it inside the virtual environment.
Failed: DID NOT WARN. No warnings of type (<class 'UserWarning'>,) were emitted. Nothing of that category was emitted in the block. Read Emitted warnings: underneath — often the code warns with a different category, or not on this input.
Failed: Regex pattern did not match any of the 1 warnings emitted. Right category, wrong text. match is a regular expression, so (, . and ? need escaping — or wrap the text in re.escape().
AttributeError: module 'builtins' has no attribute 'DeprecatedWarning'. Did you mean: 'DeprecationWarning'? A typo in the category of a filterwarnings line. pytest refuses to start and prints the line it could not parse just above.
Step 4 of 6 — Predict
Check your understanding
This is run with pytest -q -s. What number does the test print?
import warnings
import pytest
def check(n):
if n < 0:
warnings.warn("negative value", UserWarning)
return abs(n)
def test_record():
with pytest.warns(UserWarning) as record:
check(-1)
check(2)
check(-3)
print(len(record))- A3
- B1
- C2
- D0
The project root has both a pytest.ini and a pyproject.toml with [tool.pytest.ini_options]. What happens when you run pytest?
- AThe two files are merged, with `pyproject.toml` filling the gaps
- B`pytest.ini` is used, the `pyproject.toml` settings are ignored, and the header warns about it
- C`pyproject.toml` wins because it is the newer standard
- Dpytest stops with an error about having two config files
The config has addopts = -ra --strict-markers and registers only slow under markers. What happens on pytest -m "not slow"?
import pytest
@pytest.mark.slwo
def test_every_day_of_a_year():
fees = [day * 5 for day in range(366)]
assert fees[-1] == 1825- AThe test is deselected, as intended
- BThe test runs, with a warning in the summary
- CThe test is skipped
- DCollection stops: 'slwo' not found in `markers` configuration option
Answering needs an account
Sign in to check your answers
The questions are above, and working them out in your head is the part that matters. Sign in to see the answers, the explanations and the three-level hints.
Your turn
The library project charges a late fee. src/library/fees.py:
import warnings
PER_DAY = 5
def late_fee(days):
if days < 0:
raise ValueError("days cannot be negative")
if days > 60:
warnings.warn(f"{days} days late looks like a lost book", UserWarning)
return days * PER_DAY
def fine(days):
warnings.warn(
"fine() is deprecated; use late_fee()",
DeprecationWarning,
stacklevel=2,
)
return late_fee(days)$ PYTHONPATH=src python -c "from library.fees import late_fee; print(late_fee(4))"
20- Write the
[tool.pytest.ini_options]table so that plainpytest, from the root or from insidetests/, importslibrary, collects onlytests/, prints-rasummaries, rejects unknown markers, fails on an unexpectedxfailpass and turns every unexpected warning into an error. Register aslowmarker. - Write the test plan first — case, input, expected — covering on-time return, the 60-day boundary, 61 days, a negative number and the deprecated
fine(). - Write
tests/test_fees.pyfrom the plan, with oneslowtest that sweeps every day of a year. - Run
pytest, thenpytest -m "not slow", thencd tests && pytest.
Then two experiments, predicting each result first: replace the deprecated_call test with a plain call to fine(3), and misspell slow as slwo.
Solution
The plan:
| case | input | expected | | --- | --- | --- | | returned on time | late_fee(0) | 0, no warning | | boundary, still quiet | late_fee(60) | 300, no warning | | just past the boundary | late_fee(61) | 305 and one UserWarning mentioning "lost book" | | invalid input | late_fee(-1) | ValueError mentioning "negative" | | deprecated name | fine(3) | 15 and a DeprecationWarning mentioning late_fee | | a whole year (slow) | late_fee(0) … late_fee(365) | last fee 1825, warnings expected |
pyproject.toml:
[project]
name = "library"
version = "0.1.0"
[tool.pytest.ini_options]
minversion = "9.0"
testpaths = ["tests"]
pythonpath = ["src"]
addopts = "-ra --strict-markers"
markers = [
"slow: takes more than a second; deselect with -m 'not slow'",
]
xfail_strict = true
filterwarnings = [
"error",
]tests/test_fees.py:
import pytest
from library.fees import fine, late_fee
def test_returned_on_time():
assert late_fee(0) == 0
def test_sixty_days_is_quiet():
# filterwarnings = error: any warning here would fail this test
assert late_fee(60) == 300
def test_sixty_one_days_warns():
with pytest.warns(UserWarning, match="lost book") as record:
assert late_fee(61) == 305
assert len(record) == 1
def test_negative_days_rejected():
with pytest.raises(ValueError, match="negative"):
late_fee(-1)
def test_fine_is_deprecated():
with pytest.deprecated_call(match="use late_fee"):
assert fine(3) == 15
@pytest.mark.slow
def test_every_day_of_a_year():
with pytest.warns(UserWarning):
fees = [late_fee(day) for day in range(366)]
assert fees[-1] == 1825$ pytest
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/library
configfile: pyproject.toml
testpaths: tests
collected 6 items
tests/test_fees.py ...... [100%]
============================== 6 passed in 0.01s ===============================
$ pytest -m "not slow" -q
..... [100%]
5 passed, 1 deselected in 0.01s
$ cd tests && pytest -q
...... [100%]
6 passed in 0.01sThe two experiments:
$ pytest -q # test_fine_still_works calls fine(3) with no deprecated_call
=========================== short test summary info ============================
FAILED tests/test_fees.py::test_fine_still_works - DeprecationWarning: fine()...
1 failed, 5 passed in 0.01s
$ pytest -q # @pytest.mark.slwo
ERROR tests/test_fees.py - Failed: 'slwo' not found in `markers` configuratio...
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.01sWhy each decision:
pythonpathandtestpathsin the file, not on the command line. That is whycd tests && pytestgives the same six passes: both are relative to the rootdir, which pytest finds by walking up topyproject.toml.- Only rules in
addopts.-raand--strict-markersare policies everyone shares;-vor-xare personal habits and stay on your own command line. filterwarnings = ["error"]with no exceptions. The project has no third-party warnings yet, so there is nothing to excuse. The day one appears, add a narrowignore::...:moduleline, never a blanketignore.- The 60-day test has no warning code. Under
error, any warning at 60 days would fail it, so the config guards the boundary. matchon every check aimed at one message. "lost book" and "use late_fee" are the words a reader acts on; the rest can be reworded freely. The year-long test leavesmatchout on purpose: it is about the fees, and only needs to say warnings are expected there.recordin the 61-day test. It proves one call warns once, not on every internal step.- The experiments. Without
deprecated_call, the deprecation is unexpected, soerrorturns it into a failure — exactly what CI should do when someone calls an old API. The misspelt marker stops collection because--strict-markersis inaddopts. (Here theerrorfilter would also have caught it, since an unknown marker emits aPytestUnknownMarkWarning— but the marker rule should not depend on the warning policy, so both stay.)
Step 6 of 6
Stretch — the chapter quiz
Ten questions from easy to hard. The last ones are difficult on purpose.
Sign in to take the quiz