## 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.
## 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.
**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
```