Skip to content

Chapter 4 · Organizing Tests and Marks ​

Chapter goals: organize tests sensibly with classes and modules, handle "can't run yet" cases with built-in markers (skip/xfail), and tag tests for classification with custom marks.

4.1 Organizing Tests into Classes ​

As your suite grows, cases for the same feature should live together. pytest supports grouping tests inside Test-prefixed classes; there are only two rules:

  • The class name must start with Test and must not define an __init__ method (otherwise the whole class is skipped during collection);
  • Test methods start with test_ and need no base-class inheritance.
python
# tests/test_cart.py
class TestAddItem:
    """Cart add-item test group"""

    def test_add_new_item(self):
        cart = Cart()
        cart.add("apple", qty=2)
        assert cart.count("apple") == 2

    def test_add_same_item_merges(self):
        cart = Cart()
        cart.add("apple", qty=1)
        cart.add("apple", qty=3)
        assert cart.count("apple") == 4


class TestRemoveItem:
    def test_remove_existing(self):
        ...

Why __init__ Is Forbidden

pytest's fixture mechanism (from Chapter 6 onward) replaces the traditional constructor-initialization approach; a Test class defining __init__ gets silently skipped — one of the most common "why aren't my tests running" causes among newcomers.

For module-level layout, the official docs recommend one of two layouts: tests separated from source (src/mypkg/ plus a top-level tests/) combined with an editable install (pip install -e .). New projects should also enable --import-mode=importlib in config to avoid clashes between same-named test files.

4.2 Built-in Marks: skip and skipif ​

A mark is metadata attached to a test function. The most-used family is "skipping":

python
import sys

import pytest


@pytest.mark.skip(reason="feature not implemented yet")
def test_future_feature():
    ...


@pytest.mark.skipif(sys.platform == "win32", reason="POSIX platforms only")
def test_posix_paths():
    ...


# keeping conditions in module-level variables is easier to maintain
SUPPORTS_ORJSON = True


@pytest.mark.skipif(not SUPPORTS_ORJSON, reason="requires orjson")
def test_fast_json():
    ...

Unconditional skipping uses skip; conditional uses skipif(cond, reason=...). Skipped tests show as s in reports along with your reason — always state why, since nobody remembers six months later.

Runtime dynamic skipping has its own tool: pytest.skip(), typically used inside fixtures when the environment turns out to be unsuitable.

4.3 Built-in Marks: xfail — Expected Failure ​

Some cases "should fail now but will be fixed later", e.g. waiting on an unmerged upstream bugfix. Hard-skipping hides the moment they actually get better; xfail keeps executing them:

python
@pytest.mark.xfail(reason="waiting on upstream fix #123")
def test_upstream_bug():
    result = buggy_function()
    assert result.correct


@pytest.mark.xfail(strict=True, reason="strict mode: passing means failure")
def test_will_be_fixed_soon():
    ...

The outcome semantics are worth memorizing:

  • Test fails → xfailed (the x in reports): expected;
  • Test unexpectedly passes → xpassed (the X);
  • With strict=True, "unexpectedly passes" counts as a failure, forcing you to remove the mark as soon as the issue is fixed.

4.4 Custom Marks and Registration ​

A custom mark is just any decorator tagging tests with business labels:

python
# tests/test_payment.py
import pytest


@pytest.mark.slow
@pytest.mark.payment
def test_full_refund_flow():
    ...

An unregistered mark triggers PytestUnknownMarkWarning on every use — that's a guard against typo'd mark names. The right move is registering them in your config file (Chapter 11 covers config files themselves):

toml
# pyproject.toml
[pytest]
markers = [
    "slow: slow tests, excluded daily via -m 'not slow'",
    "payment: payment-domain cases",
]

For stricter setups add --strict-markers (or configure strict_markers = true): using an unregistered mark then errors out instead of warning. Recommended for every serious project.

bash
pytest --markers        # list all registered marks and their descriptions
pytest -m slow          # run only slow tests
pytest -m "not slow"    # exclude slow tests

4.5 Stacking Marks and Scopes ​

Marks stack freely and can also decorate whole classes or modules:

python
# class-level mark: applies to every test inside
@pytest.mark.slow
class TestEtlPipeline:
    def test_extract(self):
        ...

    def test_load(self):
        ...


# module-level mark: assigned to the conventional global pytestmark
pytestmark = pytest.mark.skipif(sys.version_info < (3, 10), reason="requires Python 3.10+")

Two boundaries to note:

  • Marks only affect tests; they have no effect on fixtures;
  • -m expressions support and / or / not, e.g. pytest -m "payment and not slow" precisely scopes a run — the core speed-up lever in day-to-day development on large projects.

4.6 Chapter Summary ​

  • Test-prefixed classes without __init__ group tests; src/tests separation plus importlib import mode is the recommended setup for new projects;
  • skip(reason=...) skips unconditionally, skipif(cond, reason=...) conditionally; both display s;
  • xfail means "expected failure": failing yields xfailed, unexpected passing yields xpassed, and strict=True escalates xpassed to FAILED;
  • Custom marks must be registered in config (or enable --strict-markers) or you get warnings/errors;
  • -m "not slow" is a daily-development efficiency weapon.

🧪 随堂测验

点击你认为正确的选项。答错时会展示正确答案与原因解析。

1. Which class’s test_ methods will pytest NOT collect?

2. A test decorated @pytest.mark.xfail(strict=True) unexpectedly passes. What happens?

3. You use an unregistered custom mark @pytest.mark.slwo (typo). What happens by default?

4. Which command runs only payment-related tests while excluding slow ones?

🛠️ Hands-On Exercises ​

  1. Add two custom marks slow and integration to your project, register them in pyproject.toml, then verify pytest -m "not slow" excludes them.
  2. Write a time-dependent case and use skipif to auto-skip it on weekends (hint: datetime.date.today().weekday() >= 5).
  3. Convert a known environment-dependent failure into xfail(strict=True), then satisfy the dependency and watch the enforced xpassed→FAILED reminder fire.

With organization and tagging covered, the next chapter introduces pytest's density multiplier: parametrization.