Python · Testing with pytest · Intermediate

Marks and Test Selection

5 tasks

Tag tests with built-in and custom marks to control which tests run.

Marks and Test Selection

#
## Not all tests should always run As a test suite grows, tests accumulate very different characteristics: - Some are fast (pure functions, no I/O) - Some are slow (hit a database or external API) - Some only make sense on a specific OS or Python version - Some test a feature that isn't implemented yet - Some are known to fail due to an open bug Running everything on every keystroke wastes time. **Marks** let you label tests semantically and filter them with the `-m` flag at run time. ## Built-in marks pytest ships several marks out of the box. They require no registration. ### @pytest.mark.skip — unconditional skip ```python @pytest.mark.skip(reason='payment gateway not configured in test env') def test_charge_card(): ... ``` The test shows as `s` in output. With `-v` you see the reason. Always include `reason=` — future you will thank present you. ### @pytest.mark.skipif — conditional skip ```python import sys @pytest.mark.skipif(sys.platform == 'win32', reason='POSIX paths only') def test_symlinks(): ... @pytest.mark.skipif(sys.version_info < (3, 11), reason='tomllib added in 3.11') def test_tomllib_parse(): import tomllib ... ``` The condition is evaluated at **collection time** (when pytest gathers tests), not at run time. Use any Python expression that evaluates to a bool. ### @pytest.mark.xfail — expected failure ```python @pytest.mark.xfail(reason='bug #47: parser crashes on empty input') def test_parse_empty(): assert parse('') == [] # currently raises IndexError ``` Outcomes: - Test fails → `x` (xfail — expected, fine) - Test passes → `X` (xpass — unexpected pass) Add `strict=True` to turn an unexpected pass into a hard failure. This forces you to remove the mark once the bug is fixed — crucial for CI: ```python @pytest.mark.xfail(strict=True, reason='bug #47') def test_parse_empty(): assert parse('') == [] ``` With `strict=True`: - Test still fails as expected → `x` (same as without `strict`) - Test unexpectedly passes → `FAILED` — blocks CI just like a regular test failure Without `strict=True` an unexpected pass shows as `X` (xpass) — a warning, not a failure. ## @pytest.mark.parametrize — run one test with many inputs The most commonly used mark. Instead of writing five nearly identical tests, write one and provide a list of inputs: ```python import pytest @pytest.mark.parametrize('value,expected', [ (0, 0), (1, 1), (-1, 1), (100, 100), (-50, 50), ]) def test_abs(value, expected): assert abs(value) == expected ``` pytest runs this test five times, once per row. Each run appears as a separate item in the output: `test_abs[0-0]`, `test_abs[1-1]`, etc. ### Single parameter ```python @pytest.mark.parametrize('n', [1, 2, 3, 10, 100]) def test_positive(n): assert n > 0 ``` ### Marking individual cases Each parameter set can carry its own marks. Useful for skipping one case or marking it as expected failure: ```python @pytest.mark.parametrize('code,expected_status', [ ('SAVE20', 200), ('EXPIRED', 400), pytest.param('SECRET', 200, marks=pytest.mark.xfail(reason='not yet implemented')), ]) def test_coupon(code, expected_status): ... ``` ### Stacking parametrize Multiple `@pytest.mark.parametrize` decorators on one test produce the **Cartesian product** — every combination is tested: ```python @pytest.mark.parametrize('base', [10, 100]) @pytest.mark.parametrize('discount', [0.1, 0.2, 0.5]) def test_discount(base, discount): result = base * (1 - discount) assert result < base # 2 × 3 = 6 test cases total ``` --- ## Custom marks You can define any mark name: `@pytest.mark.slow`, `@pytest.mark.database`, `@pytest.mark.external`. But you must **register** them in `pytest.ini` to avoid `PytestUnknownMarkWarning`: ```ini [pytest] markers = slow: marks tests as slow (deselect with '-m "not slow"') database: tests that require a real database connection external: tests that call external services ``` Apply them as decorators — a test can have multiple marks: ```python @pytest.mark.slow @pytest.mark.external def test_load_from_s3(): ... ``` ## Running tests by mark: -m expressions ```bash pytest -m slow # only 'slow' tests pytest -m "not slow" # everything except 'slow' pytest -m "slow and external" # must have both marks pytest -m "slow or database" # either mark pytest -m "not (slow or external)" # neither mark ``` The `-m` flag supports `and`, `or`, `not`, and parentheses — full boolean algebra. ## Practical development pattern Register a `slow` mark. Apply it to anything that hits the network or a real database. During development, run `pytest -m "not slow"` for a sub-second feedback loop. In CI, run `pytest` with no filter to catch everything including the slow suite. This pattern keeps local iteration fast while ensuring full coverage in CI without any configuration differences — same test files, different `-m` flags.

Marks in Practice

