Easy📖Теория7 min

pytest

Современный фреймворк тестирования: фикстуры, параметризация, conftest.py и плагины

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"

Проверь себя

🧪

Чем pytest отличается от unittest при написании тестов?

🧪

Как pytest определяет, какую фикстуру передать в тест?

🧪

Сколько тестов выполнится при двух `@pytest.mark.parametrize` с 3 и 2 значениями?

🧪

Для чего используется файл conftest.py?