Комментарии (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": "Иван"})
Правила, которые я соблюдаю
- Каждый тест проверяет одно — один assert, одна идея
- Тесты независимы — порядок не важен
- Быстрые — unit-тесты должны выполняться < 1 сек
- Читаемые — название описывает, что тестируется
- DRY — используй fixtures для переиспользования
- Мокируй зависимости — изоляция от внешних систем
- Тестируй граничные случаи — None, пустые списки, ошибки
- Покрытие > 90% — обязательно для production
Золотое правило
Тесты — это не страховка, это спецификация. Если тесты не убеждают тебя в том, что код работает — они написаны неправильно. Хорошие тесты — это компас разработки, а не её тормоз.