Pytest Markers, Skipping, and xfail
Tag tests with custom markers, select them with -m, skip conditionally, and record a known bug as xfail so it fails the build the day it is fixed.
A mark is a label attached to a test. pytest ships a few with behaviour built in — skip,
skipif, xfail — and lets you invent your own to slice the suite from the command line.
Tagging tests
# test_orders.py
import pytest
from orders import total, submit
def test_total_sums_line_items():
assert total([("A1", 2, 4.50), ("B7", 1, 12.00)]) == 21.00
@pytest.mark.slow
def test_bulk_import_of_10k_orders(tmp_path):
submit_batch(tmp_path / "orders.csv", rows=10_000)
assert count_orders() == 10_000
@pytest.mark.integration
def test_submit_hits_the_payment_api():
assert submit("A1", card="4242424242424242").status == "captured"
$ pytest -q
===================================== warnings summary =====================================
test_orders.py:9
/home/you/shop/test_orders.py:9: PytestUnknownMarkWarning: Unknown pytest.mark.slow - is
this a typo? You can register custom marks to avoid this warning - for details, see
https://docs.pytest.org/en/stable/how-to/mark.html
@pytest.mark.slow
3 passed, 2 warnings in 4.31s
The tests ran, but pytest does not know those marks. That warning exists because
@pytest.mark.slwo is also an unknown mark — it applies happily, and then -m "not slow"
runs your slowest test anyway. Register them:
# pyproject.toml
[tool.pytest.ini_options]
markers = [
"slow: takes more than a second",
"integration: needs a live service",
]
$ pytest -q
3 passed in 4.31s
The text after the colon is the description, and it shows up in the listing:
$ pytest --markers | head -8
@pytest.mark.slow: takes more than a second
@pytest.mark.integration: needs a live service
@pytest.mark.skip(reason=None): skip the given test function with an optional reason.
Selecting by mark
pytest -m slow # only the slow ones
pytest -m "not slow" # everything else
pytest -m "integration and not slow"
$ pytest -m "not slow" -q
.. [100%]
2 passed, 1 deselected in 0.03s
The deselected test was collected and filtered out, not run. This is the standard split for a
fast local loop: pytest -m "not slow and not integration" before every commit, and the full
suite in CI.
A mark on a class applies to every test in it, and pytestmark at module level applies to
every test in the file:
# test_payments.py
import pytest
pytestmark = pytest.mark.integration
def test_capture():
...
def test_refund():
...
$ pytest test_payments.py -m integration -q
.. [100%]
2 passed in 1.84s
Skipping
skip removes a test from the run:
@pytest.mark.skip(reason="rewriting the tax engine, back on Friday")
def test_vat_on_digital_goods():
assert vat("GB", "ebook") == 0.20
$ pytest -q
..s [100%]
2 passed, 1 skipped in 0.02s
The s is the skip. The reason is hidden until you ask for it with -rs:
$ pytest -rs -q
..s [100%]
=================== short test summary info ===================
SKIPPED [1] test_orders.py:22: rewriting the tax engine, back on Friday
2 passed, 1 skipped in 0.02s
Always give a reason. A skip with no reason becomes permanent, because nobody who finds it later knows whether it is safe to delete.
Skipping conditionally
skipif takes a condition evaluated at collection time:
import sys
@pytest.mark.skipif(sys.platform == "win32", reason="uses os.fork")
def test_worker_forks():
assert spawn_worker().pid > 0
@pytest.mark.skipif(sys.version_info < (3, 11), reason="needs tomllib")
def test_reads_toml_config():
assert load_config("app.toml")["name"] == "shop"
$ pytest -rs -q
.s. [100%]
=================== short test summary info ===================
SKIPPED [1] test_platform.py:6: uses os.fork
2 passed, 1 skipped in 0.02s
For a missing library, importorskip is shorter than a skipif on an import attempt:
import pytest
pandas = pytest.importorskip("pandas", minversion="2.0")
def test_frame_round_trip(tmp_path):
df = pandas.DataFrame({"sku": ["A1"], "qty": [2]})
df.to_parquet(tmp_path / "orders.parquet")
assert pandas.read_parquet(tmp_path / "orders.parquet").iloc[0]["qty"] == 2
$ pytest -rs -q
=================== short test summary info ===================
SKIPPED [1] test_frames.py:3: could not import 'pandas': No module named 'pandas'
1 skipped in 0.01s
The whole module is skipped at import, so it does not matter that the file uses pandas at
the top level.
Skipping from inside a test
Sometimes you only learn mid-test that the environment cannot run it:
def test_uploads_to_s3():
if not os.getenv("AWS_ACCESS_KEY_ID"):
pytest.skip("no AWS credentials in this environment")
assert upload("report.csv").ok
$ pytest -rs -q
s [100%]
=================== short test summary info ===================
SKIPPED [1] test_s3.py:6: no AWS credentials in this environment
1 skipped in 0.01s
pytest.skip() raises, so nothing after it runs. Prefer skipif when the condition is known
before the test starts — it is visible in the decorator instead of buried in the body.
Expecting a failure
xfail runs the test and expects it not to pass:
@pytest.mark.xfail(reason="rounds down on .005, issue #412")
def test_half_penny_rounds_up():
assert round_money(2.005) == 2.01
$ pytest -v
test_money.py::test_half_penny_rounds_up XFAIL [100%]
============================ 1 xfailed in 0.02s ============================
XFAIL is not a failure — the suite is green. What you have written down is a bug that is
known, reproducible, and has a test waiting for the fix.
Now fix round_money and run it again:
$ pytest -v
test_money.py::test_half_penny_rounds_up XPASS [100%]
============================ 1 xpassed in 0.02s ============================
XPASS — it passed when it was supposed to fail. That is still green, which is the trap:
the stale mark sits there for a year. Make it strict:
@pytest.mark.xfail(strict=True, reason="rounds down on .005, issue #412")
$ pytest -v
test_money.py::test_half_penny_rounds_up FAILED [100%]
================================= FAILURES =================================
[XPASS(strict)] rounds down on .005, issue #412
=================== short test summary info ================================
FAILED test_money.py::test_half_penny_rounds_up
============================ 1 failed in 0.02s =============================
The build breaks on the day the bug is fixed and tells you to delete the mark. Turn it on everywhere:
[tool.pytest.ini_options]
xfail_strict = true
Narrow the expectation with raises= so an unrelated crash is still a failure:
@pytest.mark.xfail(raises=NotImplementedError, reason="refunds land in v2")
def test_partial_refund():
assert refund("A1", amount=5.00).status == "refunded"
If the code raises TypeError instead, the test fails properly rather than being quietly
absorbed by the mark.
Reading the summary
$ pytest -q
..sx.X.F [100%]
1 failed, 4 passed, 1 skipped, 1 xfailed, 1 xpassed in 0.31s
| Char | Meaning |
|---|---|
. | passed |
F | failed |
E | error in a fixture, not in the test body |
s | skipped |
x | xfailed — expected to fail, and did |
X | xpassed — expected to fail, but passed |
-ra prints the reasons for everything that was not a plain pass, which is the flag worth
putting in addopts permanently.
Practice
1. Register a smoke marker, tag two tests, and run only those.
[tool.pytest.ini_options]
markers = ["smoke: the five-second sanity check"]
$ pytest -m smoke -q
.. [100%]
2 passed, 6 deselected in 0.04s
A smoke subset is the usual first use of custom marks: run it on every push, run everything on merge.
2. Apply @pytest.mark.slwo (a typo) without registering it, then with registration.
$ pytest -m "not slow" -q
..... [100%]
5 passed in 6.02s
Six seconds — the “slow” test ran, because its mark was misspelled and matched nothing. With
the markers list configured, pytest warns on the typo instead of silently ignoring it.
3. Write a skipif on an environment variable and run it both ways.
@pytest.mark.skipif(not os.getenv("RUN_DB_TESTS"), reason="set RUN_DB_TESTS=1 to enable")
def test_migration_applies():
assert current_revision() == "a41f9c"
$ pytest -rs -q
s [100%]
SKIPPED [1] test_db.py:7: set RUN_DB_TESTS=1 to enable
1 skipped in 0.01s
$ RUN_DB_TESTS=1 pytest -q
. [100%]
1 passed in 0.84s
The reason doubles as the instructions. Anyone who hits the skip knows how to opt in.
4. Mark a passing test xfail(strict=True) and read the failure.
================================= FAILURES =================================
[XPASS(strict)] this should have failed
No traceback, because nothing raised — the failure is the passing. This is exactly what happens when someone fixes a bug and forgets the mark, which is why strict is the setting you want.
Next: where tests live, how pytest finds them, and the import errors that follow.