#
## Setup: register custom marks in pytest.ini ```ini # pytest.ini [pytest] markers = slow: marks slow-running tests database: requires a live database external: calls an external service ``` Without this, pytest shows `PytestUnknownMarkWarning` for custom marks. Registration also lets `--markers` list them for the team. ## Built-in marks in a test file ```python # test_features.py import sys import pytest @pytest.mark.skip(reason='billing module not implemented yet') def test_generate_invoice(): from billing import generate_invoice assert generate_invoice(order_id=1) is not None @pytest.mark.skipif(sys.platform != 'linux', reason='inotify is Linux-only') def test_file_watcher(): from watcher import FileWatcher watcher = FileWatcher('/tmp') assert watcher.is_running() @pytest.mark.xfail(reason='bug #88: parse() crashes on empty input') def test_parse_empty(): from mylib import parse assert parse('') == [] # currently raises IndexError — xfail allows this @pytest.mark.xfail(strict=True, reason='bug #88') def test_parse_empty_strict(): # Once bug #88 is fixed and this passes, CI will fail until the mark is removed from mylib import parse assert parse('') == [] ``` ## Custom marks: separating fast and slow tests ```python # test_products.py import pytest import requests BASE_URL = 'https://apilearn.tukas.dev' def test_price_calculation(): # Fast — pure math, no I/O; runs in every local pytest invocation assert round(100 * 0.9, 2) == 90.0 @pytest.mark.slow def test_products_api(): # Real HTTP call — deselect with '-m "not slow"' during development resp = requests.get(f'{BASE_URL}/api/products/') assert resp.status_code == 200 @pytest.mark.slow @pytest.mark.external def test_product_search(): # Two marks — can target with '-m "slow and external"' resp = requests.get(f'{BASE_URL}/api/products/?search=laptop') assert resp.status_code == 200 ``` ## Running with -m ```bash # Development: instant feedback, skip anything slow pytest -m "not slow" # CI: everything, including slow and external pytest # Target a specific group pytest -m "slow and not external" # Show reasons for skipped tests pytest -v -m "not slow" ``` **Output with `pytest -v -m "not slow"`:** ``` test_products.py::test_price_calculation PASSED test_products.py::test_products_api DESELECTED test_products.py::test_product_search DESELECTED 1 passed, 2 deselected in 0.05s ``` ## Applying a mark to an entire class ```python @pytest.mark.slow class TestAPIIntegration: def test_products(self): ... def test_orders(self): ... # both get 'slow' automatically ``` ## Combining with skipif ```python @pytest.mark.slow @pytest.mark.skipif(sys.platform == 'win32', reason='Unix paths') def test_unix_file_processing(): ... ``` Marks stack — all conditions apply independently.

Marks Quick Reference

#
**Built-in marks:** | Mark | Effect | Output | |------|--------|--------| | `@pytest.mark.skip(reason='...')` | Always skip | `s` | | `@pytest.mark.skipif(cond, reason='...')` | Skip if condition True | `s` | | `@pytest.mark.xfail(reason='...')` | Expected failure | `x` / `X` | | `@pytest.mark.xfail(strict=True)` | Unexpected pass = CI failure | `F` | **Register custom marks (pytest.ini):** ```ini [pytest] markers = slow: marks slow tests database: requires a database external: calls external services ``` **Apply marks:** ```python @pytest.mark.slow @pytest.mark.database def test_something(): ... ``` **Apply to entire class:** ```python @pytest.mark.slow class TestHeavy: def test_a(self): ... def test_b(self): ... # both get 'slow' ``` **-m expression syntax:** ```bash pytest -m slow pytest -m "not slow" pytest -m "slow and database" pytest -m "slow or external" pytest -m "not (slow or external)" ``` **Common skipif patterns:** ```python import sys @pytest.mark.skipif(sys.platform == 'win32', reason='...') @pytest.mark.skipif(sys.version_info < (3, 11), reason='...') ``` **List all registered marks:** ```bash pytest --markers ```
01

Skip a Test with a Reason

#

Write a test function `test_future_feature` that would test some not-yet-implemented functionality. Mark it with `@pytest.mark.skip` and a descriptive `reason` string. Run `pytest -v` and confirm the test shows as `SKIPPED` with your reason visible. Then run `pytest -v -rs` to see the skip reason in the summary section.

import pytest


@pytest.mark.skip(reason='...')   # add your reason here
def test_future_feature():
    # this code never runs when skipped
    result = some_unimplemented_function()
    assert result == 42
Solution
import pytest


@pytest.mark.skip(reason='payment refunds not implemented yet — see issue #42')
def test_future_feature():
    result = some_unimplemented_function()
    assert result == 42
02

Skip Based on Python Version

#

Write a test that uses `@pytest.mark.skipif` to skip if the Python version is below 3.10. Inside the test body, use `match` statement syntax (added in Python 3.10): `match value: case 1: result = "one"`. Run `pytest -v` — the test should run on Python 3.10+ and be skipped on earlier versions. Print `sys.version` in the test to confirm which version ran it.

import sys
import pytest


