Pytest & CI

Pytest markers for conditional test execution

Pytest markers for conditional test execution

Core Mechanics of Conditional Markers

Conditional test execution in pytest is fundamentally governed by the collection phase, not the execution phase. When pytest discovers test items, it evaluates marker expressions immediately upon module import. This architectural decision ensures that the test scheduler can accurately partition workloads before any fixtures are instantiated or test functions are invoked. Understanding this lifecycle boundary is critical for avoiding race conditions, import-time failures, and silent test suppression.

The two primary conditional markers, pytest.mark.skipif and pytest.mark.xfail, operate with distinct reporting semantics and lifecycle impacts. skipif removes the test from the execution queue entirely when its boolean condition evaluates to True, reporting the item as SKIPPED. Conversely, xfail retains the test in the execution queue but annotates it as an expected failure. If the test unexpectedly passes, pytest's reporting behavior depends on the strict parameter. Both markers parse their condition strings using Python's eval() equivalent within a restricted namespace during collection. This means any variables, module attributes, or environment checks referenced in the condition must be resolvable at import time.

A frequent production issue stems from unregistered markers. When pytest encounters an undeclared marker, it emits a PytestUnknownMarkWarning. While this does not halt execution, it degrades CI signal clarity and disables IDE autocompletion. To enforce strict marker hygiene, declare all custom and conditional markers in your configuration file. For comprehensive guidance on structuring these declarations, consult Pytest Configuration Best Practices before scaling your test matrix.

TOML
# pyproject.toml
[tool.pytest.ini_options]
markers = [
 "skip_platform: Skip tests based on OS constraints",
 "requires_db: Skip tests when database is unavailable",
 "xfail_flaky: Mark known intermittent failures"
]

To verify marker attachment before the execution phase begins, leverage pytest --collect-only -v. This command outputs the complete test tree alongside attached markers without invoking any test logic. If a marker fails to appear in the collection output, the condition likely raised an exception during import, or the marker was applied to a non-test item (e.g., a helper function). Always validate collection output after modifying conditional logic to ensure the marker graph matches your architectural intent.

Dynamic Condition Evaluation at Runtime

Dynamic conditional execution requires careful handling of runtime state during the collection phase. Because marker conditions are evaluated at import time, referencing mutable global state, performing network calls, or querying databases directly inside skipif or xfail expressions will degrade collection performance and introduce non-deterministic behavior. The recommended approach is to leverage deterministic, import-safe attributes such as sys.platform, os.environ, or the platform module.

When constructing boolean expressions, avoid lazy evaluation pitfalls. Python's short-circuiting behavior applies, but pytest's internal parser expects a resolvable boolean. If a condition depends on an external service, defer the check to the pytest_collection_modifyitems hook rather than embedding it in the marker. This preserves import-time safety while enabling runtime-aware filtering.

Python
# test_platform_skip.py
import sys
import platform
import pytest

# Safe import-time evaluation using platform constants
WINDOWS_ONLY = pytest.mark.skipif(
 sys.platform != "win32",
 reason="Requires Windows-specific registry APIs"
)

LINUX_AND_PYTHON310_PLUS = pytest.mark.skipif(
 sys.platform != "linux" or sys.version_info < (3, 10),
 reason="Test requires Linux kernel features and Python 3.10+ pattern matching"
)

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

@LINUX_AND_PYTHON310_PLUS
def test_linux_epoll_integration():
 # Implementation details omitted
 pass

Environment variable integration requires defensive programming. Direct dictionary access (os.environ["CI"]) will raise KeyError during collection if the variable is absent, halting the entire test suite. Use .get() with explicit type coercion to ensure graceful degradation.

Python
# test_env_driven.py
import os
import pytest

# Graceful handling of missing environment variables
SKIP_IF_NO_DB_URL = pytest.mark.skipif(
 not os.environ.get("TEST_DB_URL"),
 reason="TEST_DB_URL not configured; skipping integration tests"
)

RUN_PERFORMANCE_TESTS = pytest.mark.skipif(
 os.environ.get("CI_PROFILE", "false").lower() != "true",
 reason="Performance profiling disabled in current environment"
)

