pytest — современное тестирование Python
Почему pytest
pytest — самый популярный фреймворк тестирования в Python-экосистеме. В отличие от unittest, он предлагает минималистичный синтаксис, мощную систему фикстур и богатую экосистему плагинов.
# test_basics.py — tests are just functions with assert
def test_addition() -> None:
assert 1 + 1 == 2
def test_string_upper() -> None:
assert "hello".upper() == "HELLO"
def test_list_contains() -> None:
fruits = ["яблоко", "банан", "вишня"]
assert "банан" in fruits
def test_dict_key() -> None:
user = {"name": "Алексей", "age": 30}
assert user["name"] == "Алексей"
assert "email" not in user
Запуск:
# Run all tests
pytest
# Verbose output
pytest -v
# Run specific file
pytest test_basics.py
# Run specific test function
pytest test_basics.py::test_addition
# Show print output
pytest -s
# Stop at first failure
pytest -x
# Run last failed tests
pytest --lf
Главное преимущество pytest — информативные сообщения об ошибках. При падении assert pytest показывает значения переменных:
def test_detailed_error() -> None:
expected = {"name": "Алексей", "role": "admin"}
actual = {"name": "Алексей", "role": "user"}
assert actual == expected
# pytest output:
# E AssertionError: assert {'name': 'Алексей', 'role': 'user'}
# == {'name': 'Алексей', 'role': 'admin'}
# E Differing items:
# E {'role': 'user'} != {'role': 'admin'}
Фикстуры (Fixtures)
Фикстуры — механизм подготовки тестовых данных и ресурсов. Они объявляются через декоратор @pytest.fixture и автоматически внедряются в тесты по имени параметра.
import pytest
from dataclasses import dataclass
@dataclass
class User:
name: str
email: str
active: bool = True
@dataclass
class UserRepository:
_users: dict[str, User]
def get(self, email: str) -> User | None:
return self._users.get(email)
def add(self, user: User) -> None:
self._users[user.email] = user
def count(self) -> int:
return len(self._users)
# Fixtures — declared once, used in many tests
@pytest.fixture
def sample_user() -> User:
"""Create a sample user for testing."""
return User(name="Алексей", email="[email protected]")
@pytest.fixture
def user_repo() -> UserRepository:
"""Create an empty user repository."""
return UserRepository(_users={})
@pytest.fixture
def populated_repo(user_repo: UserRepository, sample_user: User) -> UserRepository:
"""Repository with one user. Fixtures can depend on other fixtures."""
user_repo.add(sample_user)
return user_repo
# Tests receive fixtures as parameters
def test_add_user(user_repo: UserRepository, sample_user: User) -> None:
user_repo.add(sample_user)
assert user_repo.count() == 1
def test_get_user(populated_repo: UserRepository) -> None:
user = populated_repo.get("[email protected]")
assert user is not None
assert user.name == "Алексей"
def test_get_nonexistent(user_repo: UserRepository) -> None:
assert user_repo.get("[email protected]") is None
Области видимости фикстур (scope)
import pytest
@pytest.fixture(scope="function") # Default — new instance per test
def per_test_resource():
print("Создание ресурса для теста")
yield {"type": "per-test"}
print("Очистка ресурса после теста")
@pytest.fixture(scope="class") # One instance per test class
def per_class_resource():
print("Создание ресурса для класса")
yield {"type": "per-class"}
print("Очистка ресурса после класса")
@pytest.fixture(scope="module") # One instance per module
def per_module_resource():
print("Создание ресурса для модуля")
yield {"type": "per-module"}
print("Очистка ресурса после модуля")
@pytest.fixture(scope="session") # One instance for entire test session
def per_session_resource():
print("Создание ресурса для сессии")
yield {"type": "per-session"}
print("Очистка ресурса после сессии")
yield-фикстуры для setup/teardown
import pytest
import tempfile
import os
@pytest.fixture
def temp_file():
"""Create a temp file, clean up after test."""
fd, path = tempfile.mkstemp(suffix=".txt")
os.write(fd, b"test data")
os.close(fd)
yield path # Test runs here
# Teardown — runs after test
if os.path.exists(path):
os.remove(path)
def test_read_temp_file(temp_file: str) -> None:
with open(temp_file) as f:
content = f.read()
assert content == "test data"
def test_temp_file_exists(temp_file: str) -> None:
assert os.path.exists(temp_file)
Параметризация (parametrize)
Параметризация позволяет запускать один тест с разными входными данными:
import pytest
def is_palindrome(s: str) -> bool:
"""Check if string is a palindrome."""
cleaned = s.lower().replace(" ", "")
return cleaned == cleaned[::-1]
@pytest.mark.parametrize("text, expected", [
("radar", True),
("hello", False),
("level", True),
("А роза упала на лапу Азора", True),
("python", False),
("", True),
("a", True),
("Топот", True),
])
def test_is_palindrome(text: str, expected: bool) -> None:
assert is_palindrome(text) == expected
# Multiple parameter sets
@pytest.mark.parametrize("x", [0, 1, 2])
@pytest.mark.parametrize("y", [10, 20])
def test_multiply(x: int, y: int) -> None:
"""Runs 6 tests: all combinations of x and y."""
result = x * y
assert result == x * y # Trivial, but shows the pattern
# Parametrize with IDs for better output
@pytest.mark.parametrize(
"input_val, expected",
[
pytest.param("42", 42, id="positive-int"),
pytest.param("-7", -7, id="negative-int"),
pytest.param("0", 0, id="zero"),
pytest.param("abc", None, id="invalid-string"),
],
)
def test_safe_int(input_val: str, expected: int | None) -> None:
try:
result = int(input_val)
except ValueError:
result = None
assert result == expected
conftest.py — общие фикстуры
conftest.py — специальный файл, в котором фикстуры автоматически доступны всем тестам в текущей директории и поддиректориях:
tests/
conftest.py # Fixtures available to all tests
test_users.py
test_orders.py
api/
conftest.py # Additional fixtures for api tests
test_endpoints.py
# tests/conftest.py
import pytest
from dataclasses import dataclass
@dataclass
class DBConnection:
"""Fake database connection for testing."""
url: str
connected: bool = False
def connect(self) -> None:
self.connected = True
def disconnect(self) -> None:
self.connected = False
def query(self, sql: str) -> list[dict]:
if not self.connected:
raise RuntimeError("Not connected")
return [{"id": 1, "name": "test"}]
@pytest.fixture(scope="session")
def db_url() -> str:
"""Database URL for the test session."""
return "postgresql://test:test@localhost/test_db"
@pytest.fixture
def db(db_url: str) -> DBConnection:
"""Database connection — connects before test, disconnects after."""
conn = DBConnection(url=db_url)
conn.connect()
yield conn
conn.disconnect()
@pytest.fixture
def admin_user() -> dict:
"""Admin user data available to all tests."""
return {
"id": 1,
"name": "Admin",
"email": "[email protected]",
"role": "admin",
}
# tests/test_users.py — fixtures from conftest.py are auto-available
def test_db_connected(db) -> None:
assert db.connected is True
def test_admin_exists(admin_user: dict) -> None:
assert admin_user["role"] == "admin"
Маркеры (Marks)
Маркеры позволяют категоризировать и выборочно запускать тесты:
import pytest
@pytest.mark.slow
def test_heavy_computation() -> None:
"""Slow test — skip with: pytest -m 'not slow'"""
total = sum(i * i for i in range(10_000_000))
assert total > 0
@pytest.mark.integration
def test_external_api() -> None:
"""Integration test requiring network."""
pass
@pytest.mark.skipif(
condition=True, # Replace with actual condition
reason="Feature not yet implemented",
)
def test_future_feature() -> None:
pass
@pytest.mark.xfail(reason="Known bug #123")
def test_known_bug() -> None:
assert 1 == 2 # Expected to fail
# Run only slow tests
pytest -m slow
# Run everything except slow tests
pytest -m "not slow"
# Run slow AND integration
pytest -m "slow and integration"
Плагины pytest
Экосистема pytest включает сотни плагинов:
# Coverage report
pip install pytest-cov
pytest --cov=src --cov-report=html
# Async tests
pip install pytest-asyncio
# Random test order
pip install pytest-randomly
# Parallel execution
pip install pytest-xdist
pytest -n 4 # Run on 4 CPU cores
# Timeout for hanging tests
pip install pytest-timeout
pytest --timeout=10
pytest-asyncio — тестирование async-кода
import pytest
import asyncio
async def fetch_data(key: str) -> dict:
"""Simulate async data fetch."""
await asyncio.sleep(0.01)
return {"key": key, "value": f"data-{key}"}
@pytest.mark.asyncio
async def test_fetch_data() -> None:
result = await fetch_data("users")
assert result["key"] == "users"
assert "data-" in result["value"]
@pytest.mark.asyncio
async def test_concurrent_fetch() -> None:
results = await asyncio.gather(
fetch_data("a"),
fetch_data("b"),
fetch_data("c"),
)
assert len(results) == 3
assert all("value" in r for r in results)
Практический пример: тестирование сервиса
# services.py
from dataclasses import dataclass
@dataclass
class EmailService:
"""Email sending service."""
def send(self, to: str, subject: str, body: str) -> bool:
# In tests, this would be mocked
raise NotImplementedError("Use mock in tests")
@dataclass
class OrderService:
"""Order processing service."""
email_service: EmailService
def place_order(self, user_email: str, items: list[str]) -> dict:
if not items:
raise ValueError("Корзина пуста")
order = {
"id": 1,
"email": user_email,
"items": items,
"status": "placed",
}
self.email_service.send(
to=user_email,
subject="Заказ оформлен",
body=f"Ваш заказ: {', '.join(items)}",
)
return order
# test_services.py
import pytest
from unittest.mock import MagicMock
@pytest.fixture
def email_service() -> MagicMock:
service = MagicMock()
service.send.return_value = True
return service
@pytest.fixture
def order_service(email_service: MagicMock):
from services import OrderService
return OrderService(email_service=email_service)
def test_place_order(order_service, email_service) -> None:
result = order_service.place_order(
"[email protected]",
["Ноутбук", "Мышь"],
)
assert result["status"] == "placed"
assert result["items"] == ["Ноутбук", "Мышь"]
email_service.send.assert_called_once()
def test_place_empty_order_raises(order_service) -> None:
with pytest.raises(ValueError, match="Корзина пуста"):
order_service.place_order("[email protected]", [])
@pytest.mark.parametrize("items", [
["Книга"],
["Книга", "Ручка"],
["Книга", "Ручка", "Тетрадь"],
])
def test_place_order_various_items(order_service, items) -> None:
result = order_service.place_order("[email protected]", items)
assert result["items"] == items
assert result["status"] == "placed"