Pytest & CI

Pytest Markers for Conditional Test Execution

A test guarded with @pytest.mark.skipif(os.environ["CI"] == "true", ...) can crash the entire collection with KeyError when the variable is absent, and an undeclared marker silently emits PytestUnknownMarkWarning that masks typos until a test runs everywhere it should have been skipped. Both stem from the same fact: pytest evaluates marker conditions during the collection phase, at module import time, long before any fixture or test body runs. This guide shows how to write import-safe skipif/xfail conditions, register markers to enforce hygiene, and inject markers from CI without editing test files.

When each marker mechanism fires during a pytest run A four-phase timeline. Collection/import evaluates skipif and xfail conditions; pytest_collection_modifyitems injects markers with add_marker; fixture setup instantiates fixtures; the test body can call pytest.skip or pytest.xfail. A warning notes that os.environ["KEY"] raises KeyError at collection, and that fixtures cannot be read by skipif conditions. When each marker mechanism fires 1 · Collection / import skipif & xfail conditions evaluated as plain Python no fixtures exist yet 2 · modifyitems hook item.add_marker() injects skip from CI os.environ safe here 3 · Fixture setup fixtures instantiate values become readable from here on 4 · Test body pytest.skip() / pytest.xfail() run can read fixtures Collection-phase trap os.environ["KEY"] raises KeyError at import, before a single test is collected. Use os.environ.get("KEY") → falsy when unset, so the condition degrades instead of crashing. Fixtures aren't available yet skipif runs in phase 1, but fixtures only instantiate in phase 3 — a condition that names one raises NameError. Decide inside the body with pytest.skip().
Marker conditions are evaluated at collection (phase 1), long before fixtures exist; only add_marker injection and in-body pytest.skip() run late enough to read the environment and fixture state safely.

The lifecycle ordering matters because it dictates which values a condition can legally read. A skipif expression is just Python evaluated at import; pytest.skip() inside a body runs after fixtures exist. Confusing the two is the root cause of nearly every marker bug below.

Prerequisites

  • pytest >= 8.0, Python 3.9+.
  • Markers declared and strict mode enabled in pyproject.toml:
TOML
# pyproject.toml
[tool.pytest.ini_options]
addopts = "--strict-markers"
markers = [
  "skip_platform: skip tests based on OS constraints",
  "requires_db: skip tests when the database is unavailable",
  "xfail_flaky: known intermittent failure",
]

Marker registration is part of the broader configuration discipline in Pytest Configuration Best Practices; how conditions are parsed during collection ties back to Advanced Pytest Architecture & Configuration.

Solution

Build conditions from deterministic, import-safe values and read environment variables with .get() so a missing key degrades gracefully instead of raising during collection:

Python
import os
import sys
import platform
import pytest

# Evaluated once at import time from constants — never a network/DB call.
WINDOWS_ONLY = pytest.mark.skipif(
    sys.platform != "win32",
    reason="Requires Windows-specific registry APIs",
)

LINUX_PY310 = pytest.mark.skipif(
    sys.platform != "linux" or sys.version_info < (3, 10),
    reason="Needs Linux kernel features and Python 3.10+ match statement",
)

# os.environ["TEST_DB_URL"] would KeyError at collection if unset;
# .get() returns None, so the condition is simply truthy/falsy.
SKIP_IF_NO_DB = pytest.mark.skipif(
    not os.environ.get("TEST_DB_URL"),
    reason="TEST_DB_URL not configured; skipping integration tests",
)

@WINDOWS_ONLY
def test_windows_registry_access():
    assert platform.system() == "Windows"

@SKIP_IF_NO_DB
def test_db_round_trip():
    ...

To gate a single parametrized case rather than the whole function, attach the marker to that case — function-level markers evaluate before parameters expand:

Python
@pytest.mark.parametrize("payload", [
    {"v": 1},
    pytest.param({"v": 2}, marks=pytest.mark.xfail(reason="schema v2 not shipped")),
])
def test_payload(payload):
    assert validate(payload)

For environment-aware filtering without touching test files, inject markers in pytest_collection_modifyitems — one of the collection hooks you can implement in a plugin or conftest — which runs after collection but before fixture setup. Place it in the root conftest.py so it applies across a whole conftest hierarchy:

Python
# conftest.py
import os
import pytest

def pytest_collection_modifyitems(config, items):
    """Inject skip markers from CI env vars — keeps tests clean."""
    fast_mode = os.environ.get("CI_FAST_MODE", "false").lower() == "true"
    skip_slow = pytest.mark.skip(reason="slow tests skipped in fast CI stage")
    for item in items:
        if fast_mode and "slow" in item.keywords:
            item.add_marker(skip_slow)

Confirm the resulting marker stack with pytest --collect-only -v before trusting it.

A marker is metadata until something reads it; these are the three readers and the order they run in.

What reads a marker, and when A left-to-right flow: the marker is attached at collection, the -m expression filters the collected items, a collection-modifying hook can add skip or xfail marks, and finally the runtest setup phase applies whatever marks survived. What reads a marker, and when attached at collection -m filters expression on the CLI hook adds marks collection_modifyitems setup applies skip / xfail honoured Registering markers in the ini file turns a typo into a warning instead of silence.
Filtering with -m happens before any hook can add marks, which is why a dynamically added skip cannot be selected with -m.

Why this works

