pytest: основы, assert, параметризация, маркеры

pytest: основы, assert, параметризация, маркеры

pytest это стандарт де-факто для тестирования в Python. Минималистичный синтаксис (без классов), мощные fixtures, отличные сообщения об ошибках, огромная экосистема плагинов. В этом уроке - базы: написание тестов, параметризация, маркеры и organising.

Зачем pytest вместо unittest

В стандартной библиотеке есть unittest, но он многословный и требует классов:

# unittest
import unittest

class TestMath(unittest.TestCase):
    def test_add(self):
        self.assertEqual(2 + 2, 4)
    def test_divide(self):
        self.assertRaises(ZeroDivisionError, lambda: 1 / 0)

# pytest
def test_add():
    assert 2 + 2 == 4

def test_divide():
    with pytest.raises(ZeroDivisionError):
        1 / 0

pytest короче, использует обычный assert, не требует классов. Совместим с unittest - может запускать его тесты тоже. По духу это близко к тестам в Go: обычные функции, никаких классов и методов-ассертов.

Установка и запуск

pip install pytest
pytest                    # запуск всех тестов в текущей директории
pytest tests/             # конкретная директория
pytest tests/test_x.py    # конкретный файл
pytest tests/test_x.py::test_function   # конкретный тест
pytest -v                 # verbose (показывает имена тестов)
pytest -x                 # stop на первой ошибке
pytest -k "name"          # запустить тесты с "name" в имени
pytest -m slow            # запустить только тесты с маркером slow
pytest --pdb              # запустить отладчик при ошибке

Discovery rules

pytest автоматически находит тесты по правилам:

  • Файлы test_*.py или *_test.py
  • Функции test_* внутри них
  • Классы Test* (без __init__)
  • Методы test_* внутри классов

Можно настроить через pytest.ini или pyproject.toml:

