Берём модуль платежей Todo-приложения без тестов. Применяем все паттерны: characterization, seams, strangler fig
Практика: применим ВСЕ паттерны legacy-рефакторинга к реальному модулю платежей Todo-приложения.
Цель: Покрыть тестами legacy-модуль без тестов и подготовить к добавлению Premium-подписки.
# todo_app/payments.py (LEGACY — БЕЗ ТЕСТОВ)
import stripe
import smtplib
from datetime import datetime
import psycopg2
stripe.api_key = "sk_live_abc123"
class PaymentProcessor:
"""Legacy процессор платежей (написан 2 года назад)"""
def process_payment(self, user_id, amount_cents, card_token):
"""Обработать платёж"""
# Проверка пользователя в БД
conn = psycopg2.connect("postgresql://prod:5432/tododb")
cur = conn.cursor()
cur.execute("SELECT email, name FROM users WHERE id=%s", (user_id,))
user = cur.fetchone()
if not user:
return {"status": "error", "message": "User not found"}
email, name = user
# Создание платежа в Stripe
try:
charge = stripe.Charge.create(
amount=amount_cents,
currency="usd",
source=card_token,
description=f"Todo Premium - {name}"
)
except stripe.error.CardError:
return {"status": "error", "message": "Card declined"}
# Сохранение в БД
cur.execute(
"INSERT INTO payments (user_id, amount, status, created_at) VALUES (%s, %s, %s, %s)",
(user_id, amount_cents, "completed", datetime.now())
)
conn.commit()
# Отправка email
smtp = smtplib.SMTP("smtp.gmail.com", 587)
smtp.login("todo@app.com", "password123")
message = f"Payment received: ${amount_cents/100:.2f}"
smtp.send_message(message, from_addr="todo@app.com", to_addrs=[email])
return {"status": "success", "charge_id": charge.id}# tests/test_payments_characterization.py
import pytest
from unittest.mock import patch, Mock
from todo_app.payments import PaymentProcessor
@pytest.fixture
def mock_dependencies(monkeypatch):
"""Mock всех внешних зависимостей"""
# Mock PostgreSQL
mock_conn = Mock()
mock_cur = Mock()
mock_cur.fetchone.return_value = ("user@test.com", "Test User")
mock_conn.cursor.return_value = mock_cur
monkeypatch.setattr("psycopg2.connect", lambda _: mock_conn)
# Mock Stripe
mock_charge = Mock()
mock_charge.id = "ch_123"
monkeypatch.setattr("stripe.Charge.create", lambda **kwargs: mock_charge)
# Mock SMTP
mock_smtp = Mock()
monkeypatch.setattr("smtplib.SMTP", lambda host, port: mock_smtp)
return {
"conn": mock_conn,
"cursor": mock_cur,
"charge": mock_charge,
"smtp": mock_smtp,
}
def test_successful_payment_characterization(mock_dependencies):
"""Characterization: успешный платёж"""
processor = PaymentProcessor()
result = processor.process_payment(
user_id=1,
amount_cents=2000,
card_token="tok_visa"
)
# Проверка текущего поведения
assert result["status"] == "success"
assert result["charge_id"] == "ch_123"
# Проверка что БД вызвана
mock_dependencies["cursor"].execute.assert_called()
# Проверка что email отправлен
mock_dependencies["smtp"].send_message.assert_called_once()# todo_app/payments_refactored.py
from abc import ABC, abstractmethod
from datetime import datetime
from typing import Optional
# Интерфейсы (Seams)
class PaymentGateway(ABC):
@abstractmethod
def charge(self, amount_cents, card_token, description):
pass
class EmailService(ABC):
@abstractmethod
def send_payment_confirmation(self, email, amount_cents):
pass
class UserRepository(ABC):
@abstractmethod
def get_user(self, user_id):
pass
@abstractmethod
def save_payment(self, user_id, amount_cents, status, timestamp):
pass
# Реализации
class StripeGateway(PaymentGateway):
def __init__(self, api_key):
import stripe
stripe.api_key = api_key
def charge(self, amount_cents, card_token, description):
import stripe
try:
charge = stripe.Charge.create(
amount=amount_cents,
currency="usd",
source=card_token,
description=description
)
return {"status": "success", "charge_id": charge.id}
except stripe.error.CardError:
return {"status": "error", "message": "Card declined"}
class SMTPEmailService(EmailService):
def __init__(self, smtp_host, smtp_user, smtp_password):
self.host = smtp_host
self.user = smtp_user
self.password = smtp_password
def send_payment_confirmation(self, email, amount_cents):
import smtplib
smtp = smtplib.SMTP(self.host, 587)
smtp.login(self.user, self.password)
message = f"Payment received: ${amount_cents/100:.2f}"
smtp.send_message(message, from_addr=self.user, to_addrs=[email])
class PostgreSQLUserRepository(UserRepository):
def __init__(self, connection_string):
import psycopg2
self.conn = psycopg2.connect(connection_string)
def get_user(self, user_id):
cur = self.conn.cursor()
cur.execute("SELECT email, name FROM users WHERE id=%s", (user_id,))
return cur.fetchone()
def save_payment(self, user_id, amount_cents, status, timestamp):
cur = self.conn.cursor()
cur.execute(
"INSERT INTO payments (user_id, amount, status, created_at) VALUES (%s, %s, %s, %s)",
(user_id, amount_cents, status, timestamp)
)
self.conn.commit()
# Новый PaymentProcessor (тестируемый)
class PaymentProcessorV2:
def __init__(
self,
payment_gateway: PaymentGateway,
email_service: EmailService,
user_repository: UserRepository,
current_time_func=None
):
self.payment_gateway = payment_gateway
self.email_service = email_service
self.user_repository = user_repository
self.current_time = current_time_func or datetime.now
def process_payment(self, user_id, amount_cents, card_token):
"""Обработать платёж (тестируемая версия)"""
# Получить пользователя
user = self.user_repository.get_user(user_id)
if not user:
return {"status": "error", "message": "User not found"}
email, name = user
# Создать платёж
charge_result = self.payment_gateway.charge(
amount_cents=amount_cents,
card_token=card_token,
description=f"Todo Premium - {name}"
)
if charge_result["status"] == "error":
return charge_result
# Сохранить в БД
self.user_repository.save_payment(
user_id=user_id,
amount_cents=amount_cents,
status="completed",
timestamp=self.current_time()
)
# Отправить email
self.email_service.send_payment_confirmation(email, amount_cents)
return charge_result# tests/test_payments_v2.py
from unittest.mock import Mock
from datetime import datetime
from todo_app.payments_refactored import PaymentProcessorV2
def test_successful_payment_v2():
"""Тест нового кода с моками"""
# Mock зависимостей
mock_gateway = Mock()
mock_gateway.charge.return_value = {"status": "success", "charge_id": "ch_123"}
mock_email = Mock()
mock_repo = Mock()
mock_repo.get_user.return_value = ("user@test.com", "Test User")
fixed_time = datetime(2025, 1, 1, 12, 0, 0)
# Создать processor
processor = PaymentProcessorV2(
payment_gateway=mock_gateway,
email_service=mock_email,
user_repository=mock_repo,
current_time_func=lambda: fixed_time
)
# Выполнить
result = processor.process_payment(1, 2000, "tok_visa")
# Проверки
assert result["status"] == "success"
assert result["charge_id"] == "ch_123"
mock_gateway.charge.assert_called_once_with(
amount_cents=2000,
card_token="tok_visa",
description="Todo Premium - Test User"
)
mock_repo.save_payment.assert_called_once_with(
user_id=1,
amount_cents=2000,
status="completed",
timestamp=fixed_time
)
mock_email.send_payment_confirmation.assert_called_once_with("user@test.com", 2000)# todo_app/payments_facade.py
import os
from todo_app.payments import PaymentProcessor as LegacyProcessor
from todo_app.payments_refactored import (
PaymentProcessorV2,
StripeGateway,
SMTPEmailService,
PostgreSQLUserRepository
)
USE_V2 = os.getenv("USE_PAYMENT_V2", "false") == "true"
# Инициализация V2
if USE_V2:
gateway = StripeGateway(api_key=os.getenv("STRIPE_API_KEY"))
email_service = SMTPEmailService(
smtp_host="smtp.gmail.com",
smtp_user=os.getenv("SMTP_USER"),
smtp_password=os.getenv("SMTP_PASSWORD")
)
user_repo = PostgreSQLUserRepository(
connection_string=os.getenv("DATABASE_URL")
)
processor_v2 = PaymentProcessorV2(gateway, email_service, user_repo)
def process_payment(user_id, amount_cents, card_token):
"""Facade: роутинг между legacy и V2"""
if USE_V2:
return processor_v2.process_payment(user_id, amount_cents, card_token)
else:
legacy = LegacyProcessor()
return legacy.process_payment(user_id, amount_cents, card_token)### Week 1-2: Подготовка
- [x] Написать characterization tests для legacy
- [x] Рефакторить V2 с seams
- [x] Написать тесты V2
- [x] Code review
### Week 3: Shadow Mode
```python
# Оба кода работают, сравниваем результаты
legacy_result = legacy_processor.process_payment(...)
v2_result = processor_v2.process_payment(...)
if legacy_result != v2_result:
logger.warning("Mismatch detected!")
return legacy_result # Пока используем legacy
```Вопросы ещё не добавлены
Вопросы для этой подтемы ещё не добавлены.
Далее: TDD: Red-Green-Refactor цикл в деталях