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

Как сделать тесты атомарными?

1.7 Middle🔥 161 комментариев
#Тестирование

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

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

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

Как сделать тесты атомарными?

Атомарность тестов означает, что каждый тест должен быть независимым, не зависящим от состояния других тестов, и должен оставлять систему в том же состоянии до и после выполнения. Это критически важно для надёжности и воспроизводимости тестов.

Основные принципы атомарности тестов

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 операций
"""

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

Как сделать тесты атомарными? | PrepBro