Chapter 13

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.

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

The problem we are solving

The shop project now has the shape most real projects share: code in src/, tests in tests/.

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

You have learned to run it properly, and "properly" has become a long line:

text
PYTHONPATH=src pytest -ra --strict-markers tests

A teammate clones the repository and types what anyone would type:

text
$ 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 (or pytest.ini) and confirm from the header which file was used
  • Choose testpaths, addopts, pythonpath, minversion, markers and xfail_strict deliberately, and say why
  • Turn on live logging with log_cli when you need to watch a run
  • Assert that code warns with pytest.warns and pytest.deprecated_call, using match and the recorded list
  • Control warnings with filterwarnings in 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 pytest gets the same tests, the same import path and the same strictness as CI. A misspelt marker stops the run. An xfail that starts passing is reported, not hidden.
  • What the code says besides its return value. apply_discount(total, percent) returns a number, and for percent > 50 it also emits a UserWarning saying the discount "looks unusually large". total_with_vat() still works but emits a DeprecationWarning pointing at line_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]:

toml
[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:

python
def line_total(price, quantity):
    if quantity < 1:
        raise ValueError("quantity must be at least 1")
    return round(price * quantity * 1.15, 2)
text
$ 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:

python
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
text
$ 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:

text
$ 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 items

The same settings in pytest.ini use INI syntax, a [pytest] section, and no quotes or brackets:

ini
[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:

text
$ 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:

text
$ 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.01s

testpaths says where the real suite lives:

toml
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]
text
$ pytest -q
..                                                                       [100%]
2 passed in 0.01s

In 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:

toml
[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:

text
$ 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.01s

That 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:

toml
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]
addopts = "-ra --strict-markers"
markers = [
    "slow: takes more than a second; deselect with -m 'not slow'",
]
text
$ 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:

text
$ pytest -q -o addopts=""
.s..                                                                     [100%]
3 passed, 1 skipped in 0.01s

No 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:

text
$ 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:

python
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
text
$ pytest -q
X                                                                        [100%]
1 xpassed in 0.01s

A green run, an X nobody reads, and a marker that now lies. With xfail_strict = true in the config:

text
$ 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.01s

The 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:

toml
[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:

toml
[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
text
$ 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

addopts 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:

text
$ 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/mv

Warnings: 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:

python
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)
text
$ 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.0

stacklevel=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:

python
from pricing import total_with_vat


def test_old_name_still_works():
    assert total_with_vat(100.0, 1) == 115.0
text
$ 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.01s

The 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:

python
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
text
$ pytest -q test_discount.py
..                                                                       [100%]
2 passed in 0.01s

The 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:

python
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)
text
$ 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.01s

Both 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:

python
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)
text
$ pytest -q -s test_record.py
discount of 60% looks unusually large
UserWarning 19
.
1 passed in 0.01s

record 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:

python
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
text
$ pytest -q test_old_api.py
.                                                                        [100%]
1 passed in 0.01s

It 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:

toml
[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:

python
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
text
$ 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.01s

Each 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:

toml
[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:

python
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
text
$ pytest -q
...                                                                      [100%]
3 passed in 0.01s

That 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:

text
$ 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.01s

When 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:

text
$ 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.01s

One 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:

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:

python
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)
text
$ PYTHONPATH=src python -c "from shop.pricing import apply_discount; print(apply_discount(200.0, 10))"
180.0

tests/test_pricing.py — the plan from the start of the chapter, row by row:

python
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
text
$ 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.