[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py", "*_test.py"]
python_functions = ["test_*"]

Базовая структура

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

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

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

def test_add_zero():
    assert add(0, 5) == 5

Каждая функция - независимый тест. pytest запускает их и сообщает результаты.

assert - простой и понятный

def test_examples():
    assert 1 + 1 == 2
    assert "hello" in "hello world"
    assert [1, 2, 3] == [1, 2, 3]
    assert 5 > 3
    assert isinstance(42, int)
    assert callable(print)

    # С сообщением (опционально)
    n = 5
    assert n > 0, f"n must be positive, got {n}"

В отличие от unittest, не нужны assertEqual, assertIn, assertTrue. Просто assert. pytest перехватывает и даёт красивые сообщения об ошибках.

Сообщения об ошибках

pytest показывает понятные диагностики при failures:

======================== FAILURES ========================
__________________ test_add_positive __________________

    def test_add_positive():
>       assert add(2, 3) == 6
E       assert 5 == 6
E         +  where 5 = add(2, 3)

Видишь точно что сравнивается, какие реальные значения. Это assertion rewriting - pytest модифицирует AST для извлечения деталей.

Проверка исключений

import pytest

def test_division_by_zero():
    with pytest.raises(ZeroDivisionError):
        1 / 0

def test_exception_with_message():
    with pytest.raises(ValueError, match="invalid"):
        raise ValueError("invalid input")

def test_exception_attributes():
    with pytest.raises(ValueError) as exc_info:
        raise ValueError("error 42")
    assert "42" in str(exc_info.value)
    assert exc_info.type == ValueError

match= использует regex для проверки сообщения. exc_info даёт доступ к exception для детальных проверок.

Параметризация - один тест для многих случаев

import pytest

@pytest.mark.parametrize("a,b,expected", [
    (1, 2, 3),
    (0, 0, 0),
    (-1, 1, 0),
    (100, 200, 300),
])
def test_add(a, b, expected):
    assert add(a, b) == expected

Эквивалентно 4 отдельным тестам, но без дублирования. pytest запустит каждую комбинацию и пометит в отчёте отдельно:

test_math.py::test_add[1-2-3] PASSED
test_math.py::test_add[0-0-0] PASSED
test_math.py::test_add[-1-1-0] PASSED
test_math.py::test_add[100-200-300] PASSED

Параметризация с ids

@pytest.mark.parametrize("input,expected", [
    pytest.param("hello", 5, id="lowercase"),
    pytest.param("WORLD", 5, id="uppercase"),
    pytest.param("", 0, id="empty"),
])
def test_length(input, expected):
    assert len(input) == expected

pytest.param(...) позволяет дать понятное имя case. Полезно для читаемых отчётов.

Множественная параметризация

@pytest.mark.parametrize("x", [1, 2, 3])
@pytest.mark.parametrize("y", ["a", "b"])
def test_combo(x, y):
    print(x, y)
# Запустит 6 тестов: (1,a), (1,b), (2,a), (2,b), (3,a), (3,b)

Декартово произведение параметров - удобно для combinations testing.

Skip и xfail - условные тесты

import pytest
import sys

@pytest.mark.skip(reason="не работает на старом API")
def test_old():
    ...

@pytest.mark.skipif(sys.version_info < (3, 11), reason="нужен Py 3.11+")
def test_match_case():
    match 1:
        case 1:
            pass

@pytest.mark.xfail(reason="known bug, исправим в v2")
def test_known_bug():
    assert broken_function() == "fixed"
  • skip - всегда пропустить
  • skipif - условный пропуск
  • xfail - ожидаемый fail (тест помечается как XFAIL/XPASS, не блокирует suite)

Кастомные маркеры

@pytest.mark.slow
def test_heavy_integration():
    ...

@pytest.mark.db
def test_database():
    ...

Регистрируешь в pyproject.toml:

[tool.pytest.ini_options]
markers = [
    "slow: medленные тесты",
    "db: требуют БД",
]

Запускаешь:

pytest -m slow              # только slow
pytest -m "not slow"        # все кроме slow
pytest -m "slow and db"     # пересечение

Удобно для CI: pull-request тесты быстрые, nightly прогоняют всё.

conftest.py - общие настройки

# tests/conftest.py
import pytest

@pytest.fixture
def sample_data():
    return {"name": "Alice", "age": 30}

conftest.py автоматически доступен во всех тестах в директории и поддиректориях. Подробно про fixtures в следующем уроке.

Plugins ecosystem

pytest имеет богатую экосистему:

  • pytest-cov - coverage report
  • pytest-asyncio - async/await тесты
  • pytest-mock - удобный mocker fixture (разбираем в уроке про mocking)
  • pytest-xdist - параллельные тесты
  • pytest-timeout - timeout для тестов
  • pytest-django / pytest-flask - framework integration
  • freezegun - мокинг времени
  • respx / responses - мокинг HTTP

Установка:

pip install pytest-cov pytest-mock pytest-asyncio

Coverage

pip install pytest-cov
pytest --cov=mypackage --cov-report=html

Создаёт HTML-отчёт в htmlcov/ показывающий какие строки тестами покрыты. Цель обычно 80%+ для backend кода.

Структура проекта с тестами

Структура проекта с тестами: my_backend содержит pyproject.toml, src/my_backend и tests/ с conftest.py, test_core.py и под-директорией unit/test_utils.py

tests/ отдельно от src. __init__.py в tests опционален - без него pytest сам справится. С ним - можно делать общие импорты.

Запуск отдельных тестов

# Конкретный тест
pytest tests/test_core.py::test_add

# Класс целиком
pytest tests/test_api.py::TestUsers

# Метод класса
pytest tests/test_api.py::TestUsers::test_login

# По substring имени
pytest -k "auth"   # все тесты с auth в имени

# По маркеру
pytest -m unit

Тесты async-функций

import pytest
import asyncio

@pytest.mark.asyncio
async def test_async():
    result = await some_async_function()
    assert result == "expected"

Требует pytest-asyncio. Можно настроить auto mode:

[tool.pytest.ini_options]
asyncio_mode = "auto"   # не нужен @pytest.mark.asyncio

Captured output

def test_print(capsys):
    print("hello")
    captured = capsys.readouterr()
    assert captured.out == "hello\n"
    assert captured.err == ""

capsys это встроенная fixture pytest для захвата stdout/stderr. Подробно про fixtures в следующем уроке.

tmp_path - временная директория

def test_with_file(tmp_path):
    file = tmp_path / "test.txt"
    file.write_text("hello")
    assert file.read_text() == "hello"
    # tmp_path автоматически очищается

tmp_path встроенная fixture даёт уникальный Path для каждого теста. Содержимое удаляется после теста (через несколько runs - чтобы можно было дебажить).

Хорошие практики

1. AAA (Arrange-Act-Assert) - структура теста:

def test_user_login():
    # Arrange - подготовка
    user = User("alice", "password123")

    # Act - действие
    result = user.login("password123")

    # Assert - проверка
    assert result.success is True
    assert user.is_authenticated

2. One concept per test - каждый тест проверяет одну вещь:

# Плохо - много в одном
def test_user_everything():
    user = create_user()
    assert user.name == "Alice"
    user.login()
    assert user.is_logged_in
    user.logout()
    assert not user.is_logged_in

# Лучше - отдельные
def test_user_creation():
    user = create_user()
    assert user.name == "Alice"

def test_user_login_changes_state():
    user = create_user()
    user.login()
    assert user.is_logged_in

3. Понятные имена:

# Плохо
def test_1():
    ...

# Хорошо
def test_user_with_admin_role_can_delete_other_users():
    ...

4. Independent tests - тесты не зависят друг от друга:

# Плохо - test_b зависит от состояния после test_a
def test_a():
    global_state["x"] = 5

def test_b():
    assert global_state["x"] == 5

# Хорошо - каждый тест автономен через fixtures

Распространённые ошибки

1. Зависимые тесты

Уже выше. Используй fixtures для setup, не глобальное состояние.

2. Тестирование implementation вместо behavior

# Плохо - проверяет конкретный вызов
def test_send():
    sender = Sender()
    sender.send("hello")
    assert sender._internal_method_called   # implementation detail!

# Хорошо - проверяет результат
def test_send():
    sender = Sender()
    sender.send("hello")
    assert sender.last_sent == "hello"

Тестируй contract (что должно случиться), не как именно случилось.

3. Слишком много мокинга

Мокинг всего делает тесты хрупкими и бессмысленными. Лучше тестировать с реальными зависимостями где можно.

4. Игнорировать failing тесты через xfail

@pytest.mark.xfail   # просто чтобы не падало
def test_broken():
    assert broken()

xfail допустим для known issues с указанным reason и сроком. Постоянное использование как silencer - плохая практика.

5. Тесты без assertions

def test_no_error():
    do_work()
# Прошёл если не упал, но ничего конкретного не проверил

Каждый тест должен иметь хотя бы один assert.

Сравнение с Go

В Go встроенное testing:

func TestAdd(t *testing.T) {
    result := Add(2, 3)
    if result != 5 {
        t.Errorf("Add(2,3) = %d; want 5", result)
    }
}

// Параметризация через table-driven
func TestAdd(t *testing.T) {
    cases := []struct{
        a, b, expected int
    }{
        {1, 2, 3},
        {0, 0, 0},
    }
    for _, c := range cases {
        if got := Add(c.a, c.b); got != c.expected {
            t.Errorf("Add(%d,%d) = %d; want %d", c.a, c.b, got, c.expected)
        }
    }
}

Go более многословен (явные if + t.Errorf), но строго типизирован. pytest проще для написания, Go явнее.

Мини-задание

  1. Базовый тест:
# math_ops.py
def divide(a, b):
    if b == 0:
        raise ValueError("Cannot divide by zero")
    return a / b

# test_math_ops.py
import pytest
from math_ops import divide

def test_divide_positive():
    assert divide(10, 2) == 5

def test_divide_returns_float():
    assert isinstance(divide(7, 2), float)

def test_divide_by_zero():
    with pytest.raises(ValueError, match="divide by zero"):
        divide(1, 0)
  1. Параметризация:
import pytest

@pytest.mark.parametrize("text,expected", [
    ("hello", "HELLO"),
    ("", ""),
    ("123", "123"),
    ("MixedCase", "MIXEDCASE"),
])
def test_upper(text, expected):
    assert text.upper() == expected
  1. Скип условный:
import sys
import pytest

@pytest.mark.skipif(sys.platform != "linux", reason="only Linux feature")
def test_linux_only():
    import os
    assert os.uname().sysname == "Linux"

Что дальше

Освоили базовый pytest. В следующем уроке - fixtures: способ переиспользования setup-кода через dependency injection. Это даёт чистые независимые тесты без дублирования.

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.