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 або *_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` -- не змінюючи жодного твердження.

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'` і переконайтеся, що виконуються лише 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