Python · Синтаксис · Просунутий рівень

Тестування з pytest

10 завдань

Пишіть надійні тести за допомогою pytest. Охоплює тестові функції, фікстури, parametrize, monkeypatch та тестування винятків.

pytest основи, assertions, parametrize, тестування винятків

#
**Навіщо тести і чому pytest** Тести — це виконуваня документація. Вони доводять що ваш код робить те що ви думаєте, і ловлять регресії при змінах. pytest — де-факто стандарт: менше шаблонного коду ніж `unittest`, потужний parametrize, величезна екосистема плагінів. **Перший тест на pytest** Будь-яка функція починається з `test_` у файлі починається з `test_` — автоматично виявляється: ```python # test_math.py def add(a, b): return a + b def test_add_integers(): assert add(2, 3) == 5 def test_add_floats(): assert add(0.1, 0.2) == pytest.approx(0.3) ``` **pytest assertions vs unittest** pytest переписує `assert` щоб показувати детальні diff при невдачі: ```python # стиль unittest: self.assertEqual(result, [1, 2, 3]) self.assertRaises(ValueError, func, bad_arg) # стиль pytest — чистий Python: assert result == [1, 2, 3] with pytest.raises(ValueError): func(bad_arg) ``` **`@pytest.mark.parametrize` — тестування кількох вхідних даних** ```python import pytest @pytest.mark.parametrize('a, b, expected', [ (1, 2, 3), (0, 0, 0), (-1, 1, 0), ]) def test_add(a, b, expected): assert add(a, b) == expected ``` **Тестування винятків** ```python def test_divide_by_zero(): with pytest.raises(ZeroDivisionError, match='cannot divide'): divide(10, 0) def test_divide_by_zero_type(): with pytest.raises(ZeroDivisionError) as exc_info: divide(10, 0) assert 'zero' in str(exc_info.value) ```

Фікстури, scope, conftest.py, mock та monkeypatch

#
**Фікстури — повторно використовувана підготовка** Фікстура — функція з декоратором `@pytest.fixture` що встановлює спільний стан для тестів. pytest впроваджує фікстури за іменем параметра: ```python import pytest @pytest.fixture def user(): return {'name': 'Alice', 'role': 'admin'} def test_admin_access(user): # pytest впроваджує 'user' автоматично assert user['role'] == 'admin' ``` **Очищення фікстури з `yield`** ```python @pytest.fixture def db_connection(): conn = connect_to_test_db() yield conn # налаштування повертається тут conn.close() # очищення після тесту ``` **Область видимості фікстури** ```python @pytest.fixture(scope='module') # один екземпляр на модуль def expensive_resource(): return setup_slow_external_service() @pytest.fixture(scope='session') # один екземпляр на весь запуск def db_schema(): return create_schema() ``` **`conftest.py` — спільні фікстури між файлами** Фікстури в `conftest.py` автоматично доступні всім тестам у тій же директорії та підкаталогах — без import: ``` tests/ conftest.py <- фікстури тут test_users.py <- використовує фікстури з conftest test_orders.py <- теж ``` **`unittest.mock` — заміна залежностей** ```python from unittest.mock import patch @patch('mymodule.requests.get') def test_fetch_data(mock_get): mock_get.return_value.status_code = 200 mock_get.return_value.json.return_value = {'key': 'value'} result = fetch_data('https://api.example.com') assert result == {'key': 'value'} mock_get.assert_called_once_with('https://api.example.com') ``` **`monkeypatch` — вбудований патчер pytest** ```python def test_home_dir(monkeypatch, tmp_path): monkeypatch.setenv('HOME', str(tmp_path)) monkeypatch.setattr(os.path, 'exists', lambda p: True) # зміни автоматично відкочуються після тесту ```

Ізоляція тестів, фікстури-фабрики, marks, coverage, CLI

