Комментарии (1)
Ответ сгенерирован нейросетью и может содержать ошибки
Как сделать тесты атомарными?
Атомарность тестов означает, что каждый тест должен быть независимым, не зависящим от состояния других тестов, и должен оставлять систему в том же состоянии до и после выполнения. Это критически важно для надёжности и воспроизводимости тестов.
Основные принципы атомарности тестов
1. Изоляция и независимость
import pytest
from unittest import mock
# ПЛОХО: Тесты зависят друг от друга
global_state = []
def test_add_item():
global_state.append("item1")
assert len(global_state) == 1
def test_another_operation():
# Этот тест может сломаться, если run test_add_item перед ним
assert len(global_state) == 0 # Может быть 1, если выполнять в другом порядке!
# ХОРОШО: Каждый тест изолирован
@pytest.fixture
def fresh_state():
"""Фикстура обеспечивает чистое состояние для каждого теста"""
state = []
yield state
# Cleanup (опционально)
def test_add_item_good(fresh_state):
fresh_state.append("item1")
assert len(fresh_state) == 1
def test_another_operation_good(fresh_state):
# Каждый тест начинает с пустого списка
assert len(fresh_state) == 0
Использование фикстур для изоляции состояния
2. Pytest фикстуры для управления состоянием
import pytest
from typing import Generator
import tempfile
import os
# Фикстура для работы с временными файлами
@pytest.fixture
def temp_directory() -> Generator[str, None, None]:
"""Создать временную директорию и очистить после теста"""
temp_dir = tempfile.mkdtemp()
yield temp_dir
# Cleanup
import shutil
shutil.rmtree(temp_dir)
def test_write_file(temp_directory):
test_file = os.path.join(temp_directory, "test.txt")
with open(test_file, "w") as f:
f.write("test data")
assert os.path.exists(test_file)
# После теста temp_directory автоматически удалится
def test_another_write_file(temp_directory):
# Этот тест получит новую, чистую временную директорию
test_file = os.path.join(temp_directory, "another.txt")
assert not os.path.exists(test_file) # Гарантированно пусто
Работа с базой данных
3. Транзакции для откатываемых изменений
import pytest
from sqlalchemy import create_engine, Column, Integer, String
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.ext.declarative import declarative_base
Base = declarative_base()
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
name = Column(String)
# ПЛОХО: Тесты загрязняют БД
def test_create_user_bad(db_session):
user = User(name="Alice")
db_session.add(user)
db_session.commit()
assert user.id is not None
# БД остаётся загрязненной!
# ХОРОШО: Использовать транзакции с rollback
@pytest.fixture
def db_session_with_rollback():
"""Фикстура с автоматическим откатом"""
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
SessionLocal = sessionmaker(bind=engine)
session = SessionLocal()
# Начать транзакцию
connection = engine.connect()
transaction = connection.begin()
try:
yield session
finally:
transaction.rollback()
connection.close()
def test_create_user_good(db_session_with_rollback):
user = User(name="Alice")
db_session_with_rollback.add(user)
db_session_with_rollback.commit()
assert user.id is not None
# Изменения откатятся автоматически после теста!
def test_list_users_good(db_session_with_rollback):
# Каждый тест начинает с пустой БД
users = db_session_with_rollback.query(User).all()
assert len(users) == 0
Мокирование внешних зависимостей
4. Изолировать от внешних систем
import pytest
from unittest.mock import Mock, patch, MagicMock
import requests
class PaymentService:
def charge(self, card_id: str, amount: float) -> bool:
"""Взять платёж с карты"""
response = requests.post(
"https://api.payment-provider.com/charge",
json={"card_id": card_id, "amount": amount}
)
return response.status_code == 200
# ПЛОХО: Реальные HTTP запросы в тестах
def test_charge_real_payment():
service = PaymentService()
# Это полагается на внешний API - нестабильно и медленно!
result = service.charge("4532015112830366", 99.99)
# API может быть недоступен, платёж может реально произойти!
# ХОРОШО: Мокировать внешние сервисы
@pytest.fixture
def mock_payment_api():
"""Мокировать API платежей"""
with patch('requests.post') as mock_post:
mock_response = MagicMock()
mock_response.status_code = 200
mock_post.return_value = mock_response
yield mock_post
def test_charge_mock(mock_payment_api):
service = PaymentService()
result = service.charge("4532015112830366", 99.99)
assert result is True
# Проверить, что API был вызван с правильными параметрами
mock_payment_api.assert_called_once()
call_kwargs = mock_payment_api.call_args[1]
assert call_kwargs["json"]["amount"] == 99.99
def test_charge_failure(mock_payment_api):
# В другом тесте можно настроить другой ответ
mock_payment_api.return_value.status_code = 400
service = PaymentService()
result = service.charge("invalid_card", 99.99)
assert result is False
Использование pytest-mock
5. Более удобное мокирование
import pytest
from datetime import datetime
import time
class Clock:
def get_current_time(self) -> datetime:
return datetime.now()
class ScheduledTask:
def __init__(self, clock: Clock):
self.clock = clock
def should_run(self, last_run: datetime, interval_seconds: int) -> bool:
"""Проверить, настало ли время запустить задачу"""
elapsed = (self.clock.get_current_time() - last_run).total_seconds()
return elapsed >= interval_seconds
# ПЛОХО: Нужно ждать реальное время
def test_scheduled_task_bad():
clock = Clock()
task = ScheduledTask(clock)
last_run = datetime.now()
assert not task.should_run(last_run, 60)
time.sleep(61) # МЕДЛЕННЫЙ ТЕСт!!!
assert task.should_run(last_run, 60)
# ХОРОШО: Мокировать время
def test_scheduled_task_good(mocker):
"""mocker - фикстура из pytest-mock"""
clock = Clock()
task = ScheduledTask(clock)
# Установить фиксированное время
mock_now = datetime(2024, 1, 1, 12, 0, 0)
mocker.patch.object(clock, 'get_current_time', return_value=mock_now)
last_run = datetime(2024, 1, 1, 11, 59, 0) # 1 минуту назад
assert task.should_run(last_run, 60) # Тест мгновенный!
assert not task.should_run(last_run, 120) # 2 минуты еще не прошло
Фикстуры для сложных объектов
6. Фабрики объектов
import pytest
from dataclasses import dataclass
from typing import List
@dataclass
class Order:
id: int
items: List[str]
total: float
# ПЛОХО: Дублировать создание в каждом тесте
def test_order_discount():
order = Order(id=1, items=["item1", "item2"], total=100.0)
# ...
def test_order_shipping():
order = Order(id=1, items=["item1", "item2"], total=100.0)
# ...
# ХОРОШО: Использовать фабрику
@pytest.fixture
def sample_order():
"""Фабрика для создания стандартного заказа"""
return Order(id=1, items=["item1", "item2"], total=100.0)
@pytest.fixture
def order_factory():
"""Параметризуемая фабрика"""
def create_order(order_id=1, items=None, total=100.0):
if items is None:
items = ["item1", "item2"]
return Order(id=order_id, items=items, total=total)
return create_order
def test_order_discount(sample_order):
assert sample_order.total == 100.0
def test_expensive_order(order_factory):
order = order_factory(total=1000.0)
assert order.total == 1000.0
def test_bulk_order(order_factory):
order = order_factory(items=["a", "b", "c", "d", "e"])
assert len(order.items) == 5
Очистка ресурсов (Cleanup)
7. Гарантированная очистка
import pytest
from contextlib import contextmanager
class ResourceManager:
def __init__(self):
self.resources = []
def allocate(self, name: str):
resource = f"Resource_{name}"
self.resources.append(resource)
return resource
def cleanup(self):
self.resources.clear()
@pytest.fixture
def resource_manager():
"""Фикстура с гарантированной очисткой"""
manager = ResourceManager()
yield manager
# Cleanup код ВСЕГДА выполнится, даже если тест упал
manager.cleanup()
def test_allocate_resource(resource_manager):
res1 = resource_manager.allocate("res1")
res2 = resource_manager.allocate("res2")
assert len(resource_manager.resources) == 2
# После теста resources будет очищен
def test_resources_isolated(resource_manager):
# Каждый тест получает новый ResourceManager
assert len(resource_manager.resources) == 0
Параметризованные тесты (atomicity)
8. Независимые параметризованные тесты
import pytest
@pytest.mark.parametrize("input_val,expected", [
(1, 2),
(2, 4),
(3, 6),
(5, 10),
])
def test_double(input_val, expected):
"""Каждый параметр — независимый атомарный тест"""
result = input_val * 2
assert result == expected
# Это эквивалентно:
# - test_double[1-2]
# - test_double[2-4]
# - test_double[3-6]
# - test_double[5-10]
# Все независимы друг от друга!
@pytest.fixture(params=[
{"name": "Alice", "age": 30},
{"name": "Bob", "age": 25},
{"name": "Charlie", "age": 35},
])
def user_data(request):
"""Фикстура с параметрами"""
return request.param
def test_user_validation(user_data):
"""Выполнится 3 раза с разными данными"""
assert user_data["age"] >= 18
assert len(user_data["name"]) > 0
Чеклист для атомарных тестов
9. Правила атомарности
"""
ЧЕКЛИСТ АТОМАРНОСТИ:
1. ИЗОЛЯЦИЯ СОСТОЯНИЯ
Каждый тест начинает с чистого состояния
Нет глобальных переменных
Используйте фикстуры для setup/teardown
2. ОТСУТСТВИЕ ЗАВИСИМОСТЕЙ
Тесты не зависят от порядка выполнения
Один тест не влияет на другой
Тесты работают в любом порядке
3. ВНЕШНИЕ ЗАВИСИМОСТИ
HTTP запросы мокированы
БД операции откатываются или работают с памятью
Время и случайные числа контролируются
4. РЕСУРСЫ
Файлы удаляются после теста
Соединения с БД закрываются
Временные данные очищаются
5. СКОРОСТЬ
Тесты не ждут реальное время
Используется в памяти БД (SQLite)
Нет ненужных IO операций
"""
Таким образом, атомарные тесты — это тесты, которые полностью независимы, не зависят от других тестов и внешних систем, и гарантированно оставляют систему в чистом состоянии после выполнения. Используйте фикстуры, мокирование и откаты транзакций.