Resource library

QA How-To

pytest Markers Tutorial: skip, xfail and Custom Markers

Learn pytest markers tutorial examples for skip, xfail, custom tags, strict XPASS checks, parameter rows, and reliable CI selection with runnable Python code.

18 min read | 3,248 words

TL;DR

Use skip for tests that cannot run, xfail for known failures that should still execute, and registered custom markers for selection. Make xfail strict, give every exception a reason, and verify selected counts before trusting a CI gate.

Key Takeaways

  • Use skip only when a test cannot run under a stated condition.
  • Use strict xfail for a narrow, tracked known defect so a fix produces a failing XPASS signal.
  • Register custom marks and enable strict marker validation to catch decorator typos.
  • Use -m expressions for declared groups and inspect deselected counts in CI.
  • Mark one parametrized row with pytest.param when the exception does not apply to the whole test.
  • Report skipped, XFAIL, and XPASS reasons with pytest's short-summary flags.

Pytest markers tutorial: use skip when a test cannot run in the current environment, xfail when a known defect should remain visible, and custom markers when you need to select a meaningful slice of the suite. Markers attach metadata to collected tests; they do not replace clear assertions or repair unstable tests. This guide builds a small shipping calculator suite so every command has a concrete result you can inspect.

If you are new to test discovery and assertions, start with the pytest tutorial for beginners. Here you will move from an unmarked baseline to registered selection tags, conditional skips, strict expected failures, and a CI-friendly command. The same techniques work for API and UI suites, but the local example needs no credentials or network service.

TL;DR

Marker or command What it means Observable result
@pytest.mark.skip(reason=...) Do not execute this test SKIPPED; reason appears with -rs
@pytest.mark.skipif(condition, reason=...) Execute only when the condition is false Platform-dependent pass or skip
@pytest.mark.xfail(reason=..., strict=True) Known failure that should start failing the suite when fixed XFAIL now, XPASS(strict) after a fix
@pytest.mark.smoke User-defined test group Select with -m smoke after registration
pytest.param(..., marks=...) Mark one data row Other rows still execute normally

Use python -m pytest -q -rsx to see skipped and expected-failure reasons. Register every custom name and enable strict marker validation. Keep a ticket or issue reference in each expected-failure reason so it has an owner.

What You Will Build

  • A deterministic shipping_fee function with a known boundary defect at a subtotal of 50.
  • A pytest suite with fast and slow custom groups, plus a marker that carries a tier value.
  • Platform and environment skips that explain exactly why a test did not run.
  • Strict xfail coverage that reports the bug without turning the whole suite red, then detects an unexpected pass.
  • A parametrized boundary table and collection commands you can use in a CI job.

The example intentionally leaves one product defect in place until the final check. That makes the XFAIL result real, rather than relying on a fake assertion. Keep the example in a new empty directory so commands do not collect unrelated tests.

Prerequisites

The commands below were checked with Python 3.12.7 and pytest 9.1.1. Use those exact versions if you need to reproduce the shown output closely; otherwise, compare the commands with your installed pytest release before adopting them in an existing project. The examples use only the Python standard library and pytest. No plugin, database, browser, or live API is required.

Create a virtual environment and confirm the interpreter and runner are paired. On macOS or Linux, run:

mkdir pytest-markers-demo
cd pytest-markers-demo
python3 -m venv .venv
. .venv/bin/activate
python -m pip install pytest
python --version
python -m pytest --version

On Windows, use py -m venv .venv, activate with .venv\Scripts\activate, and run the later commands as python -m pytest from that environment. The output should identify the Python and pytest versions actually installed. Pin the release you have verified in your own dependency lockfile; do not copy a version number without checking that release against your interpreter and plugins. If you prefer a broader introduction to the runner before adding marks, the pytest versus unittest comparison explains the different test organization models.

Step 1: Establish a Passing Baseline for This Pytest Markers Tutorial

Create one small function with three observable rules. Domestic orders over 50 ship free, smaller domestic orders cost 5, and international orders cost 20. The intended rule is that a domestic subtotal of 50 or more ships free. The implementation below deliberately uses > 50; that single boundary defect will give xfail something honest to report later.

Save this as shipping.py in the project root:

