Right, so you’ve got a test suite. It’s beautiful. A sprawling, intricate tapestry of logic that validates every possible state of your application. Except, of course, for that one function that only works on Tuesdays, or the new API endpoint that the backend team swears they’ll finish next sprint. If you were to run your entire suite right now, it would light up like a Christmas tree—not with joy, but with the searing red of failure for things you know aren’t ready.

This is where most testing frameworks would just let you fail. Not pytest. Pytest, in its infinite wisdom, gives you the tools to be a responsible adult about this mess. Instead of just commenting out tests or, heaven forbid, deleting them, you can formally and declaratively say, “I know about this, it’s not a secret, but let’s not talk about it today.” That’s the power of skipping, expected failures, and custom marks.

The Art of the Graceful Skip (@pytest.mark.skip)

Sometimes, you just have to admit defeat. The test is broken because of an external dependency that’s down, it’s for a Python version you don’t have, or it’s that Tuesday thing. For these cases, you use @pytest.mark.skip. It’s your white flag. When pytest sees this decorator, it doesn’t run the test. At all. It just logs a polite “Skipped” and moves on with its life.

The basic form is straightforward. You just slap the decorator on and give a reason. Always give a reason. Your future self, wondering why a critical test isn’t running, will thank you.

import pytest
import sys

@pytest.mark.skip(reason="This API is currently on fire. Literally.")
def test_external_service_integration():
    # ... code that would definitely fail right now
    assert False

@pytest.mark.skipif(sys.version_info < (3, 8), reason="Requires Python 3.8+ for walrus operator")
def test_using_walrus_operator():
    # This syntax is a SyntaxError in older versions
    if (result := some_complex_function()) is not None:
        assert result > 0

The output in your terminal will be a calming yellow s instead of a frantic red F. It’s the difference between “I’m aware of this” and “Everything is broken.”

Expecting Failure, on Purpose (@pytest.mark.xfail)

Now, skip is for when you don’t even want to try. xfail is for a much more nuanced and, frankly, common scenario: you have a known bug. There’s a ticket for it in JIRA. The product manager has acknowledged it. But it’s not fixed yet. You still want the test to run, because once the bug is fixed, this test should start passing and you want to know! But for now, you expect it to fail.

Using @pytest.mark.xfail tells pytest, “I am expecting this test to fail. If it does, that’s fine, mark it as an expected failure. But if it passes… well, that’s interesting! That might mean the bug is fixed!”

import pytest

@pytest.mark.xfail(reason="Bug report #XYZ: Division by zero not handled", strict=True)
def test_known_buggy_math():
    # We know this function crashes with ZeroDivisionError on input 0
    result = buggy_math_function(0)
    assert result == 0  # This is what it *should* do

def buggy_math_function(x):
    return 10 / x  # Yikes.

Here’s the kicker: the strict=True parameter. Without it, if the test passes, pytest logs a yellow “XPASS” (an unexpected pass). This is a good thing, a gentle nudge. With strict=True, an unexpected pass is treated as a full-blown test failure. This is the nuclear option for when a passing test would be a genuine surprise that needs immediate attention, like if your fix was accidentally reverted.

Organizing with Custom Marks (@pytest.mark.my_custom_mark)

Skipping and xfail are built-in marks. The real power comes from defining your own. Custom marks are essentially labels you stick on your tests to categorize them. The most common use case? Running a subset of your tests.

Maybe you have a few tests that are painfully slow because they hit a real database. You don’t want to run them every single time you save a file. The solution? Mark them.

First, you define the mark. You can do this in your pytest.ini file to avoid warnings:

# pytest.ini
[pytest]
markers =
    slow: marks tests as slow (deselect with -m "not slow")
    integration: tests that require external systems

Then, you use it:

@pytest.mark.slow
def test_really_slow_database_integration():
    """This might take a minute."""
    # ... complicated database setup and teardown
    assert True

def test_fast_unit_test():
    """This runs instantly."""
    assert 1 + 1 == 2

Now, for your daily development, you run pytest -m "not slow" and blaze through your fast unit tests. When you’re ready to commit, you run the whole suite with pytest to make sure your slow integration tests still pass. It’s the perfect balance between speed and confidence. You can create marks for anything: smoke, windows_only, foo_regression. They’re your personal organizational system, and they’re incredibly powerful.

The key takeaway here is control. These features don’t let you ignore problems; they let you manage them with intention. Your test suite becomes a living document of what works, what’s broken, what’s slow, and what’s platform-specific. It’s honesty in code, and it’s one of the main reasons pytest leaves other testing frameworks in the dust.