Mathew K Analytics

Lesson 5 · Pytest deep dive

pytest Tutorial #5: Markers — Organising and Selecting Tests

On testing real Python code with pytest. skip, skipif, xfail, and defining custom markers. Let's get into it. @pytest.mark.skip unconditionally skips a…

⬇ Download notebook

📓 Full notebook

Download .ipynb

pytest Deep-Dive, Video 5: Markers#

  • Video five of an eighteen-part series on testing real Python code with pytest.
  • skip, skipif, xfail, and defining custom markers.
  • Let's get into it.

Part 1: The Helper From Last Time#

import pytest, tempfile, os
def run_pytest(code_str, args=None):
    with tempfile.NamedTemporaryFile(mode='w', suffix='_test.py', delete=False) as f:
        f.write(code_str)
        path = f.name
    result = pytest.main([path, *(args or [])])
    os.unlink(path)
    return result

Part 2: @pytest.mark.skip - Unconditionally Skipping a Test#

code = '''
import pytest

@pytest.mark.skip(reason='not implemented yet')
def test_future_feature():
    assert False

def test_normal():
    assert True
'''
exit_code = run_pytest(code, ['-v', '--no-header'])
print(exit_code)
============================= test session starts =============================
collecting ... collected 2 items

..\..\..\Users\makmw\AppData\Local\Temp\tmpklml2fc3_test.py::test_future_feature SKIPPED [ 50%]
..\..\..\Users\makmw\AppData\Local\Temp\tmpklml2fc3_test.py::test_normal PASSED [100%]

======================== 1 passed, 1 skipped in 0.20s =========================
0

Part 3: @pytest.mark.skipif - Conditional Skipping#

code = '''
import sys
import pytest

@pytest.mark.skipif(sys.version_info < (3, 8), reason='requires Python 3.8+')
def test_needs_modern_python():
    assert sys.version_info >= (3, 8)

@pytest.mark.skipif(sys.version_info >= (4, 0), reason='not relevant on Python 4')
def test_still_python_three():
    assert sys.version_info.major == 3
'''
exit_code = run_pytest(code, ['-v', '--no-header'])
print(exit_code)
============================= test session starts =============================
collecting ... collected 2 items

..\..\..\Users\makmw\AppData\Local\Temp\tmpmfz0z8_r_test.py::test_needs_modern_python PASSED [ 50%]
..\..\..\Users\makmw\AppData\Local\Temp\tmpmfz0z8_r_test.py::test_still_python_three PASSED [100%]

============================== 2 passed in 0.11s ==============================
0

Part 4: @pytest.mark.xfail - Expected Failure#

code = '''
import pytest

def buggy_reverse(items):
    return items

@pytest.mark.xfail(reason='buggy_reverse does not actually reverse yet')
def test_buggy_reverse():
    assert buggy_reverse([1, 2, 3]) == [3, 2, 1]
'''
exit_code = run_pytest(code, ['-v', '--no-header'])
print(exit_code)
============================= test session starts =============================
collecting ... collected 1 item

..\..\..\Users\makmw\AppData\Local\Temp\tmpk_eodl5c_test.py::test_buggy_reverse XFAIL [100%]

============================= 1 xfailed in 0.30s ==============================
0

Part 5: xfail(strict=True) - Catching an Unexpected Pass#

code = '''
import pytest

def already_fixed_reverse(items):
    return list(reversed(items))

@pytest.mark.xfail(reason='thought this was still broken', strict=True)
def test_incorrectly_still_marked_xfail():
    assert already_fixed_reverse([1, 2, 3]) == [3, 2, 1]
'''
exit_code = run_pytest(code, ['-v', '--no-header'])
print(exit_code)
============================= test session starts =============================
collecting ... collected 1 item

..\..\..\Users\makmw\AppData\Local\Temp\tmpejb6a1op_test.py::test_incorrectly_still_marked_xfail FAILED [100%]

