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

Как нужно писать функцию, чтобы ее было просто тестировать?

2.3 Middle🔥 151 комментариев
#Архитектура и паттерны#Тестирование

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

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

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

Написание тестируемого кода

Код, который легко тестировать, обладает определёнными свойствами. Они вытекают из принципов SOLID, чистой архитектуры и здравого смысла.

1. Функция должна быть чистой (Pure Function)

Чистая функция:

  • Возвращает один результат для одного входа
  • Без побочных эффектов
  • Не изменяет глобальное состояние
  • Не зависит от времени выполнения
# ПЛОХО: нечистая функция
global_multiplier = 2

def calculate(x):
    return x * global_multiplier  # Зависит от глобального состояния!

# Сложно тестировать: нужно мокировать global_multiplier

# ХОРОШО: чистая функция
def calculate(x, multiplier):
    return x * multiplier

# Легко тестировать
assert calculate(5, 2) == 10
assert calculate(5, 3) == 15

2. Функция должна иметь одну ответственность

# ПЛОХО: функция делает слишком много
def process_user_and_send_email(user_data):
    # Валидация
    if not user_data['email']:
        raise ValueError("Email required")
    
    # Преобразование
    user = User(
        name=user_data['name'],
        email=user_data['email']
    )
    
    # Сохранение в БД
    db.users.insert(user)
    
    # Отправка email
    send_email(user.email, "Welcome!")
    
    return user

# Сложно тестировать: нужно мокировать БД, email, валидацию

# ХОРОШО: разделить на функции
def validate_user_data(user_data):
    """Валидация"""
    if not user_data.get('email'):
        raise ValueError("Email required")
    if not user_data.get('name'):
        raise ValueError("Name required")

def create_user(user_data):
    """Только создание объекта"""
    return User(
        name=user_data['name'],
        email=user_data['email']
    )

def process_user(user_data, user_repository, email_service):
    """Оркестрация"""
    validate_user_data(user_data)
    user = create_user(user_data)
    user_repository.insert(user)
    email_service.send_welcome(user.email)
    return user

# Легко тестировать каждую функцию отдельно

3. Зависимости передавайте как параметры (Dependency Injection)

# ПЛОХО: зависимость жёстко закодирована
import requests

def get_user_data(user_id):
    response = requests.get(f'https://api.example.com/users/{user_id}')
    return response.json()

# Нельзя легко мокировать requests!

# ХОРОШО: зависимость как параметр
def get_user_data(user_id, http_client):
    response = http_client.get(f'https://api.example.com/users/{user_id}')
    return response.json()

# Тестирование
class MockHttpClient:
    def get(self, url):
        return MockResponse({'id': 1, 'name': 'John'})

result = get_user_data(1, MockHttpClient())
assert result['name'] == 'John'

4. Явные параметры вместо скрытого состояния

# ПЛОХО: метод класса зависит от состояния
class UserProcessor:
    def __init__(self):
        self.discount = 0.1  # Скрытое состояние
    
    def calculate_price(self, amount):
        return amount * (1 - self.discount)

# Сложно тестировать: нужно создавать объект и менять состояние

# ХОРОШО: передать параметр
def calculate_price(amount, discount):
    return amount * (1 - discount)

# Легко тестировать
assert calculate_price(100, 0.1) == 90
assert calculate_price(100, 0.2) == 80

5. Вернуть результат вместо I/O

# ПЛОХО: функция выполняет I/O
def process_order(order_id):
    order = db.orders.get(order_id)
    total = sum(item.price for item in order.items)
    
    # Сохранение прямо в функции
    db.orders.update(order_id, {'total': total})
    
    # Отправка сообщения
    queue.publish('order_total_calculated', {'order_id': order_id, 'total': total})
    
    return {'success': True}

# Сложно тестировать: нужно мокировать БД и очередь

# ХОРОШО: вернуть результат, пусть вызывающая функция делает I/O
def calculate_order_total(order):
    """Бизнес-логика: только расчёт"""
    return sum(item.price for item in order.items)

def process_order(order_id, order_repository, queue_publisher):
    """Оркестрация: управляет I/O"""
    order = order_repository.get(order_id)
    total = calculate_order_total(order)
    order_repository.update(order_id, {'total': total})
    queue_publisher.publish('order_total_calculated', {
        'order_id': order_id,
        'total': total
    })
    return {'success': True}

