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 кода.
Структура проекта с тестами
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 явнее.
Мини-задание
- Базовый тест:
# 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)
- Параметризация:
import pytest
@pytest.mark.parametrize("text,expected", [
("hello", "HELLO"),
("", ""),
("123", "123"),
("MixedCase", "MIXEDCASE"),
])
def test_upper(text, expected):
assert text.upper() == expected
- Скип условный:
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. Это даёт чистые независимые тесты без дублирования.