Mathew K Analytics

Lesson 9 · Pytest deep dive

pytest Tutorial #9: Test Organisation That Scales

On testing real Python code with pytest. Test classes, the init gotcha, and xunit-style setup and teardown. Let's get into it. A class named Test-something,…

⬇ Download notebook

What you'll learn

Data

No separate download needed — the notebook creates or downloads everything it uses.

📓 Full notebook

Download .ipynb

pytest Deep-Dive, Video 9: Test Organization#

  • Video nine of an eighteen-part series on testing real Python code with pytest.
  • Test classes, the init gotcha, and xunit-style setup and teardown.
  • 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: Grouping Tests With a Test Class#

code = '''
class TestStringMethods:
    def test_upper(self):
        assert 'hello'.upper() == 'HELLO'

    def test_strip(self):
        assert '  hi  '.strip() == 'hi'

    def test_split(self):
        assert 'a,b,c'.split(',') == ['a', 'b', 'c']
'''
exit_code = run_pytest(code, ['-v', '--no-header'])
print(exit_code)
============================= test session starts =============================
collecting ... collected 3 items

..\..\..\Users\makmw\AppData\Local\Temp\tmp1d_1q8rj_test.py::TestStringMethods::test_upper PASSED [ 33%]
..\..\..\Users\makmw\AppData\Local\Temp\tmp1d_1q8rj_test.py::TestStringMethods::test_strip PASSED [ 66%]
..\..\..\Users\makmw\AppData\Local\Temp\tmp1d_1q8rj_test.py::TestStringMethods::test_split PASSED [100%]

============================== 3 passed in 0.25s ==============================
0

Part 3: A Real Gotcha - a Class With init Is NOT Collected#

code = '''
class TestWithInit:
    def __init__(self):
        self.value = 1

    def test_a(self):
        assert self.value == 1

class TestNormal:
    def test_b(self):
        assert True
'''
exit_code = run_pytest(code, ['-v', '--no-header'])
print(exit_code)
============================= test session starts =============================
collecting ... collected 1 item

..\..\..\Users\makmw\AppData\Local\Temp\tmpe6hx57ab_test.py::TestNormal::test_b PASSED [100%]

============================== warnings summary ===============================
..\..\..\Users\makmw\AppData\Local\Temp\tmpe6hx57ab_test.py:2
  C:\Users\makmw\AppData\Local\Temp\tmpe6hx57ab_test.py:2: PytestCollectionWarning: cannot collect test class 'TestWithInit' because it has a __init__ constructor (from: tmpe6hx57ab_test.py)
    class TestWithInit:

-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
======================== 1 passed, 1 warning in 0.11s =========================
0

Part 4: xunit-Style setup_method and teardown_method#

code = '''
events = []

class TestLifecycle:
    def setup_method(self, method):
        events.append(f'setup for {method.__name__}')

    def teardown_method(self, method):
        events.append(f'teardown for {method.__name__}')

    def test_one(self):
        events.append('body one')

    def test_two(self):
        events.append('body two')

def test_check_events():
    assert events == [
        'setup for test_one', 'body one', 'teardown for test_one',
        'setup for test_two', 'body two', 'teardown for test_two',
    ]
'''
exit_code = run_pytest(code, ['-v', '--no-header'])
print(exit_code)
============================= test session starts =============================
collecting ... collected 3 items

..\..\..\Users\makmw\AppData\Local\Temp\tmpfw5ttc5p_test.py::TestLifecycle::test_one PASSED [ 33%]
..\..\..\Users\makmw\AppData\Local\Temp\tmpfw5ttc5p_test.py::TestLifecycle::test_two PASSED [ 66%]
..\..\..\Users\makmw\AppData\Local\Temp\tmpfw5ttc5p_test.py::test_check_events PASSED [100%]

============================== 3 passed in 0.12s ==============================
0

Part 5: setup_class and teardown_class - Once Per Class#

code = '''
build_count = {"n": 0}

class TestSharedConnection:
    @classmethod
    def setup_class(cls):
        build_count["n"] += 1
        cls.connection = {'status': 'connected'}

    @classmethod
    def teardown_class(cls):
        cls.connection = None

    def test_first(self):
        assert self.connection['status'] == 'connected'

    def test_second(self):
        assert self.connection['status'] == 'connected'
        assert build_count["n"] == 1
'''
exit_code = run_pytest(code, ['-v', '--no-header'])
print(exit_code)
============================= test session starts =============================
collecting ... collected 2 items

..\..\..\Users\makmw\AppData\Local\Temp\tmp_zppswvs_test.py::TestSharedConnection::test_first PASSED [ 50%]
..\..\..\Users\makmw\AppData\Local\Temp\tmp_zppswvs_test.py::TestSharedConnection::test_second PASSED [100%]

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

Part 6: A Real Pattern - Organizing by Feature Area#

code = '''
def validate_email(email):
    return '@' in email and '.' in email.split('@')[-1]

def normalize_email(email):
    return email.strip().lower()

class TestEmailValidation:
    def test_valid_email_accepted(self):
        assert validate_email('user@example.com') is True

    def test_missing_at_sign_rejected(self):
        assert validate_email('userexample.com') is False

    def test_missing_domain_dot_rejected(self):
        assert validate_email('user@examplecom') is False

class TestEmailNormalization:
    def test_lowercases_email(self):
        assert normalize_email('USER@Example.com') == 'user@example.com'

    def test_strips_whitespace(self):
        assert normalize_email('  user@example.com  ') == 'user@example.com'
'''
exit_code = run_pytest(code, ['-v', '--no-header'])
print(exit_code)
============================= test session starts =============================
collecting ... collected 5 items

..\..\..\Users\makmw\AppData\Local\Temp\tmpc_5zqy0n_test.py::TestEmailValidation::test_valid_email_accepted PASSED [ 20%]
..\..\..\Users\makmw\AppData\Local\Temp\tmpc_5zqy0n_test.py::TestEmailValidation::test_missing_at_sign_rejected PASSED [ 40%]
..\..\..\Users\makmw\AppData\Local\Temp\tmpc_5zqy0n_test.py::TestEmailValidation::test_missing_domain_dot_rejected PASSED [ 60%]
..\..\..\Users\makmw\AppData\Local\Temp\tmpc_5zqy0n_test.py::TestEmailNormalization::test_lowercases_email PASSED [ 80%]
..\..\..\Users\makmw\AppData\Local\Temp\tmpc_5zqy0n_test.py::TestEmailNormalization::test_strips_whitespace PASSED [100%]

============================== 5 passed in 0.12s ==============================
0

Wrap-Up: What You Learned#

  • A class named Test-something, with methods named test_something, groups related tests together.
  • A Test class defining its own init is silently uncollectable; pytest warns and skips the whole class.
  • setup_method and teardown_method run before and after every test method, xunit-style.
  • setup_class and teardown_class, marked as classmethods, run once per class, shared by every method.
  • Splitting one module into several classes keeps a growing test suite organized by feature area.
  • That wraps up test organization. Next up: Parametrized and Indirect Fixtures.

Found this useful?

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