def shipping_fee(subtotal: int, region: str) -> int:
    if subtotal < 0:
        raise ValueError("subtotal cannot be negative")
    if region == "domestic":
        return 0 if subtotal > 50 else 5
    if region == "international":
        return 20
    raise ValueError(f"unsupported region: {region}")

Create tests/test_shipping.py:

import pytest

from shipping import shipping_fee


def test_small_domestic_order_pays_postage():
    assert shipping_fee(49, "domestic") == 5


def test_large_domestic_order_ships_free():
    assert shipping_fee(51, "domestic") == 0


def test_international_order_has_flat_fee():
    assert shipping_fee(20, "international") == 20


def test_negative_subtotal_is_rejected():
    with pytest.raises(ValueError, match="subtotal cannot be negative"):
        shipping_fee(-1, "domestic")

Create the directory with mkdir -p tests before saving the test file. These checks avoid the disputed boundary so the initial baseline passes. An ordinary failure at this stage would mean file placement, imports, or the implementation itself is wrong, not a marker issue. Keeping a clean baseline makes later status changes easy to attribute.

Verify: Run python -m pytest -q tests/test_shipping.py. Expect four passes. Then run python -m pytest --collect-only -q tests/test_shipping.py and inspect the four collected node IDs. Collection lists tests without executing their bodies; it is a useful first diagnostic when a marker selection returns zero tests.

Step 2: Register Custom Markers and Reject Typos

Add pyproject.toml at the project root. Pytest reads marker definitions from its configuration, then --strict-markers turns unknown marker names into errors. Registration documents the tags and catches a misspelling such as @pytest.mark.smkoe during collection. A marker registration does not make slow tests skip by itself; selection is a separate decision made by the command you run.