Profiling collection overhead is essential when scaling beyond a few hundred tests. Marker conditions that invoke expensive functions (e.g., subprocess.run, socket.gethostbyname, or ORM connection probes) will execute once per test module during collection. To measure this impact, run pytest --collect-only --durations=0 and observe the collection time delta. If collection exceeds 3–5 seconds, replace dynamic checks with memoized boolean flags or precomputed environment snapshots. For deeper insights into how pytest parses and caches marker expressions during the collection phase, refer to Advanced Pytest Architecture & Configuration.

Advanced Composition and Conftest Inheritance

Marker precedence follows a strict hierarchical resolution order: function-level markers override class-level markers, which override module-level markers, which override conftest.py markers. This inheritance model enables centralized conditional logic while allowing localized overrides. However, silent marker collisions frequently occur when nested conftest.py files redefine markers without explicit combination.

When combining multiple conditions, use logical operators directly within the marker expression or stack multiple markers. Pytest evaluates stacked markers using logical AND semantics for skipif (all conditions must be True to skip) and OR semantics for xfail (any condition matching triggers the xfail annotation).

Python
# conftest.py (root)
import pytest
import sys

# Global skip condition applied to entire test tree
pytestmark = pytest.mark.skipif(
 sys.version_info < (3, 9),
 reason="Suite requires Python 3.9+ type hinting syntax"
)

In a subdirectory, a child conftest.py can refine or override this behavior. However, markers do not automatically merge across conftest boundaries. To preserve parent conditions while adding new constraints, explicitly reapply the parent marker or use pytest_collection_modifyitems for programmatic combination.

Python
# tests/integration/conftest.py
import pytest
import os

# Child conftest adds database requirement without overriding parent
pytestmark = pytest.mark.skipif(
 not os.environ.get("INTEGRATION_DB_HOST"),
 reason="Integration database host not specified"
)

To diagnose silent overrides or unexpected marker resolution, execute pytest --trace-config. This command outputs the exact loading order of conftest.py files, plugin hooks, and marker registrations. Cross-reference this output with your directory structure to verify inheritance chains. If a test unexpectedly runs despite a parent skipif, verify that the child conftest.py isn't inadvertently shadowing the marker namespace or that a higher-precedence marker (e.g., function-level @pytest.mark.skipif(False)) isn't overriding the skip condition. Always validate marker precedence using pytest --collect-only -v --trace-config to visualize the exact marker stack attached to each test item before execution.

CI/CD-Driven Conditional Execution

Modern CI/CD pipelines require environment-aware test filtering without modifying source files. Hardcoding environment checks into test modules creates maintenance overhead and couples test logic to infrastructure state. The pytest_collection_modifyitems hook provides a clean, centralized mechanism for injecting markers dynamically based on CI environment variables, GitHub Actions matrix parameters, or deployment stage flags.

Python
# conftest.py (dynamic injection)
import os
import pytest

def pytest_collection_modifyitems(config, items):
 """Dynamically inject skip/xfail markers based on CI environment."""
 ci_stage = os.environ.get("CI_STAGE", "unit")
 runner_os = os.environ.get("RUNNER_OS", "Linux")
 
 skip_slow = pytest.mark.skipif(
 ci_stage == "pr_check" and os.environ.get("CI_FAST_MODE", "false") == "true",
 reason="Skipping slow tests during PR fast-check stage"
 )
 
 skip_windows_specific = pytest.mark.skipif(
 runner_os != "Windows",
 reason="Windows-specific tests skipped on non-Windows runners"
 )
 
 for item in items:
 if "slow" in item.keywords:
 item.add_marker(skip_slow)
 if "windows_only" in item.keywords:
 item.add_marker(skip_windows_specific)

This hook executes after collection but before fixture setup, ensuring that marker injection does not interfere with test discovery. By reading os.environ at this stage, you avoid import-time evaluation risks while maintaining deterministic filtering. This pattern integrates seamlessly with GitHub Actions matrix testing, where matrix.os and matrix.python-version can be mapped to environment variables before invoking pytest.