@pytest.mark.skipif(
    # condition: sys.version_info < (?, ?),
    # reason='...',
)
def test_match_statement():
    value = 1
    # use a match/case statement here
    # assert result == 'one'
Solution
import sys
import pytest


@pytest.mark.skipif(
    sys.version_info < (3, 10),
    reason='match statement added in Python 3.10',
)
def test_match_statement():
    print(f'running on Python {sys.version}')
    value = 1
    match value:
        case 1:
            result = 'one'
        case _:
            result = 'other'
    assert result == 'one'
03

Mark a Test as Expected to Fail (xfail)

#

Write two tests: `test_known_bug` and `test_known_bug_strict`. Both call `int("abc")` without try/except (this raises `ValueError`). Mark `test_known_bug` with `@pytest.mark.xfail` and `test_known_bug_strict` with `@pytest.mark.xfail(strict=True)`. Run `pytest -v` — both should show as `XFAIL`. Notice the difference: without `strict`, an unexpected pass would show as `XPASS` (warning but not a failure); with `strict=True`, an unexpected pass becomes a hard `FAILED` that blocks CI.

import pytest


@pytest.mark.xfail(reason=...)  # fill in the reason
def test_known_bug():
    result = int('abc')
    assert result == 0


@pytest.mark.xfail(strict=..., reason=...)  # add strict= parameter
def test_known_bug_strict():
    result = int('abc')
    assert result == 0
Solution
import pytest


@pytest.mark.xfail(reason='bug #77: int() does not handle letters')
def test_known_bug():
    result = int('abc')
    assert result == 0


# With strict=True: if this test unexpectedly passes, pytest reports it as FAILED
@pytest.mark.xfail(strict=True, reason='bug #77')
def test_known_bug_strict():
    result = int('abc')
    assert result == 0
04

Register a Custom Mark and Filter with -m

#

Add `slow` to the `markers` section in `pytest.ini`. Write three test functions in one file: `test_fast_calculation` (no mark, just `assert 2 + 2 == 4`), `test_slow_api_call` and `test_slow_db_query` (both marked `@pytest.mark.slow`). Run `pytest -m slow -v` and confirm only the two slow tests run. Run `pytest -m "not slow" -v` and confirm only the fast test runs.

# pytest.ini
# [pytest]
# markers =
#     slow: marks tests as slow (deselect with '-m "not slow"')


# test_mixed.py
import pytest


def test_fast_calculation():
    assert 2 + 2 == 4


@pytest.mark.slow
def test_slow_api_call():
    # simulate a slow operation
    import time
    time.sleep(0.1)
    assert True


@pytest.mark.slow
def test_slow_db_query():
    import time
    time.sleep(0.1)
    assert True
Solution
# pytest.ini
# [pytest]
# markers =
#     slow: marks tests as slow (deselect with '-m "not slow"')


# test_mixed.py
import pytest


def test_fast_calculation():
    assert 2 + 2 == 4


@pytest.mark.slow
def test_slow_api_call():
    import time
    time.sleep(0.1)
    assert True


@pytest.mark.slow
def test_slow_db_query():
    import time
    time.sleep(0.1)
    assert True
05

Combine Multiple Marks

#

Register both `slow` and `unix_only` marks in `pytest.ini`. Write `test_fast_check` with no marks. Write `test_file_permissions` marked with `@pytest.mark.slow`, `@pytest.mark.unix_only`, AND `@pytest.mark.skipif(sys.platform == 'win32', ...)`. Run `pytest -v` to confirm both tests execute (or skip on Windows). Then run `pytest -m slow -v` — only the slow test is selected. Then run `pytest -m unix_only -v` — same result: only the platform test is selected. Notice that `@pytest.mark.skipif` provides a runtime guard, while the mark enables CLI filtering.

# pytest.ini
# [pytest]
# markers =
#     slow: slow tests
#     unix_only: runs only on Unix/Linux/macOS


import sys
import pytest


def test_fast_check():
    assert 'py' in 'pytest'


@pytest.mark.slow
@pytest.mark.unix_only
@pytest.mark.skipif(sys.platform == 'win32', reason='uses POSIX file permissions')
def test_file_permissions():
    import os
    import tempfile
    with tempfile.NamedTemporaryFile() as f:
        os.chmod(f.name, 0o644)
        mode = oct(os.stat(f.name).st_mode)[-3:]
        assert mode == '644'
Solution
# pytest.ini
# [pytest]
# markers =
#     slow: slow tests
#     unix_only: runs only on Unix/Linux/macOS


import sys
import pytest


def test_fast_check():
    assert 'py' in 'pytest'


@pytest.mark.slow
@pytest.mark.unix_only
@pytest.mark.skipif(sys.platform == 'win32', reason='uses POSIX file permissions')
def test_file_permissions():
    import os
    import tempfile
    with tempfile.NamedTemporaryFile() as f:
        os.chmod(f.name, 0o644)
        mode = oct(os.stat(f.name).st_mode)[-3:]
        assert mode == '644'