================================== FAILURES ===================================
_____________________ test_incorrectly_still_marked_xfail _____________________
[XPASS(strict)] thought this was still broken
=========================== short test summary info ===========================
FAILED ..\..\..\Users\makmw\AppData\Local\Temp\tmpejb6a1op_test.py::test_incorrectly_still_marked_xfail - [XPASS(strict)] thought this was still broken
============================== 1 failed in 0.11s ==============================
1

Part 6: Custom Markers and -m Filtering#

code = '''
import pytest

@pytest.mark.slow
def test_slow_computation():
    total = sum(range(1000))
    assert total == 499500

def test_fast_check():
    assert 1 + 1 == 2
'''
print('--- running everything ---')
run_pytest(code, ['-v', '--no-header', '-W', 'ignore::pytest.PytestUnknownMarkWarning'])
print('--- running only -m slow ---')
exit_code = run_pytest(code, ['-v', '--no-header', '-m', 'slow', '-W', 'ignore::pytest.PytestUnknownMarkWarning'])
print(exit_code)
--- running everything ---
============================= test session starts =============================
collecting ... collected 2 items

..\..\..\Users\makmw\AppData\Local\Temp\tmp660pnxs9_test.py::test_slow_computation PASSED [ 50%]
..\..\..\Users\makmw\AppData\Local\Temp\tmp660pnxs9_test.py::test_fast_check PASSED [100%]

============================== 2 passed in 0.10s ==============================
--- running only -m slow ---
============================= test session starts =============================
collecting ... collected 2 items / 1 deselected / 1 selected

..\..\..\Users\makmw\AppData\Local\Temp\tmpmkkkd1ht_test.py::test_slow_computation PASSED [100%]

======================= 1 passed, 1 deselected in 0.09s =======================
0

Part 7: A Real Pattern - Platform-Sensitive Tests#

code = '''
import sys
import pytest

def normalize_case_for_platform(name, platform):
    if platform.startswith('win') or platform == 'darwin':
        return name.lower()
    return name

@pytest.mark.skipif(not sys.platform.startswith('linux'), reason='linux-specific case-sensitivity check')
def test_linux_keeps_case():
    assert normalize_case_for_platform('MyFile.TXT', 'linux') == 'MyFile.TXT'

@pytest.mark.xfail(reason='non-ASCII casing does not collapse to plain ASCII yet')
def test_windows_unicode_casing():
    assert normalize_case_for_platform('stanbul.txt', 'win32') == 'istanbul.txt'
'''
exit_code = run_pytest(code, ['-v', '--no-header'])
print(exit_code)
============================= test session starts =============================
collecting ... collected 2 items

..\..\..\Users\makmw\AppData\Local\Temp\tmpdak2gw0f_test.py::test_linux_keeps_case SKIPPED [ 50%]
..\..\..\Users\makmw\AppData\Local\Temp\tmpdak2gw0f_test.py::test_windows_unicode_casing XFAIL [100%]

======================== 1 skipped, 1 xfailed in 0.14s ========================
0

Wrap-Up: What You Learned#

  • @pytest.mark.skip unconditionally skips a test, reported skipped rather than passed or failed.
  • @pytest.mark.skipif skips a test only when a given boolean condition evaluates true at collection time.
  • @pytest.mark.xfail marks a test as expected to fail, reported XFAIL without failing the overall run.
  • xfail(strict=True) turns an unexpected pass, XPASS, into a genuine failure, catching a stale marker.
  • Any name after pytest.mark, like slow, becomes a custom marker; -m filters the run to matching tests.
  • Custom markers trigger a warning until formally registered, filterable for a quick demo.
  • skipif and xfail combine naturally to document platform differences and known, unfixed edge cases.
  • That wraps up markers. Next up: Assertions Deep-Dive.

Found this useful?

All lessons, notebooks and datasets here are free. If they helped you, a coffee keeps new lessons coming.