Skip to main content
Pytest beginner Lesson 4 of 10

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
CharMeaning
.passed
Ffailed
Eerror in a fixture, not in the test body
sskipped
xxfailed — expected to fail, and did
Xxpassed — 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.

Frequently Asked Questions

What is the difference between skip and xfail?
A skipped test is never executed — it is not applicable here. An xfail test runs, is expected to fail, and reports XFAIL when it does. Use skip for missing platforms or dependencies, and xfail for a known bug you intend to fix.
Why does pytest warn about an unknown mark?
Custom marks must be registered in the markers list of your config file. Without registration a typo like pytest.mark.slwo silently applies a mark nothing selects, so pytest raises PytestUnknownMarkWarning to catch it.
How do I run only the tests with a given marker?
Use pytest -m slow to run them, or pytest -m 'not slow' to exclude them. The -m expression accepts and, or, and not, so -m 'integration and not slow' works as written.
What does strict xfail do?
With strict=True a test that unexpectedly passes reports FAILED instead of XPASS, so the build breaks when a known bug gets fixed and reminds you to delete the mark. Set xfail_strict = true in config to make that the default.