Python · Testing with pytest · Beginner

Organizing Tests

5 tasks

Structure test files and directories for discoverability and maintainability.

Test Discovery and Naming Conventions

#
**Why keep tests separate from production code?** Tests are not part of your shipping product. They live alongside your code to verify it, but users never run them. Keeping tests in a dedicated `tests/` directory (or in `test_*.py` files) makes this separation clear, avoids cluttering imports, and gives you control over what goes into a deployment package. **How pytest discovers tests:** pytest walks your project directory tree and applies these rules at each level: 1. **File level** — collect files matching `test_*.py` or `*_test.py` 2. **Module level** — collect functions whose name starts with `test_` 3. **Class level** — collect classes whose name starts with `Test` (no `__init__`) 4. **Method level** — collect methods inside `Test*` classes starting with `test_` A few examples of what gets collected vs skipped: ``` test_math.py → collected math_test.py → collected math.py → not collected helpers.py → not collected def test_add(): → collected def add_test(): → not collected (no test_ prefix) def check_add(): → not collected class TestMath: → collected class MathTest: → not collected (wrong prefix) class TestMath: def test_add(): → collected def add_check(): → not collected ``` You can run `pytest --collect-only` to see exactly what would be collected before actually running anything. **Plain functions vs test classes:** Plain functions are simpler and sufficient for most cases: ```python def test_add(): assert add(2, 3) == 5 def test_subtract(): assert subtract(5, 3) == 2 ``` Use classes when you have a group of related tests that share a concept — classes act as a namespace and make the relationship explicit: ```python class TestUserAccount: def test_creation(self): ... def test_deactivation(self): ... def test_password_change(self): ... ``` Classes also support `setup_method` / `teardown_method` for per-test setup. However, for shared *data*, fixtures (covered next) are a cleaner solution than class-level state. **Recommended project layout:** ``` my_project/ ├── src/ │ ├── users.py │ └── products.py ├── tests/ │ ├── conftest.py ← shared fixtures (covered in a later topic) │ ├── test_users.py │ └── test_products.py └── pytest.ini ``` Mirroring the `src/` structure inside `tests/` makes it easy to find tests for any given module.

Discovery in Action: Functions, Classes, and Layout

#
**What --collect-only shows you:** Before running tests, you can preview what pytest would collect: ```bash pytest --collect-only ``` Output for a typical project: ``` <Module test_math.py> <Function test_add> <Function test_subtract> <Module test_strings.py> <Class TestReverse> <Function test_basic> <Function test_empty> <Function test_slugify> ``` This is useful for confirming your naming conventions are correct. **Plain functions — the default style:** ```python # test_string_utils.py from string_utils import reverse, truncate def test_reverse_basic(): assert reverse('hello') == 'olleh' def test_reverse_empty(): assert reverse('') == '' def test_truncate_longer(): assert truncate('hello world', 5) == 'hello' def test_truncate_within_limit(): assert truncate('hi', 10) == 'hi' ``` **Class-based — useful for grouping:** ```python # test_string_utils.py (class version) from string_utils import reverse, truncate class TestReverse: def test_basic(self): assert reverse('hello') == 'olleh' def test_empty_string(self): assert reverse('') == '' def test_single_char(self): assert reverse('x') == 'x' class TestTruncate: def test_longer_than_limit(self): assert truncate('hello world', 5) == 'hello' def test_within_limit(self): assert truncate('hi', 10) == 'hi' def test_exact_limit(self): assert truncate('hello', 5) == 'hello' ``` **Per-test setup with setup_method:** ```python class TestShoppingCart: def setup_method(self): # runs before each test method; each test gets a fresh cart self.cart = {'items': [], 'total': 0.0} def test_empty_on_creation(self): assert self.cart['items'] == [] def test_total_starts_at_zero(self): assert self.cart['total'] == 0.0 def test_add_item(self): self.cart['items'].append('apple') assert 'apple' in self.cart['items'] ``` `setup_method` runs before *every* test method. `teardown_method` (defined the same way) runs after. This guarantees each test starts with a clean slate — no state leaks between tests. **Running specific tests with -k:** ```bash pytest -k "reverse" # run tests whose node ID contains "reverse" pytest -k "not slow" # exclude tests named "slow" pytest -k "add or subtract" # union pytest tests/test_math.py # one file only pytest tests/test_math.py::TestMath::test_add # one specific test ```

Organization Quick Reference

#
**Discovery rules:** ``` test_*.py or *_test.py ← file names pytest collects test_* ← function/method names Test* ← class names (no __init__) ``` **Recommended project layout:** ``` project/ ├── src/ │ └── mymodule.py ├── tests/ │ ├── conftest.py ← shared fixtures (covered later) │ └── test_mymodule.py └── pytest.ini ``` **pytest.ini (minimal config):** ```ini [pytest] testpaths = tests addopts = -v --tb=short ``` **Running subsets:** ```bash pytest tests/test_users.py # one file pytest -k "login" # name contains "login" pytest tests/test_users.py::TestAuth::test_login # one specific test pytest --collect-only # show what would run ```
01

Reorganize Flat Tests into Classes

#

The file below has 6 flat test functions for two topics (string reversal and list filtering). Reorganize them into two `Test*` classes — `TestReverse` and `TestFilter` — without changing any assert statements.

def reverse(s):
    return s[::-1]

def filter_positive(nums):
    return [n for n in nums if n > 0]


