Python · Тестирование с pytest · Начальный

Организация тестов

5 задач

Структурируйте тестовые файлы и директории для удобного поиска и поддержки.

Обнаружение тестов и соглашения об именовании

#
**Зачем отделять тесты от производственного кода?** Тесты не являются частью вашего продукта. Они живут рядом с кодом, чтобы верифицировать его, но пользователи никогда их не запускают. Хранение тестов в выделенном каталоге `tests/` (или в файлах `test_*.py`) делает это разделение явным, избегает засорения импортов и даёт контроль над тем, что попадёт в сборку для деплоя. **Как pytest обнаруживает тесты:** pytest обходит дерево каталогов проекта и применяет следующие правила на каждом уровне: 1. **Уровень файлов** -- собирать файлы, соответствующие `test_*.py` или `*_test.py` 2. **Уровень модуля** -- собирать функции, имя которых начинается с `test_` 3. **Уровень классов** -- собирать классы, имя которых начинается с `Test` (без `__init__`) 4. **Уровень методов** -- собирать методы внутри классов `Test*`, начинающиеся с `test_` Несколько примеров того, что собирается и что пропускается: ``` test_math.py -> собирается math_test.py -> собирается math.py -> не собирается helpers.py -> не собирается def test_add(): -> собирается def add_test(): -> не собирается (нет префикса test_) def check_add(): -> не собирается class TestMath: -> собирается class MathTest: -> не собирается (неверный префикс) class TestMath: def test_add(): -> собирается def add_check(): -> не собирается ``` Запустите `pytest --collect-only`, чтобы увидеть, что именно будет собрано, прежде чем что-то запускать. **Обычные функции vs тестовые классы:** Обычные функции проще и достаточны для большинства случаев: ```python def test_add(): assert add(2, 3) == 5 def test_subtract(): assert subtract(5, 3) == 2 ``` Используйте классы, когда у вас есть группа связанных тестов, объединённых одной концепцией -- классы работают как пространство имён и делают связь явной: ```python class TestUserAccount: def test_creation(self): ... def test_deactivation(self): ... def test_password_change(self): ... ``` Классы также поддерживают `setup_method` / `teardown_method` для инициализации перед каждым тестом. Однако для совместно используемых *данных* фикстуры (рассматриваются далее) являются более чистым решением, чем состояние на уровне класса. **Рекомендуемая структура проекта:** ``` my_project/ ├── src/ │ ├── users.py │ └── products.py ├── tests/ │ ├── conftest.py <- общие фикстуры (рассматриваются в следующей теме) │ ├── test_users.py │ └── test_products.py └── pytest.ini ``` Отражение структуры `src/` внутри `tests/` позволяет легко найти тесты для любого модуля.

Обнаружение в действии: функции, классы и структура

#
**Что показывает --collect-only:** Перед запуском тестов можно предварительно просмотреть, что pytest соберёт: ```bash pytest --collect-only ``` Вывод для типичного проекта: ``` <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> ``` Это полезно для проверки правильности соглашений об именовании. **Обычные функции -- стиль по умолчанию:** ```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' ``` **На основе классов -- полезно для группировки:** ```python # test_string_utils.py (версия с классами) 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' ``` **Инициализация перед каждым тестом через setup_method:** ```python class TestShoppingCart: def setup_method(self): # выполняется перед каждым тест-методом; каждый тест получает свежую корзину 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` запускается перед *каждым* тест-методом. `teardown_method` (определяется аналогично) запускается после. Это гарантирует, что каждый тест начинается с чистого листа -- состояние не протекает между тестами. **Запуск конкретных тестов через -k:** ```bash pytest -k "reverse" # тесты, чей node ID содержит "reverse" pytest -k "not slow" # исключить тесты с именем "slow" pytest -k "add or subtract" # объединение pytest tests/test_math.py # только один файл pytest tests/test_math.py::TestMath::test_add # один конкретный тест ```

Краткий справочник: организация тестов

#
**Правила обнаружения:** ``` test_*.py or *_test.py <- имена файлов, которые собирает pytest test_* <- имена функций/методов Test* <- имена классов (без __init__) ``` **Рекомендуемая структура проекта:** ``` project/ ├── src/ │ └── mymodule.py ├── tests/ │ ├── conftest.py <- общие фикстуры (рассматриваются позже) │ └── test_mymodule.py └── pytest.ini ``` **pytest.ini (минимальная конфигурация):** ```ini [pytest] testpaths = tests addopts = -v --tb=short ``` **Запуск подмножеств:** ```bash pytest tests/test_users.py # один файл pytest -k "login" # имя содержит "login" pytest tests/test_users.py::TestAuth::test_login # один конкретный тест pytest --collect-only # показать, что будет запущено ```
01

Реорганизация плоских тестов в классы

#

В файле ниже есть 6 плоских тест-функций для двух тем (разворот строк и фильтрация списков). Реорганизуйте их в два класса `Test*` -- `TestReverse` и `TestFilter` -- не изменяя ни одного оператора assert.

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([]) == []
Решение
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

Создание класса TestStringUtils

#

Создайте класс `TestStringUtils` с тремя тест-методами: один для `capitalize_words(s)` (делает первую букву каждого слова заглавной), один для `count_vowels(s)` (считает a, e, i, o, u без учёта регистра) и один для `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  # добавьте три тест-метода здесь
Решение
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

Добавление setup_method для общего состояния

#

Добавьте `setup_method` в класс `TestShoppingCart`, чтобы каждый тест получал свежую корзину. Корзина должна быть словарём `{'items': [], 'total': 0}`. Убедитесь, что `test_cart_still_empty_after_other_test` проходит даже после `test_can_add_item`.

class TestShoppingCart:
    # добавьте setup_method здесь

    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):
        # должен проходить даже если test_can_add_item выполнился первым
        assert self.cart['items'] == []
Решение
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

Разделение тестов по файлам и фильтрация через -k

#

Создайте два тестовых файла: `test_math.py` с тестами для `add` и `multiply`, и `test_strings.py` с тестами для `str.upper()` и `str.lower()`. Запустите `pytest -k 'math'` и убедитесь, что выполняются только тесты для математических операций. Затем запустите `pytest -k 'multiply'`.

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

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

# напишите test_add и test_multiply здесь


# test_strings.py
# напишите test_upper и test_lower
Решение
# 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

Создание структуры src/ + tests/

#

Создайте минимальную структуру проекта: поместите `string_utils.py` с функцией `slugify(s)` (переводит в нижний регистр и заменяет пробелы на дефисы) в `src/`, поместите два теста в `tests/test_string_utils.py` и добавьте `pytest.ini` с `testpaths = tests`. Запустите pytest из корня проекта.

# src/string_utils.py
def slugify(s):
    pass  # нижний регистр + замена пробелов дефисами


# tests/test_string_utils.py
# импортируйте и протестируйте slugify здесь


# pytest.ini
# [pytest]
# testpaths = tests
Решение
# 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