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)