def test_reverse_hello():
    assert reverse('hello') == 'olleh'

def test_reverse_empty():
    assert reverse('') == ''

def test_reverse_single():
    assert reverse('a') == 'a'

def test_filter_mixed():
    assert filter_positive([1, -2, 3, -4]) == [1, 3]

def test_filter_all_negative():
    assert filter_positive([-1, -2]) == []

def test_filter_empty():
    assert filter_positive([]) == []
Solution
def reverse(s):
    return s[::-1]

def filter_positive(nums):
    return [n for n in nums if n > 0]


class TestReverse:
    def test_hello(self):
        assert reverse('hello') == 'olleh'

    def test_empty(self):
        assert reverse('') == ''

    def test_single_char(self):
        assert reverse('a') == 'a'


class TestFilter:
    def test_mixed_numbers(self):
        assert filter_positive([1, -2, 3, -4]) == [1, 3]

    def test_all_negative(self):
        assert filter_positive([-1, -2]) == []

    def test_empty_list(self):
        assert filter_positive([]) == []
02

Create a TestStringUtils Class

#

Create a `TestStringUtils` class with three test methods: one for `capitalize_words(s)` (capitalizes each word), one for `count_vowels(s)` (counts a, e, i, o, u case-insensitively), and one for `is_palindrome(s)`.

def capitalize_words(s):
    return ' '.join(word.capitalize() for word in s.split())

def count_vowels(s):
    return sum(1 for c in s.lower() if c in 'aeiou')

def is_palindrome(s):
    return s == s[::-1]


class TestStringUtils:
    pass  # add three test methods here
Solution
def capitalize_words(s):
    return ' '.join(word.capitalize() for word in s.split())

def count_vowels(s):
    return sum(1 for c in s.lower() if c in 'aeiou')

def is_palindrome(s):
    return s == s[::-1]


class TestStringUtils:
    def test_capitalize_words(self):
        assert capitalize_words('hello world') == 'Hello World'
        assert capitalize_words('python') == 'Python'

    def test_count_vowels(self):
        assert count_vowels('hello') == 2
        assert count_vowels('rhythm') == 0
        assert count_vowels('AEIOU') == 5

    def test_is_palindrome(self):
        assert is_palindrome('racecar') is True
        assert is_palindrome('hello') is False
        assert is_palindrome('') is True
03

Add setup_method for Shared State

#

Add `setup_method` to the `TestShoppingCart` class so each test gets a fresh cart. The cart should be a dict `{'items': [], 'total': 0}`. Verify that `test_cart_still_empty_after_other_test` passes even when run after `test_can_add_item`.

class TestShoppingCart:
    # add setup_method here

    def test_cart_starts_empty(self):
        assert self.cart['items'] == []

    def test_total_starts_at_zero(self):
        assert self.cart['total'] == 0

    def test_can_add_item(self):
        self.cart['items'].append('apple')
        assert len(self.cart['items']) == 1

    def test_cart_still_empty_after_other_test(self):
        # this must pass even if test_can_add_item ran first
        assert self.cart['items'] == []
Solution
class TestShoppingCart:
    def setup_method(self):
        self.cart = {'items': [], 'total': 0}

    def test_cart_starts_empty(self):
        assert self.cart['items'] == []

    def test_total_starts_at_zero(self):
        assert self.cart['total'] == 0

    def test_can_add_item(self):
        self.cart['items'].append('apple')
        assert len(self.cart['items']) == 1

    def test_cart_still_empty_after_other_test(self):
        assert self.cart['items'] == []
04

Split Tests Across Files and Filter with -k

#

Create two test files: `test_math.py` with tests for `add` and `multiply`, and `test_strings.py` with tests for `str.upper()` and `str.lower()`. Run `pytest -k 'math'` and verify only the math tests run. Then run `pytest -k 'multiply'`.

# test_math.py
def add(a, b):
    return a + b

def multiply(a, b):
    return a * b

# write test_add and test_multiply here


# test_strings.py
# write test_upper and test_lower
Solution
# test_math.py
def add(a, b):
    return a + b

def multiply(a, b):
    return a * b

def test_add():
    assert add(2, 3) == 5
    assert add(-1, 1) == 0

def test_multiply():
    assert multiply(3, 4) == 12
    assert multiply(0, 5) == 0


# test_strings.py
def test_upper():
    assert 'hello'.upper() == 'HELLO'

def test_lower():
    assert 'WORLD'.lower() == 'world'
05

Create a src/ + tests/ Layout

#

Create a minimal project layout: put `string_utils.py` with a `slugify(s)` function (lowercases and replaces spaces with dashes) inside `src/`, put two tests inside `tests/test_string_utils.py`, and add `pytest.ini` with `testpaths = tests`. Run pytest from the project root.

# src/string_utils.py
def slugify(s):
    pass  # lowercase + replace spaces with dashes


# tests/test_string_utils.py
# import and test slugify here


# pytest.ini
# [pytest]
# testpaths = tests
Solution
# src/string_utils.py
def slugify(s):
    return s.lower().replace(' ', '-')


# tests/test_string_utils.py
import sys, os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
from string_utils import slugify

def test_slugify_spaces():
    assert slugify('hello world') == 'hello-world'

def test_slugify_uppercase():
    assert slugify('Hello World') == 'hello-world'


# pytest.ini
# [pytest]
# testpaths = tests