#
**Принципи ізоляції тестів** Кожен тест повинен бути повністю незалежним — встановлювати власний стан, запускатись та очищатись. Тести що поділяють мутабельний стан дають нестабільні результати (проходять окремо, але падають разом). ```python # ПОГАНО: тести поділяють стан на рівні модуля items = [] def test_add_item(): items.append('a') assert len(items) == 1 def test_count(): # ПАДАЄ після test_add_item assert len(items) == 0 # ДОБРЕ: фікстура створює свіжий стан для кожного тесту @pytest.fixture def items(): return [] # новий список для кожного тесту ``` **Фікстури-фабрики** Фікстура може повертати функцію що створює об'єкти на вимогу: ```python @pytest.fixture def make_user(): def _make(name='Alice', role='user', active=True): return User(name=name, role=role, active=active) return _make def test_admin(make_user): admin = make_user(role='admin') assert admin.can_delete() ``` **Marks pytest — пропуск та маркування тестів** ```python @pytest.mark.skip(reason='ще не реалізовано') def test_future_feature(): ... @pytest.mark.skipif(sys.platform == 'win32', reason='тільки Unix') def test_unix_socket(): ... @pytest.mark.xfail(reason='відомий баг') def test_known_broken(): ... # Власні мітки: @pytest.mark.slow def test_full_integration(): ... # pytest -m slow — лише повільні # pytest -m 'not slow' — без повільних ``` **Coverage — вимірювання покриття** ```bash pip install pytest-cov pytest --cov=mymodule --cov-report=term-missing ``` **Швидкий довідник — CLI pytest** | Команда | Що робить | |---|---| | `pytest` | Всі тести | | `pytest test_foo.py::test_bar` | Один тест | | `pytest -k 'add'` | Тести що відповідають 'add' | | `pytest -v` | Детальний вивід | | `pytest -x` | Зупинитись на першому провалі | | `pytest --lf` | Перезапустити лише провалені | | `pytest -s` | Показати print() |
01

Написати базові тестові функції

#

Напишіть три тестові функції pytest для функції `add(a, b)`: `test_add_positive`, `test_add_negative` та `test_add_zero`. Кожна має викликати `add()` та перевіряти результат через `assert`.

def add(a: int, b: int) -> int:
    return a + b


def test_add_positive():
    pass

def test_add_negative():
    pass

def test_add_zero():
    pass


# Запуск: pytest this_file.py
Рішення
def add(a: int, b: int) -> int:
    return a + b


def test_add_positive():
    assert add(2, 3) == 5

def test_add_negative():
    assert add(-1, -2) == -3

def test_add_zero():
    assert add(0, 100) == 100
02

Перевірити, що винятки викидаються

#

Напишіть тести для функції `divide(a, b)`. Перевірте, що `divide(10, 2)` повертає `5.0`, і що `divide(10, 0)` викидає `ZeroDivisionError` за допомогою `pytest.raises`.

import pytest

def divide(a: float, b: float) -> float:
    return a / b


def test_divide_normal():
    pass

def test_divide_by_zero():
    pass
Рішення
import pytest

def divide(a: float, b: float) -> float:
    return a / b


def test_divide_normal():
    assert divide(10, 2) == 5.0

def test_divide_by_zero():
    with pytest.raises(ZeroDivisionError):
        divide(10, 0)
03

Створити фікстуру pytest

#

Напишіть `@pytest.fixture` під назвою `sample_list`, що повертає `[3, 1, 4, 1, 5, 9]`. Напишіть два тести, що використовують її: `test_length` перевіряє `len == 6`, а `test_max` перевіряє `max == 9`.

import pytest

@pytest.fixture
def sample_list():
    pass


def test_length(sample_list):
    pass

def test_max(sample_list):
    pass
Рішення
import pytest

@pytest.fixture
def sample_list():
    return [3, 1, 4, 1, 5, 9]


def test_length(sample_list):
    assert len(sample_list) == 6

def test_max(sample_list):
    assert max(sample_list) == 9
04

Параметризувати тест

#

Використайте `@pytest.mark.parametrize` для тестування `is_palindrome(s)` з п'ятьма випадками: `'racecar'` → True, `'hello'` → False, `'madam'` → True, `'a'` → True, `'ab'` → False. Запишіть всі п'ять як один параметризований тест.

import pytest

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


@pytest.mark.parametrize('s, expected', [
    # заповніть 5 випадків
])
def test_is_palindrome(s, expected):
    pass
Рішення
import pytest

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


@pytest.mark.parametrize('s, expected', [
    ('racecar', True),
    ('hello', False),
    ('madam', True),
    ('a', True),
    ('ab', False),
])
def test_is_palindrome(s, expected):
    assert is_palindrome(s) == expected
05

monkeypatch змінної середовища

#

Напишіть функцію `get_debug_mode() -> bool`, що читає змінну середовища `DEBUG` і повертає `True`, якщо вона дорівнює `'1'`, інакше `False`. Напишіть два тести з `monkeypatch.setenv`: `DEBUG=1` → True, `DEBUG=0` → False.

import os
import pytest

def get_debug_mode() -> bool:
    return os.environ.get('DEBUG', '0') == '1'


def test_debug_on(monkeypatch):
    pass

def test_debug_off(monkeypatch):
    pass
Рішення
import os
import pytest

def get_debug_mode() -> bool:
    return os.environ.get('DEBUG', '0') == '1'


