← Назад к вопросам

Какие пишешь unit-тесты?

1.0 Junior🔥 231 комментариев
#Тестирование

Комментарии (1)

🐱
claude-haiku-4.5PrepBro AI22 мар. 2026 г.(ред.)

Ответ сгенерирован нейросетью и может содержать ошибки

Unit-тесты в Python: мой подход

За 10+ лет я понял: хорошие тесты спасают проект. Вот как я их пишу в production.

1. Основы: pytest вместо unittest

Pytest проще и мощнее:

# ✅ Мой стандарт: pytest
import pytest

def add(a, b):
    return a + b

def test_add_positive_numbers():
    """Тест сложения положительных чисел"""
    assert add(2, 3) == 5

def test_add_negative_numbers():
    """Тест сложения отрицательных чисел"""
    assert add(-2, -3) == -5

def test_add_zero():
    """Граничный случай: ноль"""
    assert add(0, 5) == 5

Установка:

pip install pytest pytest-cov
pytest  # Запуск всех тестов
pytest --cov  # С покрытием
pytest -v  # Verbose
pytest -k "test_add"  # По паттерну

2. Структура тестов: AAA (Arrange-Act-Assert)

Всегда пишу в одном стиле для читаемости:

class TestUserService:
    def test_create_user_success(self):
        # Arrange (подготовка)
        user_service = UserService()
        user_data = {"name": "Иван", "email": "ivan@example.com"}
        
        # Act (действие)
        result = user_service.create_user(user_data)
        
        # Assert (проверка)
        assert result.id is not None
        assert result.name == "Иван"
        assert result.email == "ivan@example.com"

3. Fixtures для переиспользуемых данных

Fixtures — это фундамент хороших тестов:

import pytest
from datetime import datetime

@pytest.fixture
def user_data():
    """Фикстура с тестовыми данными пользователя"""
    return {
        "name": "Иван",
        "email": "ivan@example.com",
        "age": 30
    }

@pytest.fixture
def user_service(db):
    """Фикстура с инициализированным сервисом"""
    return UserService(db)

class TestUserService:
    def test_create_user(self, user_service, user_data):
        result = user_service.create_user(user_data)
        assert result.name == "Иван"
    
    def test_update_user(self, user_service, user_data):
        user = user_service.create_user(user_data)
        user_service.update_user(user.id, {"age": 31})
        updated = user_service.get_user(user.id)
        assert updated.age == 31

4. Моки и патчирование

Для изоляции тестируемого кода:

from unittest.mock import Mock, patch, MagicMock
import pytest

class PaymentService:
    def process_payment(self, user_id, amount):
        # Отправляет на external API
        return external_api.charge(user_id, amount)

def test_process_payment_success():
    # ❌ Плохо: зависит от внешнего API
    # result = payment_service.process_payment(1, 100)
    
    # ✅ Хорошо: мокируем внешний API
    with patch('payment_service.external_api') as mock_api:
        mock_api.charge.return_value = {"status": "success", "transaction_id": "123"}
        
        service = PaymentService()
        result = service.process_payment(1, 100)
        
        assert result["status"] == "success"
        mock_api.charge.assert_called_once_with(1, 100)

def test_payment_api_failure():
    """Тест обработки ошибок"""
    with patch('payment_service.external_api') as mock_api:
        mock_api.charge.side_effect = ConnectionError("API down")
        
        service = PaymentService()
        
        with pytest.raises(ConnectionError):
            service.process_payment(1, 100)

5. Параметризованные тесты

Для тестирования множества случаев:

import pytest

class TestCalculator:
    @pytest.mark.parametrize("a,b,expected", [
        (2, 3, 5),
        (0, 5, 5),
        (-2, 3, 1),
        (0, 0, 0),
    ])
    def test_add(self, a, b, expected):
        """Один тест вместо четырёх"""
        assert add(a, b) == expected

# Можно использовать ID
@pytest.mark.parametrize("email,valid", [
    ("user@example.com", True),
    ("invalid-email", False),
    ("test@domain.co.uk", True),
], ids=["valid_email", "invalid_format", "valid_with_uk_domain"])
def test_email_validation(email, valid):
    assert is_valid_email(email) == valid

6. Fixtures с областью видимости (Scope)

import pytest

@pytest.fixture(scope="module")  # Один раз на весь модуль
def expensive_resource():
    print("\nInitializing expensive resource...")
    resource = ExpensiveDB()
    resource.connect()
    yield resource
    print("\nCleaning up...")
    resource.close()

@pytest.fixture(scope="function")  # Один раз на каждый тест (по умолчанию)
def fresh_data():
    data = {"counter": 0}
    yield data
    # Очистка после теста
    data.clear()

class TestWithFixtures:
    def test_one(self, expensive_resource, fresh_data):
        # expensive_resource создаётся один раз
        # fresh_data создаётся для каждого теста
        pass