# Легко тестировать calculate_order_total
assert calculate_order_total(Order(items=[Item(10), Item(20)])) == 30

6. Избегайте исключений для управления потоком

# ПЛОХО: используем исключения для контроля потока
def find_user(user_id):
    try:
        return db.users.get(user_id)  # Выбрасывает исключение если не найдён
    except UserNotFound:
        return None

# Сложно тестировать: нужно мокировать выброс исключения

# ХОРОШО: вернуть Optional[User]
def find_user(user_id, repository):
    user = repository.find_by_id(user_id)
    return user

# Тестирование
class MockRepository:
    def find_by_id(self, user_id):
        return None

result = find_user(999, MockRepository())
assert result is None

7. Использование типов для самодокументирования

# ПЛОХО: нет типов
def process_data(data, config, logger):
    # Что это за объекты? Что они возвращают?
    pass

# ХОРОШО: явные типы
from typing import List, Dict, Optional
from dataclasses import dataclass

@dataclass
class ProcessConfig:
    batch_size: int
    timeout: float
    retry_count: int

def process_data(
    data: List[Dict[str, any]],
    config: ProcessConfig,
    logger: logging.Logger
) -> List[ProcessedItem]:
    """Обрабатывает данные согласно конфигурации."""
    pass

# Легче писать тесты: ясно какие объекты создавать

8. Практический пример тестируемого кода

from typing import Protocol
from dataclasses import dataclass
import pytest

# Определяем интерфейс (Protocol)
class UserRepository(Protocol):
    def get_by_id(self, user_id: int) -> 'User':
        ...
    def update(self, user_id: int, data: dict) -> None:
        ...

class EmailService(Protocol):
    def send(self, email: str, subject: str, body: str) -> None:
        ...

@dataclass
class User:
    id: int
    name: str
    email: str
    verified: bool = False

# Бизнес-логика: чистая функция
def generate_verification_email(user: User) -> str:
    return f"Hello {user.name}, click here to verify your email"

# Сервис: использует инъекцию зависимостей
class UserVerificationService:
    def __init__(
        self,
        user_repository: UserRepository,
        email_service: EmailService
    ):
        self.user_repository = user_repository
        self.email_service = email_service
    
    def send_verification_email(self, user_id: int) -> bool:
        user = self.user_repository.get_by_id(user_id)
        email_body = generate_verification_email(user)
        self.email_service.send(user.email, "Verify your email", email_body)
        return True

# Тесты
class MockUserRepository:
    def __init__(self):
        self.users = {1: User(1, "John", "john@example.com")}
    
    def get_by_id(self, user_id: int):
        return self.users[user_id]
    
    def update(self, user_id: int, data: dict):
        pass

class MockEmailService:
    def __init__(self):
        self.sent_emails = []
    
    def send(self, email: str, subject: str, body: str):
        self.sent_emails.append({
            'email': email,
            'subject': subject,
            'body': body
        })

def test_send_verification_email():
    # Arrange
    user_repo = MockUserRepository()
    email_service = MockEmailService()
    service = UserVerificationService(user_repo, email_service)
    
    # Act
    result = service.send_verification_email(1)
    
    # Assert
    assert result is True
    assert len(email_service.sent_emails) == 1
    assert email_service.sent_emails[0]['email'] == 'john@example.com'
    assert 'john' in email_service.sent_emails[0]['body'].lower()

def test_generate_verification_email():
    user = User(1, "Jane", "jane@example.com")
    email_body = generate_verification_email(user)
    assert "jane" in email_body.lower()
    assert "verify" in email_body.lower()

Чеклист для тестируемого кода

  • Функция чистая: один вход → один выход, без побочных эффектов
  • Одна ответственность: функция делает одно
  • Зависимости инъецируются: параметры, не глобалы
  • Типы явные: Type hints везде
  • Результаты возвращаются: не выполняется I/O внутри
  • Нет скрытого состояния: всё передаётся параметрами
  • Логирование как параметр: не импортируем глобальный logger
  • Конфигурация как параметр: не читаем из environment внутри функции

Итог

Тестируемый код — это код со слабыми связями (loose coupling) и сильной когезией (strong cohesion). Он следует принципам SOLID и создаёт простую, понятную архитектуру для себя и для тестов.