def test_debug_on(monkeypatch):
    monkeypatch.setenv('DEBUG', '1')
    assert get_debug_mode() is True

def test_debug_off(monkeypatch):
    monkeypatch.setenv('DEBUG', '0')
    assert get_debug_mode() is False
06

Замінити функцію через monkeypatch.setattr

#

Функція `get_username()` викликає `os.getlogin()`, що може не працювати в CI. Використайте `monkeypatch.setattr`, щоб замінити `os.getlogin` лямбдою, що повертає `'testuser'`. Перевірте, що `get_username()` повертає `'testuser'`.

import os
import pytest

def get_username() -> str:
    return os.getlogin()


def test_get_username(monkeypatch):
    pass
Рішення
import os
import pytest

def get_username() -> str:
    return os.getlogin()


def test_get_username(monkeypatch):
    monkeypatch.setattr(os, 'getlogin', lambda: 'testuser')
    assert get_username() == 'testuser'
07

Фікстура з налаштуванням та очищенням

#

Напишіть фікстуру `temp_list`, що створює список `[1, 2, 3]`, повертає його через `yield`, потім очищає після тесту (teardown через `yield`). Напишіть тест `test_append`, що додає `4` і перевіряє `len == 4`.

import pytest

@pytest.fixture
def temp_list():
    data = [1, 2, 3]
    yield data
    # teardown: очищення після тесту
    pass


def test_append(temp_list):
    temp_list.append(4)
    assert len(temp_list) == 4
Рішення
import pytest

@pytest.fixture
def temp_list():
    data = [1, 2, 3]
    yield data
    data.clear()


def test_append(temp_list):
    temp_list.append(4)
    assert len(temp_list) == 4
08

Тестування файлового I/O з tmp_path

#

Напишіть функцію `write_and_read(path, text)`, що записує `text` до `path`, читає його назад та повертає вміст. Використайте вбудовану фікстуру `tmp_path` для тестування без реальної файлової системи.

from pathlib import Path

def write_and_read(path: Path, text: str) -> str:
    path.write_text(text)
    return path.read_text()


def test_write_and_read(tmp_path):
    file = tmp_path / 'test.txt'
    result = write_and_read(file, 'hello')
    assert result == 'hello'
Рішення
from pathlib import Path

def write_and_read(path: Path, text: str) -> str:
    path.write_text(text)
    return path.read_text()


def test_write_and_read(tmp_path):
    file = tmp_path / 'test.txt'
    result = write_and_read(file, 'hello')
    assert result == 'hello'
09

Тестування класу з фікстурою

#

Маючи клас `Counter` з `increment()`, `decrement()` та властивістю `value`, напишіть фікстуру `counter`, що повертає свіжий `Counter()`. Напишіть тести: `test_initial_value` (value == 0), `test_increment` (value == 1), `test_decrement` (value == -1 після одного decrement).

import pytest

class Counter:
    def __init__(self):
        self._value = 0

    def increment(self) -> None:
        self._value += 1

    def decrement(self) -> None:
        self._value -= 1

    @property
    def value(self) -> int:
        return self._value


@pytest.fixture
def counter():
    pass

def test_initial_value(counter):
    pass

def test_increment(counter):
    pass

def test_decrement(counter):
    pass
Рішення
import pytest

class Counter:
    def __init__(self):
        self._value = 0

    def increment(self) -> None:
        self._value += 1

    def decrement(self) -> None:
        self._value -= 1

    @property
    def value(self) -> int:
        return self._value


@pytest.fixture
def counter():
    return Counter()

def test_initial_value(counter):
    assert counter.value == 0

def test_increment(counter):
    counter.increment()
    assert counter.value == 1

def test_decrement(counter):
    counter.decrement()
    assert counter.value == -1
10

Тестування значень з плаваючою точкою через pytest.approx

#

Напишіть тести для функції `circle_area(r)` (повертає `math.pi * r ** 2`). Використайте `pytest.approx` для порівняння — звичайне `==` для float не спрацює. Тестуйте з `r=1` (~3.14159), `r=2` (~12.566) та `r=0` (0).

import math
import pytest

def circle_area(r: float) -> float:
    return math.pi * r ** 2


def test_area_r1():
    pass

def test_area_r2():
    pass

def test_area_r0():
    pass
Рішення
import math
import pytest

def circle_area(r: float) -> float:
    return math.pi * r ** 2


def test_area_r1():
    assert circle_area(1) == pytest.approx(math.pi)

def test_area_r2():
    assert circle_area(2) == pytest.approx(4 * math.pi)

def test_area_r0():
    assert circle_area(0) == pytest.approx(0)