When using pytest-xdist for parallel execution, dynamic marker injection impacts worker distribution. Pytest distributes tests after marker evaluation, meaning dynamically skipped tests are removed from the distribution pool before workers are spawned. This can cause load imbalance if a large subset of tests is filtered on specific workers. To mitigate this, ensure marker conditions are deterministic across all workers. Avoid using os.getpid(), socket.gethostname(), or worker-specific state in marker conditions. Instead, rely on CI-provided environment variables or pre-filtered test lists passed via pytest -k. If load balancing remains problematic, consider using pytest-xdist's --dist=loadfile or --dist=loadscope strategies to group related tests before distribution.

Debugging and Profiling Marker Resolution

Diagnosing unexpected marker behavior requires isolating the evaluation phase from execution. When a marker evaluates to True unexpectedly, the root cause typically lies in environment variable leakage, incorrect boolean coercion, or hook execution order conflicts. Begin by enabling verbose collection logging: pytest --collect-only -v --log-cli-level=DEBUG. This reveals the exact marker stack attached to each item and highlights any PytestUnknownMarkWarning or evaluation exceptions.

To isolate false-positive xfail triggers, examine the test's execution context. An xfail with strict=True will report as FAILED if the test unexpectedly passes. If this occurs in parallel runs, race conditions in shared state or non-deterministic fixture teardown may be altering the test outcome. Reproduce the issue with pytest -x -s --dist=no to disable parallelism and observe the raw execution flow.

Python
# profile_collection.py
import time
import pytest

# Custom marker condition with explicit timing instrumentation
def expensive_check():
 start = time.perf_counter()
 # Simulate network/DB probe
 time.sleep(0.05)
 duration = time.perf_counter() - start
 print(f"[MARKER PROFILING] expensive_check took {duration:.4f}s")
 return False # Replace with actual logic

EXPENSIVE_SKIP = pytest.mark.skipif(
 expensive_check(),
 reason="External service unavailable"
)

To profile collection bottlenecks, run pytest --durations=0. This outputs the time spent on each phase: collection, setup, call, teardown, and reporting. If collection dominates the runtime, marker conditions are likely invoking heavy operations. Replace synchronous checks with cached results or precompute boolean flags in a pytest_configure hook that runs once per session.

For step-by-step diagnosis of parallel marker inconsistencies:

  1. Run pytest --collect-only -v locally and in CI. Compare outputs to identify environment-dependent marker attachments.
  2. Execute pytest --trace-config to verify conftest.py loading order matches your directory structure.
  3. Temporarily disable pytest-xdist (pytest -p no:xdist) to rule out worker distribution artifacts.
  4. Add print(f"Item: {item.nodeid}, Markers: {item.own_markers}") inside pytest_collection_modifyitems to log exact marker states before filtering.
  5. Validate environment variable casing and type coercion. CI systems often inject TRUE/FALSE as strings, which evaluate as truthy in Python. Always normalize with .lower() == "true".

Edge Cases and Anti-Patterns

Conditional markers are powerful but prone to subtle anti-patterns that degrade test reliability and suite performance. The most critical is misusing strict=True in xfail markers. When strict=True is applied, any test that passes is reported as FAILED. This is intentional for tracking expected failures, but becomes problematic when flaky tests intermittently pass due to timing variations or race conditions. If a test passes unexpectedly, remove strict=True temporarily or refactor the test to enforce deterministic failure conditions.

Python
# test_xfail_strict.py
import pytest

# strict=True converts unexpected passes into hard failures
@pytest.mark.xfail(
 condition=True,
 reason="Known race condition in async event loop",
 strict=True
)
def test_async_event_ordering():
 # If this passes, pytest reports FAILED
 assert False, "Simulated expected failure"

Another frequent anti-pattern is mixing fixture logic with marker conditions. Fixtures execute during the setup phase, while markers evaluate during collection. Attempting to reference fixture return values inside skipif or xfail will raise NameError or AttributeError because the fixture has not yet been instantiated. If conditional execution depends on fixture state, use pytest.skip() or pytest.xfail() inside the test body instead of relying on markers.