[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = ["--strict-markers"]
markers = [
  "smoke: core shipping rules required before merge",
  "slow: checks reserved for a broader run",
  "tier(level): test execution tier, such as smoke or regression",
]

Now replace tests/test_shipping.py with this marked version. The assertions are unchanged. The slow tag is illustrative: this test is quick locally, but a real project might put an equivalent carrier integration check in a slow group. Keep marker names about execution purpose or environment, not about a team member's name.

import pytest

from shipping import shipping_fee


@pytest.mark.smoke
def test_small_domestic_order_pays_postage():
    assert shipping_fee(49, "domestic") == 5


@pytest.mark.smoke
def test_large_domestic_order_ships_free():
    assert shipping_fee(51, "domestic") == 0


@pytest.mark.slow
def test_international_order_has_flat_fee():
    assert shipping_fee(20, "international") == 20


def test_negative_subtotal_is_rejected():
    with pytest.raises(ValueError, match="subtotal cannot be negative"):
        shipping_fee(-1, "domestic")

Notice that two tests have smoke, one has slow, and the validation test has neither. An unmarked test still runs in an ordinary full-suite command. Strict registration is especially useful in a large suite because an unknown mark otherwise becomes a warning and a misspelled -m query can silently produce the wrong slice of coverage.

Verify: Run python -m pytest --markers and find your three descriptions among pytest's built-in marks. Run python -m pytest -q; expect four passes again. To check typo protection without editing a test file, run python -m pytest -q -m smkoe. With strict markers enabled, pytest should reject the unknown name in that expression. Rerun with -m smoke and expect two passes.

Step 3: Select a Slice With -m and Inspect Collection

The -m option evaluates marker expressions against collected tests. It can combine tags with and, or, and not; quote the entire expression so the shell passes it intact. For this suite, smoke selects the two domestic checks, while not slow selects those checks plus the unmarked validation test. An ordinary run without -m still runs all four.

python -m pytest -q -m smoke
python -m pytest -q -m "not slow"
python -m pytest --collect-only -q -m "smoke and not slow"
python -m pytest -q -m "smoke or slow"

Expect respectively two passes, three passes, two collected node IDs, and three passes. The output also reports deselected tests. Deselecting means pytest collected a test but excluded it from this invocation; it is different from a skip result recorded for a selected test. This distinction matters when reporting coverage: a green smoke job says its selected subset passed, not that the entire suite passed.

Use -k when you want a name-based filter, such as python -m pytest -q -k domestic. Use -m for the explicit marker taxonomy you registered. A -k expression can match test names and other keyword names; it is not a substitute for a documented mark when the group has a stable operational meaning. Keep the CI expression in one obvious place so a new test author knows whether to tag a test for the merge gate. For a broader pipeline design, see the test automation CI/CD guide.

Verify: Compare the selected and deselected counts from the first two commands. In this exact four-test state, -m smoke must report two selected and two deselected; -m "not slow" must report three selected and one deselected. If a count differs, run --collect-only -q without -m and check for extra test files in the directory.

Step 4: Skip Tests for a Real Constraint

A skip says the test cannot or should not run under the current conditions. It does not say the product has a known failing behavior. Put skip on a test when a human has deliberately excluded it, skipif when the platform or feature condition is knowable at collection time, and pytest.skip() inside the test when you must inspect runtime state after setup. Give each skip a reason specific enough that a reviewer can decide whether it is still justified.

Create tests/test_environment.py:

import os
import stat

import pytest

from shipping import shipping_fee


@pytest.mark.skip(reason="live carrier contract needs sandbox credentials")
def test_live_carrier_contract_placeholder():
    assert shipping_fee(51, "domestic") == 0


@pytest.mark.skipif(
    os.name != "posix", reason="permission mode bits require POSIX"
)
def test_receipt_file_mode(tmp_path):
    receipt = tmp_path / "receipt.txt"
    receipt.write_text("paid", encoding="utf-8")
    os.chmod(receipt, 0o600)
    assert stat.S_IMODE(receipt.stat().st_mode) == 0o600


def test_carrier_token_is_present_when_requested():
    token = os.getenv("CARRIER_TOKEN")
    if token is None:
        pytest.skip("set CARRIER_TOKEN to exercise credential wiring")
    assert token.strip(), "CARRIER_TOKEN cannot be blank"

The placeholder uses a local assertion so the file remains runnable if someone removes its skip, but it is not a real carrier contract test. Replace it with an actual sandbox call when credentials and a stable test account exist. Do not use a permanent skip to disguise missing test implementation. The POSIX test uses pytest's tmp_path fixture to avoid a shared file. The token check intentionally validates only credential wiring; a real integration test would use the token in a client and make its own API assertion.

Verify: Run python -m pytest -q -rs tests/test_environment.py. On a POSIX machine with no CARRIER_TOKEN, expect one pass and two skips, with both skip reasons in the short summary. On Windows, expect three skips. Set CARRIER_TOKEN=demo for one local command if you want to see the token check pass; never put a production secret into a tutorial command or a repository file.

Step 5: Track a Known Failure With Strict xfail

Now cover the precise boundary that the implementation gets wrong. xfail still executes the test by default. When the assertion fails as expected, pytest reports XFAIL and the suite can finish successfully. With strict=True, a later pass becomes XPASS(strict) and fails the run. That red signal tells you to remove the marker and close the linked bug after confirming the behavior changed intentionally.

Create tests/test_known_bug.py:

import pytest

from shipping import shipping_fee


@pytest.mark.xfail(
    reason="SHIP-142: subtotal 50 should qualify for free domestic shipping",
    strict=True,
)
def test_domestic_free_shipping_starts_at_50():
    assert shipping_fee(50, "domestic") == 0

SHIP-142 is an illustrative issue identifier, not a real ticket. Replace it with your team's traceable defect ID. The marker is narrow: it covers one failing boundary, not the whole test module. If you tagged every domestic test as expected to fail, a new regression at 49 or 51 could hide among expected failures. Do not use xfail as a generic quarantine for intermittent failures; record and diagnose flakiness separately. The flaky test quarantine in CI guide covers a controlled quarantine workflow.

Verify: Run python -m pytest -q -rx tests/test_known_bug.py. Expect one XFAIL with the SHIP-142 reason and an overall successful command. Then run python -m pytest -q --runxfail tests/test_known_bug.py; expect a normal assertion failure because --runxfail ignores expected-failure handling. The second command is a deliberate negative check and should return a nonzero status. If the first command reports XPASS, inspect whether the function has already been fixed.

Step 6: Mark Individual Parameter Rows

A whole-function marker applies to every parametrized instance. That is too broad when only one data row is affected by a bug. pytest.param(..., marks=...) tags an individual row while preserving the rest of the matrix as ordinary regression checks. Give each row a stable ID so the report names a business case rather than a generated argument tuple.

Create tests/test_boundary_table.py:

import os

import pytest

from shipping import shipping_fee


@pytest.mark.parametrize(
    ("subtotal", "region", "expected"),
    [
        pytest.param(49, "domestic", 5, id="below-free-threshold"),
        pytest.param(
            50,
            "domestic",
            0,
            marks=pytest.mark.xfail(
                reason="SHIP-142: equality boundary is wrong",
                strict=True,
            ),
            id="at-free-threshold",
        ),
        pytest.param(51, "domestic", 0, id="above-free-threshold"),
        pytest.param(
            20,
            "international",
            20,
            marks=pytest.mark.skipif(
                os.getenv("RUN_INTERNATIONAL") != "1",
                reason="international pricing row is opt-in",
            ),
            id="international-opt-in",
        ),
    ],
)
def test_shipping_fee_table(subtotal, region, expected):
    assert shipping_fee(subtotal, region) == expected

Only the equality row is XFAIL. The 49 and 51 rows must pass, so a second defect would be visible even while SHIP-142 remains open. The international row demonstrates a conditional mark on one parameter set. In this local example it is deterministic and safe, but an opt-in flag could represent a costly downstream integration in a real suite. A parameter mark does not mutate the function or its other rows.

Verify: Run python -m pytest -q -rxs tests/test_boundary_table.py. Without RUN_INTERNATIONAL, expect two passes, one XFAIL, and one skip. Run RUN_INTERNATIONAL=1 python -m pytest -q tests/test_boundary_table.py on a POSIX shell to see three passes and one XFAIL. On Windows, set the variable using the syntax of your shell, then run the same pytest command. The IDs in the verbose output from python -m pytest -v tests/test_boundary_table.py should identify all four cases.

Step 7: Pass Custom Marker Arguments and Query Them

Custom marks can carry structured metadata. Add a tier argument when a single bare tag is not expressive enough, but keep the vocabulary small. Here tier(level="smoke") and tier(level="regression") share one registered marker name. Pytest's marker expression supports matching keyword arguments, so you can select exactly one level. Do not use positional arguments in the -m expression; this query form matches marker keyword arguments.

Create tests/test_tiers.py:

import pytest

from shipping import shipping_fee


@pytest.mark.tier(level="smoke")
def test_domestic_baseline_for_tier():
    assert shipping_fee(10, "domestic") == 5


@pytest.mark.tier(level="regression")
def test_international_baseline_for_tier():
    assert shipping_fee(10, "international") == 20

The tier mark is not a scheduling engine. It is metadata that the command line or a plugin can inspect. If your suite already uses smoke and slow, decide whether a separate tier adds information or merely duplicates tags. You can attach multiple markers to one test when they represent independent dimensions, such as business area and run frequency. Avoid encoding mutable outcomes like currently_passes into a mark; those values become stale immediately after a fix.

Verify: Run python -m pytest -q -m "tier(level='smoke')" tests/test_tiers.py; expect one pass and one deselected test. Run the same command with regression to select the other test. python -m pytest --markers must list tier(level) from your configuration. If you see a marker warning or strict-marker collection error, check spelling in both the decorator and pyproject.toml.

Step 8: Build a Merge Gate With This Pytest Markers Tutorial

Finish with two commands that express different coverage promises. The first runs the registered smoke group and excludes anything marked slow. The second runs the full local suite and reports skip, XFAIL, and XPASS details. Put the first command in a fast pull-request job and the second in a broader scheduled or pre-release job if your team's execution budget calls for that split. Keep both commands in project documentation so developers can reproduce CI locally.

python -m pytest -q -m "smoke and not slow" -rsx
python -m pytest -q -rsxX

With every file from this tutorial present, the merge-gate command selects the two marked domestic baseline tests. The full command also includes the known boundary failures, optional international row, environment skips, and tier examples. Its exit status stays successful while the known bug fails as expected. Any unrelated assertion failure or strict unexpected pass must make the job fail. Save the summary as a CI artifact if your platform supports it; the command output itself is the minimal evidence.

Before changing this suite for production, decide who owns each tag and how skips are reviewed. A new test may be correct yet absent from the merge gate because its author forgot smoke. Conversely, tagging a network-dependent check as smoke can make a fast gate unreliable. Review selected counts alongside pass counts. The pytest BDD tutorial shows how similar selection concerns appear when scenarios, steps, and tags are involved.

Verify: Run python -m pytest --collect-only -q -m "smoke and not slow" and confirm the two intended node IDs. Then run both commands above. On POSIX without the optional environment variables, the full suite should report passes, two XFAIL results, and skips, with no FAIL or XPASS. Exact counts can change when you enable the opt-in row or run on another platform; the status categories and reasons are the important checks.

Troubleshooting

Problem: PytestUnknownMarkWarning or an error for a custom mark -> Add the marker name and a description under markers in the root pyproject.toml, then rerun python -m pytest --markers. Check that pytest reports the expected config file in its header. Registration applies to custom names such as smoke and tier; built-in marks such as skip, skipif, and xfail are already known.

Problem: -m selects zero tests -> Run python -m pytest --collect-only -q without a filter, then inspect the decorators on collected tests. Confirm your shell passed the expression as one argument and that you used -m for markers rather than -k for names. Zero selected tests can also produce a nonzero exit code in automation, which is useful evidence that the gate may have lost all coverage.

Problem: a skipped test has no useful explanation -> Add a specific reason and rerun with -rs. For dynamic conditions, call pytest.skip("specific constraint") at the point where the condition is checked. Avoid a generic reason such as "not working" because it gives the next maintainer no condition to restore.

Problem: an expected failure becomes XPASS(strict) -> Reproduce the test without the mark, confirm the product behavior is now correct, remove the xfail, and close its tracked issue. If the pass occurred only because setup bypassed the assertion, fix the test before celebrating. A strict unexpected pass is a useful alarm, not an invitation to set strict=False.

Problem: ModuleNotFoundError: shipping -> Run python -m pytest from the project root containing shipping.py and pyproject.toml, with the intended virtual environment active. Check pwd or your shell's current-directory command and python -m pytest --version. Do not solve a wrong working directory by adding arbitrary paths to test code.

Problem: the full run differs across machines -> Compare os.name, CARRIER_TOKEN, and RUN_INTERNATIONAL; those conditions intentionally control the demonstration skips. Inspect the -rsxX summary instead of treating a different skip count as a product defect. Keep any real environment flags documented near the CI job that sets them.

Interview Questions and Answers

A strong explanation distinguishes collection, execution, and reporting. The questions in the interviewQnA section of this article cover those distinctions, including why a strict XPASS should fail CI and why a custom marker must be registered. Practice explaining the output of this exact suite before discussing a larger framework; the selected, deselected, skipped, XFAIL, and XPASS labels each make a different claim.

Common Mistakes

  • Using skip for a known product defect removes the assertion from execution. Keep the failing assertion active with a narrowly scoped xfail and an owner.
  • Leaving xfail non-strict allows a fixed bug to appear as an XPASS while the job remains green. Set strict=True on each known defect or use a reviewed suite policy.
  • Marking an entire parametrized function when only one row is broken hides failures in healthy rows. Attach a mark with pytest.param to the affected case.
  • Assuming a custom slow mark delays or excludes a test automatically confuses metadata with selection. Run an explicit -m expression or implement a documented plugin policy.
  • Using a vague skip reason creates permanent blind spots. State the unavailable dependency, platform limitation, or issue reference and revisit it during suite reviews.
  • Reporting only pass count from a filtered job conceals how much was deselected. Include the filter and selected count in CI logs.

Where To Go Next

Fix the boundary in shipping.py by changing subtotal > 50 to subtotal >= 50, remove the two SHIP-142 xfail marks, and rerun the full suite. That final change should turn both boundary cases into ordinary passes. Review your team's existing suite for unexplained skips, non-strict expected failures, unregistered custom tags, and merge jobs that select no tests.

For related skills, continue with the pytest BDD tutorial to connect marks with scenario organization, the top pytest interview questions to rehearse the reporting distinctions, and the Playwright Python fixtures with pytest guide when browser setup needs clean fixture boundaries. If your focus is test reliability, use the flaky test quarantine in CI guide to keep intermittent behavior visible while you investigate it.

Conclusion

Pytest markers let you express three different decisions clearly: which tests belong in a selected run, which tests cannot execute under present conditions, and which known failures should remain under observation. Register custom names, verify selection counts, give skip and xfail reasons, and make unexpected passes fail the suite. Start with the runnable project above, then apply the same discipline to one real CI job and review its outcome labels alongside its green or red status.

Interview Questions and Answers

When would you use skip rather than xfail?

I use skip when an environmental prerequisite is absent, such as a platform capability or an integration sandbox, so executing the test would be invalid. I use xfail when the test can run and expose a known product defect. The distinction matters because a skipped body supplies no evidence that the defect still exists.

Why should known-defect xfails be strict?

With `strict=True`, a test that unexpectedly passes fails the CI command as XPASS(strict). That prompts the team to verify the fix and remove the exception. Without strictness, an obsolete xfail can remain green indefinitely and weaken the meaning of the suite.

How do you prevent misspelled custom markers?

I register each supported name in pytest configuration and enable `--strict-markers`. A misspelled decorator then fails during collection instead of becoming a warning. I also document the purpose of each mark so two near-duplicate groups do not grow unnoticed.

What is the difference between deselected and skipped tests?

A deselected test was collected but excluded by a selection filter such as `-m smoke`. A skipped test was selected and then recorded as not run because of a skip rule or runtime `pytest.skip()` call. I report both numbers separately when explaining what a CI job covered.

How would you xfail only one value in a parametrized test?

I would use `pytest.param` for that row and attach `pytest.mark.xfail` through `marks=`. I would give the row a stable ID and a defect reason, then confirm neighboring rows still pass normally. Marking the whole function would conceal regressions in unaffected values.

How do marker expressions differ from name filters?

The `-m` option selects using explicit marker metadata, including boolean combinations and supported keyword-argument matches. The `-k` option filters by names and keywords, which is useful for ad hoc investigation. I use registered markers for durable pipeline groups because their intent is visible in test code and configuration.

What would you investigate if a smoke job selected zero tests?

First I would run collection without `-m` to see whether tests are discoverable. Next I would inspect the expression, shell quoting, marker registration, and decorators on expected tests. I would treat a zero-test job as a coverage failure, not a successful green gate.

Frequently Asked Questions

What is the difference between skip and xfail in pytest?

A skipped test does not run its test body, so it makes no assertion about the behavior. An xfailed test normally runs and records a failure as expected; if it passes, pytest reports XPASS. Choose based on whether the test can execute under the current conditions.

How do I run only tests with a custom pytest marker?

Register the marker in `pyproject.toml`, decorate the intended tests, and run `python -m pytest -m smoke` for a marker named `smoke`. Quote compound expressions such as `-m "smoke and not slow"` so your shell passes them intact. Check the selected and deselected counts in the output.

Does a slow marker automatically skip a pytest test?

No. A custom `slow` mark only attaches metadata. A command such as `python -m pytest -m "not slow"` excludes those tests from that invocation; an unfiltered pytest run still executes them.

What does XPASS(strict) mean?

The test was marked as an expected failure but passed, and strict mode turns that unexpected pass into a suite failure. Verify that the behavior is genuinely fixed, remove the xfail marker, and close the associated issue. Also check that the test did not bypass its important assertion.

Can I skip or xfail one parametrized case?

Yes. Wrap that data row in `pytest.param` and pass a `marks=` value such as `pytest.mark.xfail(reason=..., strict=True)`. Other parameter rows remain normal tests and can still reveal unrelated regressions.

Why does pytest say a custom marker is unknown?

The decorator name is absent from the active pytest configuration or is misspelled. Add it under `[tool.pytest.ini_options].markers` in `pyproject.toml`, confirm the config file being loaded, and use `--strict-markers` to make future typos fail collection.

How can I see why a test was skipped or xfailed?

Run pytest with `-rsx` to include skipped and expected-failure details in the short summary. Add `X` when you also want details about unexpected passes. A precise reason on the marker makes that report actionable.

Related Guides