7. Тестирование исключений

import pytest

def process_order(order_id):
    if not order_id:
        raise ValueError("Order ID cannot be empty")
    if order_id < 0:
        raise ValueError("Order ID must be positive")
    return {"id": order_id, "status": "processed"}

def test_invalid_order_id():
    """Проверяем, что выброшено исключение"""
    with pytest.raises(ValueError, match="Order ID cannot be empty"):
        process_order(None)

def test_negative_order_id():
    """Проверяем сообщение об ошибке"""
    with pytest.raises(ValueError) as exc_info:
        process_order(-1)
    
    assert "positive" in str(exc_info.value)

8. Асинхронные тесты

Для asyncio кода:

import pytest
import asyncio

@pytest.fixture
def event_loop():
    """Фикстура для asyncio тестов"""
    loop = asyncio.get_event_loop_policy().new_event_loop()
    yield loop
    loop.close()

async def fetch_user(user_id):
    await asyncio.sleep(0.1)
    return {"id": user_id, "name": "Иван"}

@pytest.mark.asyncio
async def test_fetch_user():
    """Асинхронный тест"""
    result = await fetch_user(1)
    assert result["name"] == "Иван"

# Альтернатива: pytest-asyncio
# pip install pytest-asyncio

9. Тестирование БД

import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker

@pytest.fixture(scope="function")
def db_session():
    """Фикстура для тестирования с БД"""
    # Используем in-memory SQLite для тестов
    engine = create_engine('sqlite:///:memory:')
    Base.metadata.create_all(engine)
    
    Session = sessionmaker(bind=engine)
    session = Session()
    
    yield session
    
    session.close()
    Base.metadata.drop_all(engine)

def test_user_creation(db_session):
    """Тест создания пользователя в БД"""
    user = User(name="Иван", email="ivan@example.com")
    db_session.add(user)
    db_session.commit()
    
    retrieved = db_session.query(User).filter_by(name="Иван").first()
    assert retrieved is not None
    assert retrieved.email == "ivan@example.com"

10. Тестирование API (FastAPI)

from fastapi.testclient import TestClient
from main import app

@pytest.fixture
def client():
    return TestClient(app)

def test_get_users(client):
    """Тест GET запроса"""
    response = client.get("/api/users")
    assert response.status_code == 200
    assert len(response.json()) > 0

def test_create_user(client):
    """Тест POST запроса"""
    payload = {"name": "Иван", "email": "ivan@example.com"}
    response = client.post("/api/users", json=payload)
    
    assert response.status_code == 201
    data = response.json()
    assert data["name"] == "Иван"
    assert "id" in data

def test_user_not_found(client):
    """Тест ошибки 404"""
    response = client.get("/api/users/999")
    assert response.status_code == 404

11. Покрытие тестами (Coverage)

# Запуск с отчётом о покрытии
pytest --cov=src --cov-report=html

# Проверка минимального покрытия
pytest --cov=src --cov-fail-under=90
# .coveragerc
[run]
branch = True

[report]
min_version = 4.5.4
precision = 2
fail_under = 90

12. Практический пример: полный сервис с тестами

# src/user_service.py
class UserService:
    def __init__(self, db):
        self.db = db
    
    def create_user(self, data):
        if not data.get("email"):
            raise ValueError("Email required")
        user = User(**data)
        self.db.add(user)
        self.db.commit()
        return user

# tests/test_user_service.py
import pytest
from unittest.mock import Mock
from user_service import UserService

@pytest.fixture
def mock_db():
    return Mock()

@pytest.fixture
def user_service(mock_db):
    return UserService(mock_db)

class TestUserService:
    def test_create_user_success(self, user_service, mock_db):
        # Arrange
        data = {"name": "Иван", "email": "ivan@example.com"}
        
        # Act
        user = user_service.create_user(data)
        
        # Assert
        assert user.name == "Иван"
        mock_db.add.assert_called_once()
        mock_db.commit.assert_called_once()
    
    def test_create_user_without_email(self, user_service):
        with pytest.raises(ValueError, match="Email required"):
            user_service.create_user({"name": "Иван"})

Правила, которые я соблюдаю

  1. Каждый тест проверяет одно — один assert, одна идея
  2. Тесты независимы — порядок не важен
  3. Быстрые — unit-тесты должны выполняться < 1 сек
  4. Читаемые — название описывает, что тестируется
  5. DRY — используй fixtures для переиспользования
  6. Мокируй зависимости — изоляция от внешних систем
  7. Тестируй граничные случаи — None, пустые списки, ошибки
  8. Покрытие > 90% — обязательно для production

Золотое правило

Тесты — это не страховка, это спецификация. Если тесты не убеждают тебя в том, что код работает — они написаны неправильно. Хорошие тесты — это компас разработки, а не её тормоз.