Global state in marker conditions introduces non-determinism, especially in pytest-xdist environments. Avoid mutating module-level variables during collection. Each worker process imports test modules independently, meaning global state is not shared. If a marker condition relies on a mutable cache or singleton, workers will evaluate it against uninitialized state, causing inconsistent skipping across the matrix.

Profiling complex boolean expressions reveals hidden CPU and memory overhead. Large test suites with deeply nested logical operators in markers can trigger excessive string parsing and namespace lookups during collection. To optimize, precompute boolean flags at module import time:

Python
# Optimized marker evaluation
import os
import sys

# Precomputed at import time, evaluated once per module
_IS_CI = os.environ.get("CI", "false").lower() == "true"
_IS_WINDOWS = sys.platform == "win32"

CI_WINDOWS_SKIP = pytest.mark.skipif(
 _IS_CI and _IS_WINDOWS,
 reason="Skipping known CI/Windows incompatibility"
)

This pattern eliminates repeated environment lookups and reduces collection memory footprint. Always benchmark collection time after introducing complex marker logic to ensure scalability.

Conclusion and Next Steps

Conditional markers in pytest provide a robust mechanism for environment-aware, platform-specific, and CI-driven test filtering. By understanding that marker evaluation occurs during the collection phase, you can avoid import-time failures, optimize collection performance, and prevent silent overrides. Registering markers in configuration files, leveraging pytest_collection_modifyitems for dynamic injection, and profiling collection overhead with --durations=0 form the foundation of a maintainable conditional execution strategy.

For production-grade test suites, adhere to these validation steps before merging:

  1. Verify marker registration in pyproject.toml to suppress warnings.
  2. Run pytest --collect-only -v locally and in CI to confirm consistent marker attachment.
  3. Profile collection time with --durations=0 and refactor expensive conditions into cached imports.
  4. Test marker behavior across all target platforms and Python versions in your CI matrix.
  5. Disable pytest-xdist temporarily to rule out worker distribution artifacts when debugging inconsistent skips.

As your test architecture scales, consider transitioning from inline markers to custom pytest plugins that encapsulate complex filtering logic. Plugins enable centralized marker resolution, advanced hook integration, and reusable conditional patterns across multiple repositories. Mastering conditional execution is a critical step toward building resilient, high-throughput test pipelines that adapt dynamically to infrastructure state without sacrificing reliability or developer velocity.

Frequently Asked Questions

Why does pytest.mark.skipif not evaluate correctly when using parametrized tests? Parametrization occurs during collection, but marker evaluation happens before parameter expansion. If your condition depends on a parameter value, the marker will evaluate against the unexpanded test function. To apply conditional logic to specific parameter sets, use pytest.param with the marks argument: pytest.param(value, marks=pytest.mark.skipif(condition, reason="...")).

How can I dynamically skip tests based on CI environment variables without modifying test files? Implement pytest_collection_modifyitems in your root conftest.py. Read os.environ inside the hook, construct pytest.mark.skipif objects, and attach them to matching items using item.add_marker(). This approach keeps test source files clean and centralizes CI logic.

What causes PytestUnknownMarkWarning and how do I suppress it safely? Pytest warns when it encounters markers not declared in the configuration. Register them under [tool.pytest.ini_options] markers in pyproject.toml or pytest.ini. This enables IDE autocompletion, prevents typos, and ensures consistent marker resolution across the suite.

Can I profile which markers are slowing down test collection? Yes. Run pytest --collect-only --durations=0 to log collection time per module. Add explicit time.perf_counter() instrumentation inside marker condition functions to trace evaluation frequency. Replace synchronous external checks with precomputed boolean flags to eliminate bottlenecks.

How do marker conditions interact with pytest-xdist worker distribution? Markers are evaluated before test distribution. If conditions are non-deterministic or rely on worker-specific state (e.g., os.getpid()), workers may receive inconsistent test sets, causing load imbalance or unexpected skips. Use deterministic environment variables, pre-filtered test lists, or session-scoped fixtures to ensure uniform evaluation across all workers.