Marker conditions are plain Python expressions evaluated when the module is imported during collection, so anything they reference must already be resolvable — constants like sys.platform and sys.version_info always are, while os.environ["..."] is not. Using .get() converts a fatal KeyError into a benign falsy value. Deferring environment-driven decisions to pytest_collection_modifyitems moves them to a point where os.environ is safely readable and the decision is centralized, which keeps the same condition deterministic across every pytest-xdist worker.

Edge cases and failure modes

  • CI string casing. CI systems inject TRUE/FALSE as strings, both truthy in Python. Always normalize with .lower() == "true".
  • xfail(strict=True) on flaky tests. A strict xfail reports FAILED when the test unexpectedly passes — correct for tracking known failures, but a trap for timing-flaky tests. Pair it with deterministic failure conditions, or use pytest-rerunfailures for genuine flakiness instead.
  • Referencing fixtures in conditions. Fixtures instantiate during setup, after collection, so skipif/xfail cannot read fixture values — that raises NameError. Call pytest.skip() or pytest.xfail() inside the test body when the decision needs fixture state.
  • Expensive condition functions. A condition calling subprocess.run, socket.gethostbyname, or an ORM probe runs once per module at collection and inflates pytest --collect-only --durations=0. Precompute booleans at import (_IS_CI = os.environ.get("CI", "").lower() == "true").
  • Worker-divergent conditions under parallelism. Conditions using os.getpid() or mutable module globals evaluate differently per worker, causing inconsistent skips. Use only CI-provided environment variables or pre-filtered -k lists.

Building a marker policy a team can follow

Markers rot faster than any other pytest feature, because adding one costs nothing and removing one requires knowing whether anything still selects it. A policy with three rules keeps the set small enough to remember.

Register every marker, and fail on unknown ones. --strict-markers turns a typo from a silently unselected test into an error at collection. Registration also gives each marker a one-line description that pytest --markers prints, which is the only documentation most people will read.

TOML
# pyproject.toml
[tool.pytest.ini_options]
addopts = "--strict-markers -ra"
markers = [
  "slow: takes more than two seconds; excluded from the pre-commit run",
  "integration: needs Postgres and Redis on localhost",
  "flaky: known-unstable, quarantined until the linked issue closes",
]

Separate selection markers from behaviour markers. slow and integration select; skipif and xfail change outcomes. Mixing them in one name — a integration marker that also skips when an env var is absent — makes the suite's behaviour depend on where the marker is defined rather than on how it is invoked. Keep the selection marker declarative and put the condition in a hook or a fixture where it can be read.

Python
# conftest.py — one place decides what "integration" implies
import os, pytest

def pytest_collection_modifyitems(config, items):
    if os.environ.get("RUN_INTEGRATION") == "1":
        return                                        # nothing to do
    skip = pytest.mark.skip(reason="set RUN_INTEGRATION=1 to enable")
    for item in items:
        if "integration" in item.keywords:
            item.add_marker(skip)

Give every quarantine marker an expiry. A flaky marker with an issue number in its reason is a task; one without is a permanent exemption nobody will revisit. A five-line test that fails when a quarantine is older than a sprint is cheap and keeps the list honest.

The selection syntax itself is worth teaching once, because it is more capable than most teams use. -m "slow and not integration" combines markers; -m "not (slow or flaky)" is the usual pre-commit filter; and -k selects on names rather than marks, so the two compose: pytest -m integration -k "billing and not refund".

One caveat that catches everyone: -m filters the collected items, so a test that is only marked inside pytest_collection_modifyitems cannot be selected by that marker on the same run — the filter has already run. If you need a computed marker to be selectable, compute it during collection with pytest_collection_modifyitems(config, items) running before the filter, or apply the marker statically and let the hook decide the outcome instead.

Which mechanism fits the condition A decision diagram: a condition known before the run selects with a registered marker and the -m flag, a condition known only at setup uses skipif, and a known failure that must still run uses xfail with strict mode. Which mechanism fits the condition When is the condition known? before the run marker + -m selection, not skipping CI decides the set at setup time skipif evaluated per test reason is reported it fails today xfail(strict) runs, must still fail passes become errors xfail without strict=True lets a fixed test keep reporting as expected-failure forever.
Skipping and selecting are different tools: a skipped test is reported, a deselected one is invisible.

Frequently Asked Questions

Why does pytest.mark.skipif not evaluate correctly with parametrized tests? Marker conditions evaluate during collection, before parameter expansion, so a marker on the function sees the unexpanded test. To gate a specific parameter set, attach the marker to that case with pytest.param(value, marks=pytest.mark.skipif(condition, reason="...")).

How can I dynamically skip tests by CI environment variable without editing test files? Implement pytest_collection_modifyitems in the root conftest.py, read os.environ inside the hook, build pytest.mark.skipif objects, and attach them with item.add_marker(). This keeps test sources clean and centralizes CI logic.

What causes PytestUnknownMarkWarning and how do I suppress it safely? Pytest warns when a marker is not declared in configuration. Register markers under [tool.pytest.ini_options] markers in pyproject.toml and add --strict-markers so typos become errors, enabling IDE autocompletion and consistent resolution. How do I stop a marker from silently selecting nothing? Run the selection in CI with --strict-markers and assert the count. pytest -m integration --collect-only -q | tail -1 prints the number of collected items, and a job step that fails when that number is zero catches both a renamed marker and a directory that stopped being collected. The failure mode this prevents is the worst kind: a green integration job that ran no integration tests at all, which looks identical to a healthy one in every dashboard.

← Back to Pytest Configuration Best Practices