Стратег Тестирования
Разрабатывает комплексную стратегию тестирования кода: модульные, интеграционные и нагрузочные тесты с готовыми тест-кейсами и оценкой покрытия.
Пример запуска
Сгенерировано Claude Opus 5.5, 24 сентября 2026 г.
Значения переменных
- Язык программирования
- Python 3.11
- Фреймворк тестирования
- pytest 8.x + pytest-mock + pytest-asyncio
- Тип компонента
- Сервисный слой (OrderService) — бизнес-логика обработки заказов интернет-магазина
- Целевое покрытие
- не менее 90% строк и 85% ветвлений (branch coverage) по модулю order_service.py
- Ключевые требования
- 1. Все внешние вызовы (платёжный провайдер, БД, кэш) должны быть замоканы — модульные тесты не ходят в сеть и не поднимают реальную БД. 2. Тесты детерминированы: никаких datetime.now() без фиксации через freezegun или инъекцию clock, никаких случайных ID без seed. 3. Каждый тест проверяет один сценарий (single assertion concept), название теста по схеме test_<method>_<condition>_<expected>. 4. Обязательно покрыть все пользовательские исключения: InsufficientStockError, PaymentDeclinedError, InvalidDiscountCodeError, OrderNotFoundError. 5. Для метода apply_discount нужны параметризованные тесты (pytest.mark.parametrize) на граничные значения скидки (0%, 1%, 50%, 100%, отрицательные и >100%). 6. Транзакционность: при ошибке оплаты изменения в БД (резерв товара) должны откатываться — нужен тест, подтверждающий вызов session.rollback(). 7. Логирование критических ошибок (PaymentDeclinedError) должно проверяться через caplog. 8. Код асинхронный (async def) — тесты должны использовать pytest.mark.asyncio. 9. Нагрузочный сценарий: создание 500 заказов подряд должно укладываться в лимит по времени, тест допускается как отдельный маркер @pytest.mark.slow, не входящий в основной CI-прогон по умолчанию. 10. Покрытие считать через pytest-cov с отчётом в формате term-missing и xml для загрузки в SonarQube.
- Код для тестирования
- import logging from dataclasses import dataclass from decimal import Decimal from typing import Optional logger = logging.getLogger("order_service") class InsufficientStockError(Exception): pass class PaymentDeclinedError(Exception): pass class InvalidDiscountCodeError(Exception): pass class OrderNotFoundError(Exception): pass @dataclass class Order: id: str user_id: str total: Decimal status: str class OrderService: def __init__(self, db_session, payment_gateway, cache_client, stock_repository): self.db_session = db_session self.payment_gateway = payment_gateway self.cache_client = cache_client self.stock_repository = stock_repository async def create_order(self, user_id: str, items: list[dict]) -> Order: total = Decimal("0") for item in items: available = await self.stock_repository.get_available(item["sku"]) if available < item["qty"]: raise InsufficientStockError( f"Недостаточно товара {item['sku']}: доступно {available}, запрошено {item['qty']}" ) total += Decimal(str(item["price"])) * item["qty"] await self.stock_repository.reserve(items) order = Order(id=self._generate_order_id(user_id), user_id=user_id, total=total, status="pending") try: payment_result = await self.payment_gateway.charge(user_id, total) if not payment_result.success: raise PaymentDeclinedError(f"Платёж отклонён: {payment_result.reason}") except PaymentDeclinedError: logger.error("Оплата заказа %s отклонена, откат резерва товара", order.id) await self.stock_repository.release(items) self.db_session.rollback() raise order.status = "paid" self.db_session.add(order) self.db_session.commit() await self.cache_client.set(f"order:{order.id}", order.status, ttl=3600) return order def apply_discount(self, total: Decimal, discount_percent: float, code: Optional[str] = None) -> Decimal: if discount_percent < 0 or discount_percent > 100: raise InvalidDiscountCodeError( f"Некорректный процент скидки: {discount_percent}" ) if code and not code.isalnum(): raise InvalidDiscountCodeError(f"Некорректный формат кода скидки: {code}") discount = total * (Decimal(str(discount_percent)) / Decimal("100")) return total - discount async def get_order_status(self, order_id: str) -> str: cached = await self.cache_client.get(f"order:{order_id}") if cached: return cached order = self.db_session.query(Order).filter_by(id=order_id).first() if order is None: raise OrderNotFoundError(f"Заказ {order_id} не найден") return order.status def _generate_order_id(self, user_id: str) -> str: import uuid return f"{user_id}-{uuid.uuid4().hex[:8]}"
- Внешние зависимости
- Платёжный шлюз PaymentGateway (внешний REST API провайдера Stripe-like "PayFlow") и StockRepository (внутренний сервис учёта складских остатков) — обе зависимости внедряются через конструктор и должны мокаться (MagicMock/AsyncMock)
- Хранилище данных
- PostgreSQL 15 через SQLAlchemy Session (синхронный db_session, в тестах — in-memory SQLite или мок Session) и Redis 7 в качестве кэша статусов заказов (cache_client, в тестах — фейковый асинхронный клиент fakeredis.aioredis)
- Максимальное время выполнения
- создание 500 заказов должно завершиться не более чем за 3 секунды при полностью замоканных внешних вызовах; отдельный вызов create_order — не более 50 мс
Стратегия тестирования OrderService
Код ниже написан для pytest 8.x, pytest-asyncio ≥ 0.24, pytest-mock, SQLAlchemy 2.x, redis-py ≥ 5.0.1 и fakeredis ≥ 2.20. В этой сессии я его не запускал. Первый прогон стоит сделать локально:
pytest -m "not slow".
0. Анализ кода и риски
0.1. Публичный API, ветвления, зависимости
| Метод | Ветвления | Зависимости | Побочные эффекты |
|---|---|---|---|
create_order (async) |
цикл по items (0 / N итераций); available < qty; not payment_result.success; except PaymentDeclinedError |
stock_repository.get_available/reserve/release, payment_gateway.charge, db_session.add/commit/rollback, cache_client.set, uuid.uuid4 |
резерв на складе, списание денег, запись в БД, запись в кэш, лог ERROR |
apply_discount (sync, чистая функция) |
percent < 0 or percent > 100; code and not code.isalnum() |
нет | нет |
get_order_status (async) |
if cached; order is None |
cache_client.get, db_session.query(...).filter_by(...).first() |
нет |
_generate_order_id (private) |
нет | uuid.uuid4 (импорт внутри функции) |
недетерминированный результат |
У модуля нет зависимости от времени (datetime.now() не вызывается), поэтому freezegun не нужен. Детерминизм обеспечивается патчем uuid.uuid4. Поскольку import uuid выполняется внутри метода, патчить нужно именно uuid.uuid4, а не order_service.uuid.
0.2. Риски и найденные дефекты (по приоритету)
| ID | Приоритет | Риск / дефект | Как закрываем |
|---|---|---|---|
| DEFECT-1 | P0 | Если charge бросает что-то кроме PaymentDeclinedError (таймаут, 5xx от PayFlow, ConnectionError), резерв не освобождается и rollback не вызывается. Товар «утекает». |
xfail(strict=True): U-CO-21 |
| DEFECT-2 | P1 | Если падает release() в блоке компенсации, rollback() не вызывается, а исходный PaymentDeclinedError маскируется новой ошибкой. |
xfail(strict=True): U-CO-22 |
| DEFECT-3 | P1 | apply_discount(total, float("nan")) проходит проверку диапазона (любое сравнение с NaN даёт False) и возвращает Decimal('NaN'). |
xfail(strict=True): U-AD-03 |
| RISK-4 | P1 | cache_client.set(..., ttl=3600) несовместим с redis.asyncio/fakeredis: там параметр называется ex, и вызов даёт TypeError. Без decode_responses=True get вернёт bytes. |
контрактный тест INT-07 + адаптер (допущение A2) |
| RISK-5 | P1 | Сбой кэша после commit: заказ оплачен и сохранён, но клиент получает исключение. Повтор запроса приведёт к двойному списанию. |
характеризационный тест U-CO-23 |
| RISK-6 | P1 | Пустой items, qty <= 0, отрицательная цена, дубли одного SKU в разных строках: проверка остатков идёт построчно, поэтому суммарный qty может превысить остаток. |
U-CO-24 + список тестов после уточнения |
| RISK-7 | P2 | TOCTOU: между get_available и reserve остаток может измениться. |
контракт reserve (атомарность) уточнить |
| RISK-8 | P2 | db_session.rollback() откатывает все pending-изменения общей сессии, включая чужие. Резерв при этом живёт во внешнем сервисе и откатывается только через release. |
учтено в допущении A3 |
| RISK-9 | P2 | Деньги не квантуются (17.991); синхронная БД блокирует event loop; суффикс ID всего 32 бита (коллизии); кэш может быть устаревшим, "" в кэше проваливается в БД, при промахе кэш не прогревается. |
характеризационные тесты + рекомендации |
1. Таблица тест-кейсов
| ID | Уровень | Сценарий | Входные данные | Ожидаемый результат | Приор. |
|---|---|---|---|---|---|
| U-CO-01 | unit | Успешный заказ | 1 позиция, qty=2, price=10.50 | Order(id="user42-12345678", total=21.00, status="paid") |
P0 |
| U-CO-02 | unit | Точность суммы (param ×3) | 0.1×3; мульти-позиции; 0.01 |
0.3, 29.99, 0.01 без float-дрейфа |
P0 |
| U-CO-03 | unit | Аргументы списания | успешный заказ | charge(user_id, total) вызван 1 раз |
P0 |
| U-CO-04 | unit | Граница остатка (param) | available = qty; available > qty | reserve(items) вызван 1 раз |
P0 |
| U-CO-05 | unit | Сохранение в сессию | успешный заказ | add(order) |
P0 |
| U-CO-06 | unit | Фиксация транзакции | успешный заказ | commit() 1 раз |
P0 |
| U-CO-07 | unit | Запись в кэш | успешный заказ | set("order:<id>", "paid", ttl=3600) |
P1 |
| U-CO-08 | unit | Нет ложных ERROR-логов | успешный заказ | логов order_service нет |
P2 |
| U-CO-09 | unit | Не хватает 1 шт. | available=4, qty=5 | InsufficientStockError с sku/доступно/запрошено |
P0 |
| U-CO-10 | unit | Товара нет | available=0 | InsufficientStockError |
P1 |
| U-CO-11 | unit | Нет побочных эффектов при нехватке (param ×4) | available=0 | reserve/charge/commit/cache.set не вызваны | P0 |
| U-CO-12 | unit | Остановка на 2-й позиции | остатки [10, 0, 10] | исключение про SKU-2, 2 проверки остатка | P1 |
| U-CO-13 | unit | Отказ платежа | success=False, reason=insufficient_funds |
PaymentDeclinedError с причиной |
P0 |
| U-CO-14 | unit | Откат транзакции | отказ платежа | session.rollback() 1 раз |
P0 |
| U-CO-15 | unit | Освобождение резерва | отказ платежа | release(items) 1 раз |
P0 |
| U-CO-16 | unit | Нет персистентности (param ×3) | отказ платежа | add/commit/cache.set не вызваны | P0 |
| U-CO-17 | unit | Порядок компенсации | отказ платежа | reserve → charge → release → rollback | P1 |
| U-CO-18 | unit | Лог через caplog | отказ платежа | 1 запись ERROR от order_service с ID заказа |
P0 |
| U-CO-19 | unit | Шлюз сам бросает PaymentDeclinedError |
side_effect=PaymentDeclinedError |
rollback() вызван |
P1 |
| U-CO-20 | unit | Таймаут шлюза | side_effect=TimeoutError |
исключение пробрасывается | P1 |
| U-CO-21 | unit | DEFECT-1 (xfail) | side_effect=TimeoutError |
ожидается release(), сейчас не вызывается |
P0 |
| U-CO-22 | unit | DEFECT-2 (xfail) | отказ + release падает |
ожидается rollback(), сейчас не вызывается |
P1 |
| U-CO-23 | unit | Кэш недоступен после commit (характеризация) | cache.set → ConnectionError |
commit выполнен, ошибка проброшена | P1 |
| U-CO-24 | unit | Пустой список (характеризация) | items=[] |
списание Decimal("0") |
P2 |
| U-ID-01 | unit | Формат ID (фикс. UUID) | "u1" |
"u1-12345678" |
P1 |
| U-ID-02 | unit | Формат ID (реальный UUID) | "u1" |
^u1-[0-9a-f]{8}$ |
P2 |
| U-AD-01 | unit | Валидные границы (param ×8) | 0, 0.01, 1, 12.5 (Decimal), 33.3, 50, 99.99, 100 | 100.00 … 0.00 | P0 |
| U-AD-02 | unit | Вне диапазона (param ×8) | -0.01, -1, -100, 100.01, 101, 1000, ±inf | InvalidDiscountCodeError («процент») |
P0 |
| U-AD-03 | unit | DEFECT-3 NaN (xfail) | nan |
ожидается InvalidDiscountCodeError |
P1 |
| U-AD-04 | unit | Валидные коды (param ×6) | None, "", SALE10, sale, 2024, СКИДКА10 | скидка применена | P1 |
| U-AD-05 | unit | Невалидные коды (param ×7) | SALE-10, SALE 10, " ", 10%, code!, a_b, SALE10\n |
InvalidDiscountCodeError («формат кода») |
P0 |
| U-AD-06 | unit | Приоритет проверок | 150%, BAD-CODE |
ошибка про процент | P2 |
| U-AD-07 | unit | Нет округления (характеризация) | 19.99, 10% | 17.991 |
P2 |
| U-AD-08 | unit | Нулевая сумма | 0, 50% | 0 | P2 |
| U-AD-09 | unit | Тип результата | 100.00, 10% | Decimal |
P2 |
| U-GS-01 | unit | Попадание в кэш | cache="paid" | "paid" | P0 |
| U-GS-02 | unit | Кэш-хит не ходит в БД | cache="paid" | query не вызван |
P1 |
| U-GS-03 | unit | Формат ключа | id="o-1" | get("order:o-1") |
P1 |
| U-GS-04 | unit | Промах → БД | cache=None, БД="shipped" | "shipped" | P0 |
| U-GS-05 | unit | Фильтр по ID | cache=None | filter_by(id="o-1") |
P1 |
| U-GS-06 | unit | Заказ не найден | cache=None, БД=None | OrderNotFoundError |
P0 |
| U-GS-07 | unit | Пустая строка в кэше (характеризация) | cache="" | фоллбэк в БД | P2 |
| U-GS-08 | unit | Кэш недоступен (характеризация) | ConnectionError |
ошибка проброшена, фоллбэка нет | P2 |
| INT-01 | integr. | Заказ записан в SQLite | SQLite in-memory + fakeredis | строка status="paid" |
P0 |
| INT-02 | integr. | Статус и TTL в Redis | то же | "paid", TTL ≈ 3600 |
P1 |
| INT-03 | integr. | Статус из БД после вытеснения кэша | flushall() |
"paid" | P1 |
| INT-04 | integr. | Кэш приоритетнее БД | в кэше "shipped" | "shipped" | P2 |
| INT-05a/b/c | integr. | Откат при отказе | отказ платежа | 0 строк в БД, остаток восстановлен, кэш пуст | P0 |
| INT-06 | integr. | Нехватка не меняет остаток | available=10, qty=11 | остаток 10 | P1 |
| INT-07 | integr. | Контракт redis-py | FakeRedis.set(..., ttl=) |
TypeError (подтверждает RISK-4) |
P1 |
| INT-08 | integr. | Неизвестный заказ | пустые БД и кэш | OrderNotFoundError |
P1 |
| PERF-01 | perf (slow) | 500 заказов подряд | моки | < 3 с | P1 |
| PERF-02 | perf (slow) | Один вызов | 500 замеров | max < 50 мс | P1 |
2. Код тестов
Структура
pyproject.toml
requirements-test.txt
order_service.py
tests/
├── __init__.py
├── helpers.py
├── conftest.py
├── unit/
│ ├── __init__.py
│ ├── test_create_order.py
│ ├── test_apply_discount.py
│ ├── test_get_order_status.py
│ └── test_generate_order_id.py
├── integration/
│ ├── __init__.py
│ ├── conftest.py
│ └── test_order_flow.py
└── performance/
├── __init__.py
└── test_create_order_perf.py
Файлы __init__.py пустые.
requirements-test.txt
pytest>=8.0,<9
pytest-asyncio>=0.24
pytest-mock>=3.12
pytest-cov>=5.0
SQLAlchemy>=2.0
redis>=5.0.1
fakeredis>=2.20
hypothesis>=6.100 # для property-based тестов из раздела 4
pyproject.toml
[tool.pytest.ini_options]
minversion = "8.0"
testpaths = ["tests"]
pythonpath = ["."]
asyncio_mode = "strict" # async-тесты только с явным @pytest.mark.asyncio
asyncio_default_fixture_loop_scope = "function"
xfail_strict = true # починили дефект -> XPASS -> красный тест, маркер надо снять
markers = [
"slow: нагрузочные тесты; по умолчанию исключены (pytest -m slow --no-cov)",
"integration: тесты с SQLite in-memory и fakeredis",
]
addopts = """
-ra --strict-markers --strict-config
-m "not slow"
--cov=order_service --cov-branch
--cov-report=term-missing
--cov-report=xml:coverage.xml
--cov-fail-under=90
"""
[tool.coverage.run]
branch = true
relative_files = true # относительные пути в coverage.xml — нужно SonarQube в CI/Docker
[tool.coverage.report]
show_missing = true
tests/helpers.py
"""Общие тестовые утилиты: контракты зависимостей, фейки, фабрики данных.
Контракты (Protocol-подобные классы) описывают ДОПУЩЕНИЯ об интерфейсах внешних
зависимостей (см. раздел «Допущения»). create_autospec по ним ловит опечатки
в именах методов и неверные сигнатуры.
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass
from decimal import Decimal
from typing import Any
FIXED_UUID = uuid.UUID("12345678-9abc-4def-8123-456789abcdef")
def expected_order_id(user_id: str) -> str:
return f"{user_id}-{FIXED_UUID.hex[:8]}"
def make_item(sku: str = "SKU-1", qty: int = 1, price: Any = "10.00") -> dict:
"""Каждый вызов возвращает новый dict: общее изменяемое состояние между тестами исключено."""
return {"sku": sku, "qty": qty, "price": price}
# ---------- Контракты зависимостей (допущения A1–A3) ----------
@dataclass(frozen=True)
class PaymentResult:
success: bool
reason: str | None = None
class PaymentGatewayContract:
async def charge(self, user_id: str, amount: Decimal) -> PaymentResult: ...
class StockRepositoryContract:
async def get_available(self, sku: str) -> int: ...
async def reserve(self, items: list[dict]) -> None: ...
async def release(self, items: list[dict]) -> None: ...
class CacheClientContract:
async def get(self, key: str) -> str | None: ...
async def set(self, key: str, value: str, ttl: int | None = None) -> None: ...
# ---------- Фейки для интеграционного уровня ----------
class RedisCacheAdapter:
"""Адаптер redis.asyncio -> контракт CacheClientContract (ttl -> ex)."""
def __init__(self, redis_client) -> None:
self._redis = redis_client
async def get(self, key: str) -> str | None:
return await self._redis.get(key)
async def set(self, key: str, value: str, ttl: int | None = None) -> None:
await self._redis.set(key, value, ex=ttl)
class InMemoryStockRepository:
"""Фейк склада с состоянием: позволяет проверить, что reserve/release взаимно компенсируются."""
def __init__(self, levels: dict[str, int]) -> None:
self.available: dict[str, int] = dict(levels)
async def get_available(self, sku: str) -> int:
return self.available.get(sku, 0)
async def reserve(self, items: list[dict]) -> None:
for item in items:
self.available[item["sku"]] -= item["qty"]
async def release(self, items: list[dict]) -> None:
for item in items:
self.available[item["sku"]] += item["qty"]
tests/conftest.py
"""Базовые фикстуры: все внешние зависимости — autospec-моки, без сети и БД."""
import pytest
from sqlalchemy.orm import Session
from order_service import OrderService
from tests.helpers import (
FIXED_UUID,
CacheClientContract,
PaymentGatewayContract,
PaymentResult,
StockRepositoryContract,
)
@pytest.fixture
def fixed_uuid(mocker):
# uuid импортируется внутри _generate_order_id, поэтому патчим атрибут модуля uuid
return mocker.patch("uuid.uuid4", return_value=FIXED_UUID)
@pytest.fixture
def payment_gateway(mocker):
gateway = mocker.create_autospec(PaymentGatewayContract, instance=True)
gateway.charge.return_value = PaymentResult(success=True) # AsyncMock: это awaited-значение
return gateway
@pytest.fixture
def stock_repository(mocker):
repo = mocker.create_autospec(StockRepositoryContract, instance=True)
repo.get_available.return_value = 100
return repo
@pytest.fixture
def cache_client(mocker):
cache = mocker.create_autospec(CacheClientContract, instance=True)
cache.get.return_value = None
return cache
@pytest.fixture
def db_session(mocker):
# Синхронная SQLAlchemy Session; цепочка query().filter_by().first() — обычные MagicMock
return mocker.create_autospec(Session, instance=True)
@pytest.fixture
def service(db_session, payment_gateway, cache_client, stock_repository, fixed_uuid):
return OrderService(
db_session=db_session,
payment_gateway=payment_gateway,
cache_client=cache_client,
stock_repository=stock_repository,
)
tests/unit/test_create_order.py
"""Модульные тесты OrderService.create_order."""
import logging
import operator
from decimal import Decimal
import pytest
from order_service import InsufficientStockError, Order, PaymentDeclinedError
from tests.helpers import PaymentResult, expected_order_id, make_item
pytestmark = pytest.mark.asyncio
USER_ID = "user42"
DECLINE_REASON = "insufficient_funds"
@pytest.fixture
def declined_gateway(payment_gateway):
payment_gateway.charge.return_value = PaymentResult(success=False, reason=DECLINE_REASON)
return payment_gateway
def _recorder(journal: list[str], name: str, result=None):
"""side_effect, который пишет имя вызова в общий журнал (для проверки порядка)."""
def _side_effect(*args, **kwargs):
journal.append(name)
return result
return _side_effect
# ---------------- Успешный сценарий ----------------
async def test_create_order_valid_single_item_returns_paid_order(service):
# Arrange
items = [make_item("SKU-1", qty=2, price="10.50")]
# Act
order = await service.create_order(USER_ID, items)
# Assert
assert order == Order(
id=expected_order_id(USER_ID), user_id=USER_ID, total=Decimal("21.00"), status="paid"
)
@pytest.mark.parametrize(
("items", "expected_total"),
[
pytest.param([make_item("A", 3, 0.1)], Decimal("0.3"), id="float-0.1x3-no-binary-drift"),
pytest.param([make_item("A", 1, 19.99), make_item("B", 2, 5)], Decimal("29.99"), id="multi-items"),
pytest.param([make_item("A", 1, "0.01")], Decimal("0.01"), id="min-price"),
],
)
async def test_create_order_mixed_price_types_charges_exact_decimal_total(
service, payment_gateway, items, expected_total
):
await service.create_order(USER_ID, items)
payment_gateway.charge.assert_awaited_once_with(USER_ID, expected_total)
async def test_create_order_valid_items_charges_user_once(service, payment_gateway):
await service.create_order(USER_ID, [make_item("SKU-1", qty=2, price="10.50")])
payment_gateway.charge.assert_awaited_once_with(USER_ID, Decimal("21.00"))
@pytest.mark.parametrize(
("available", "qty"),
[(5, 5), (6, 5)],
ids=["available-equals-qty", "available-exceeds-qty"],
)
async def test_create_order_sufficient_stock_reserves_items(service, stock_repository, available, qty):
stock_repository.get_available.return_value = available
items = [make_item("SKU-1", qty=qty)]
await service.create_order(USER_ID, items)
stock_repository.reserve.assert_awaited_once_with(items)
async def test_create_order_payment_success_adds_order_to_session(service, db_session):
order = await service.create_order(USER_ID, [make_item()])
db_session.add.assert_called_once_with(order)
async def test_create_order_payment_success_commits_once(service, db_session):
await service.create_order(USER_ID, [make_item()])
db_session.commit.assert_called_once_with()
async def test_create_order_payment_success_caches_status_with_ttl(service, cache_client):
await service.create_order(USER_ID, [make_item()])
cache_client.set.assert_awaited_once_with(f"order:{expected_order_id(USER_ID)}", "paid", ttl=3600)
async def test_create_order_payment_success_logs_no_errors(service, caplog):
caplog.set_level(logging.ERROR, logger="order_service")
await service.create_order(USER_ID, [make_item()])
assert [r for r in caplog.records if r.name == "order_service"] == []
# ---------------- InsufficientStockError ----------------
async def test_create_order_stock_short_by_one_raises_insufficient_stock(service, stock_repository):
stock_repository.get_available.return_value = 4
with pytest.raises(InsufficientStockError, match=r"SKU-1: доступно 4, запрошено 5"):
await service.create_order(USER_ID, [make_item("SKU-1", qty=5)])
async def test_create_order_zero_stock_raises_insufficient_stock(service, stock_repository):
stock_repository.get_available.return_value = 0
with pytest.raises(InsufficientStockError):
await service.create_order(USER_ID, [make_item(qty=1)])
@pytest.mark.parametrize(
"dependency_call",
["stock_repository.reserve", "payment_gateway.charge", "db_session.commit", "cache_client.set"],
)
async def test_create_order_insufficient_stock_skips_side_effect(service, stock_repository, dependency_call):
stock_repository.get_available.return_value = 0
with pytest.raises(InsufficientStockError):
await service.create_order(USER_ID, [make_item(qty=1)])
assert operator.attrgetter(dependency_call)(service).call_count == 0
async def test_create_order_second_item_short_stops_stock_checks(service, stock_repository):
stock_repository.get_available.side_effect = [10, 0, 10]
items = [make_item("SKU-1"), make_item("SKU-2"), make_item("SKU-3")]
with pytest.raises(InsufficientStockError, match="SKU-2"):
await service.create_order(USER_ID, items)
assert stock_repository.get_available.await_count == 2
# ---------------- PaymentDeclinedError и транзакционность ----------------
async def test_create_order_payment_declined_raises_with_reason(service, declined_gateway):
with pytest.raises(PaymentDeclinedError, match=f"Платёж отклонён: {DECLINE_REASON}"):
await service.create_order(USER_ID, [make_item()])
async def test_create_order_payment_declined_rolls_back_session(service, declined_gateway, db_session):
with pytest.raises(PaymentDeclinedError):
await service.create_order(USER_ID, [make_item()])
db_session.rollback.assert_called_once_with()
async def test_create_order_payment_declined_releases_reserved_stock(service, declined_gateway, stock_repository):
items = [make_item("SKU-1", qty=2)]
with pytest.raises(PaymentDeclinedError):
await service.create_order(USER_ID, items)
stock_repository.release.assert_awaited_once_with(items)
@pytest.mark.parametrize("dependency_call", ["db_session.add", "db_session.commit", "cache_client.set"])
async def test_create_order_payment_declined_skips_persistence(service, declined_gateway, dependency_call):
with pytest.raises(PaymentDeclinedError):
await service.create_order(USER_ID, [make_item()])
assert operator.attrgetter(dependency_call)(service).call_count == 0
async def test_create_order_payment_declined_compensates_in_order(
service, payment_gateway, stock_repository, db_session
):
# Arrange: единый журнал вызовов всех зависимостей
journal: list[str] = []
stock_repository.reserve.side_effect = _recorder(journal, "reserve")
payment_gateway.charge.side_effect = _recorder(journal, "charge", PaymentResult(False, DECLINE_REASON))
stock_repository.release.side_effect = _recorder(journal, "release")
db_session.rollback.side_effect = _recorder(journal, "rollback")
# Act
with pytest.raises(PaymentDeclinedError):
await service.create_order(USER_ID, [make_item()])
# Assert
assert journal == ["reserve", "charge", "release", "rollback"]
async def test_create_order_payment_declined_logs_error_with_order_id(service, declined_gateway, caplog):
caplog.set_level(logging.ERROR, logger="order_service")
with pytest.raises(PaymentDeclinedError):
await service.create_order(USER_ID, [make_item()])
records = [(r.levelno, r.getMessage()) for r in caplog.records if r.name == "order_service"]
assert records == [
(logging.ERROR, f"Оплата заказа {expected_order_id(USER_ID)} отклонена, откат резерва товара")
]
async def test_create_order_gateway_raises_declined_rolls_back_session(service, payment_gateway, db_session):
payment_gateway.charge.side_effect = PaymentDeclinedError("fraud_suspected")
with pytest.raises(PaymentDeclinedError, match="fraud_suspected"):
await service.create_order(USER_ID, [make_item()])
db_session.rollback.assert_called_once_with()
async def test_create_order_gateway_timeout_propagates_original_error(service, payment_gateway):
payment_gateway.charge.side_effect = TimeoutError("PayFlow timeout")
with pytest.raises(TimeoutError, match="PayFlow timeout"):
await service.create_order(USER_ID, [make_item()])
@pytest.mark.xfail(reason="DEFECT-1: при сбое шлюза (не PaymentDeclinedError) резерв не освобождается")
async def test_create_order_gateway_timeout_releases_reserved_stock(service, payment_gateway, stock_repository):
payment_gateway.charge.side_effect = TimeoutError("PayFlow timeout")
with pytest.raises(TimeoutError):
await service.create_order(USER_ID, [make_item()])
stock_repository.release.assert_awaited_once()
@pytest.mark.xfail(reason="DEFECT-2: исключение в release() пропускает rollback() и маскирует отказ оплаты")
async def test_create_order_release_fails_still_rolls_back_session(
service, declined_gateway, stock_repository, db_session
):
stock_repository.release.side_effect = ConnectionError("stock service down")
with pytest.raises(ConnectionError):
await service.create_order(USER_ID, [make_item()])
db_session.rollback.assert_called_once_with()
# ---------------- Характеризационные тесты (фиксируют ТЕКУЩЕЕ поведение, см. «Допущения») ----------------
async def test_create_order_cache_unavailable_commits_then_raises(service, cache_client, db_session):
# RISK-5: заказ зафиксирован в БД, но клиент получит ошибку -> риск повторной оплаты
cache_client.set.side_effect = ConnectionError("redis down")
with pytest.raises(ConnectionError):
await service.create_order(USER_ID, [make_item()])
db_session.commit.assert_called_once_with()
async def test_create_order_empty_items_charges_zero_total(service, payment_gateway):
# RISK-6: пустой заказ сейчас не валидируется; ожидаемое поведение нужно уточнить
await service.create_order(USER_ID, [])
payment_gateway.charge.assert_awaited_once_with(USER_ID, Decimal("0"))
tests/unit/test_apply_discount.py
"""Модульные тесты OrderService.apply_discount (синхронная чистая функция)."""
from decimal import Decimal
import pytest
from order_service import InvalidDiscountCodeError, OrderService
TOTAL = Decimal("100.00")
@pytest.fixture
def svc():
# apply_discount не использует зависимости — передаём None, чтобы это было явно видно
return OrderService(db_session=None, payment_gateway=None, cache_client=None, stock_repository=None)
@pytest.mark.parametrize(
("percent", "expected"),
[
pytest.param(0, Decimal("100.00"), id="0%-lower-bound"),
pytest.param(0.01, Decimal("99.99"), id="0.01%-just-above-lower"),
pytest.param(1, Decimal("99.00"), id="1%"),
pytest.param(Decimal("12.5"), Decimal("87.50"), id="12.5%-decimal-input"),
pytest.param(33.3, Decimal("66.70"), id="33.3%-fractional"),
pytest.param(50, Decimal("50.00"), id="50%"),
pytest.param(99.99, Decimal("0.01"), id="99.99%-just-below-upper"),
pytest.param(100, Decimal("0.00"), id="100%-upper-bound"),
],
)
def test_apply_discount_valid_percent_returns_discounted_total(svc, percent, expected):
assert svc.apply_discount(TOTAL, percent) == expected
@pytest.mark.parametrize(
"percent",
[
pytest.param(-0.01, id="-0.01%-just-below-lower"),
pytest.param(-1, id="-1%"),
pytest.param(-100, id="-100%"),
pytest.param(100.01, id="100.01%-just-above-upper"),
pytest.param(101, id="101%"),
pytest.param(1000, id="1000%"),
pytest.param(float("inf"), id="+inf"),
pytest.param(float("-inf"), id="-inf"),
],
)
def test_apply_discount_percent_out_of_range_raises_invalid_discount(svc, percent):
with pytest.raises(InvalidDiscountCodeError, match="Некорректный процент скидки"):
svc.apply_discount(TOTAL, percent)
@pytest.mark.xfail(reason="DEFECT-3: NaN проходит проверку диапазона и возвращается Decimal('NaN')")
def test_apply_discount_nan_percent_raises_invalid_discount(svc):
with pytest.raises(InvalidDiscountCodeError):
svc.apply_discount(TOTAL, float("nan"))
@pytest.mark.parametrize(
"code",
[None, "", "SALE10", "sale", "2024", "СКИДКА10"],
ids=["none", "empty-string", "alnum-upper", "alpha-lower", "digits-only", "cyrillic"],
)
def test_apply_discount_valid_code_format_applies_discount(svc, code):
assert svc.apply_discount(TOTAL, 10, code) == Decimal("90.00")
@pytest.mark.parametrize(
"code",
["SALE-10", "SALE 10", " ", "10%", "code!", "a_b", "SALE10\n"],
ids=["hyphen", "space-inside", "whitespace-only", "percent-sign", "exclamation", "underscore", "trailing-newline"],
)
def test_apply_discount_non_alnum_code_raises_invalid_discount(svc, code):
with pytest.raises(InvalidDiscountCodeError, match="Некорректный формат кода скидки"):
svc.apply_discount(TOTAL, 10, code)
def test_apply_discount_invalid_percent_and_code_reports_percent_error(svc):
with pytest.raises(InvalidDiscountCodeError, match="процент"):
svc.apply_discount(TOTAL, 150, "BAD-CODE")
def test_apply_discount_fractional_result_returns_unrounded_value(svc):
# Характеризация RISK-9: результат не квантуется до копеек
assert svc.apply_discount(Decimal("19.99"), 10) == Decimal("17.991")
def test_apply_discount_zero_total_returns_zero(svc):
assert svc.apply_discount(Decimal("0"), 50) == Decimal("0")
def test_apply_discount_valid_input_returns_decimal_type(svc):
assert isinstance(svc.apply_discount(TOTAL, 10), Decimal)
tests/unit/test_get_order_status.py
"""Модульные тесты OrderService.get_order_status."""
from decimal import Decimal
import pytest
from order_service import Order, OrderNotFoundError
pytestmark = pytest.mark.asyncio
ORDER_ID = "o-1"
def _db_returns(db_session, order):
db_session.query.return_value.filter_by.return_value.first.return_value = order
async def test_get_order_status_cache_hit_returns_cached_status(service, cache_client):
cache_client.get.return_value = "paid"
assert await service.get_order_status(ORDER_ID) == "paid"
async def test_get_order_status_cache_hit_skips_database(service, cache_client, db_session):
cache_client.get.return_value = "paid"
await service.get_order_status(ORDER_ID)
db_session.query.assert_not_called()
async def test_get_order_status_any_id_uses_prefixed_cache_key(service, cache_client):
cache_client.get.return_value = "paid"
await service.get_order_status(ORDER_ID)
cache_client.get.assert_awaited_once_with(f"order:{ORDER_ID}")
async def test_get_order_status_cache_miss_returns_db_status(service, db_session):
_db_returns(db_session, Order(id=ORDER_ID, user_id="u1", total=Decimal("1"), status="shipped"))
assert await service.get_order_status(ORDER_ID) == "shipped"
async def test_get_order_status_cache_miss_filters_by_order_id(service, db_session):
_db_returns(db_session, Order(id=ORDER_ID, user_id="u1", total=Decimal("1"), status="paid"))
await service.get_order_status(ORDER_ID)
db_session.query.return_value.filter_by.assert_called_once_with(id=ORDER_ID)
async def test_get_order_status_unknown_order_raises_order_not_found(service, db_session):
_db_returns(db_session, None)
with pytest.raises(OrderNotFoundError, match="Заказ o-404 не найден"):
await service.get_order_status("o-404")
async def test_get_order_status_empty_cached_value_falls_back_to_db(service, cache_client, db_session):
# Характеризация: "" считается промахом кэша (if cached)
cache_client.get.return_value = ""
_db_returns(db_session, Order(id=ORDER_ID, user_id="u1", total=Decimal("1"), status="paid"))
assert await service.get_order_status(ORDER_ID) == "paid"
async def test_get_order_status_cache_unavailable_propagates_error(service, cache_client):
# Характеризация: деградации до БД при недоступном Redis нет
cache_client.get.side_effect = ConnectionError("redis down")
with pytest.raises(ConnectionError):
await service.get_order_status(ORDER_ID)
tests/unit/test_generate_order_id.py
import re
from order_service import OrderService
def test_generate_order_id_fixed_uuid_returns_user_prefixed_id(service):
# service зависит от fixed_uuid -> uuid4 детерминирован
assert service._generate_order_id("u1") == "u1-12345678"
def test_generate_order_id_real_uuid_matches_expected_format():
# Значение случайное, но проверяется только формат, поэтому результат теста детерминирован
svc = OrderService(db_session=None, payment_gateway=None, cache_client=None, stock_repository=None)
assert re.fullmatch(r"u1-[0-9a-f]{8}", svc._generate_order_id("u1"))
tests/integration/conftest.py
"""Интеграционный уровень: реальная SQLAlchemy Session (SQLite in-memory) + fakeredis.
PaymentGateway остаётся autospec-моком (из корневого conftest), склад — фейк с состоянием."""
import fakeredis
import pytest
import pytest_asyncio
from fakeredis import aioredis as fake_aioredis
from sqlalchemy import Column, Numeric, String, Table, create_engine
from sqlalchemy.orm import Session, clear_mappers, registry
from order_service import Order
from tests.helpers import InMemoryStockRepository, RedisCacheAdapter
@pytest.fixture(scope="module")
def orders_metadata():
# Order — обычный dataclass (допущение A5): мапим императивно только на время модуля
reg = registry()
orders = Table(
"orders",
reg.metadata,
Column("id", String(64), primary_key=True),
Column("user_id", String(64), nullable=False),
Column("total", Numeric(12, 2), nullable=False),
Column("status", String(16), nullable=False),
)
reg.map_imperatively(Order, orders)
yield reg.metadata
clear_mappers() # возвращаем Order в исходное состояние для остальных тестов
@pytest.fixture
def db_session(orders_metadata): # переопределяет мок из корневого conftest
engine = create_engine("sqlite+pysqlite:///:memory:")
orders_metadata.create_all(engine)
with Session(engine) as session:
yield session
engine.dispose()
@pytest_asyncio.fixture
async def fake_redis():
# Отдельный FakeServer на каждый тест: данные между тестами не пересекаются
client = fake_aioredis.FakeRedis(server=fakeredis.FakeServer(), decode_responses=True)
yield client
await client.aclose()
@pytest.fixture
def cache_client(fake_redis): # переопределяет мок из корневого conftest
return RedisCacheAdapter(fake_redis)
@pytest.fixture
def stock_repository(): # переопределяет мок из корневого conftest
return InMemoryStockRepository({"SKU-1": 10})
tests/integration/test_order_flow.py
"""Интеграция OrderService с SQLAlchemy (SQLite) и Redis (fakeredis)."""
import pytest
from sqlalchemy import text
from order_service import InsufficientStockError, OrderNotFoundError, PaymentDeclinedError
from tests.helpers import PaymentResult, make_item
pytestmark = [
pytest.mark.asyncio,
pytest.mark.integration,
pytest.mark.filterwarnings("ignore:.*support Decimal objects natively:sqlalchemy.exc.SAWarning"),
]
async def test_create_order_success_persists_paid_row(service, db_session):
order = await service.create_order("user1", [make_item("SKU-1", 2, "10.00")])
status = db_session.execute(
text("SELECT status FROM orders WHERE id = :id"), {"id": order.id}
).scalar_one()
assert status == "paid"
async def test_create_order_success_stores_status_in_redis(service, fake_redis):
order = await service.create_order("user1", [make_item()])
assert await fake_redis.get(f"order:{order.id}") == "paid"
async def test_create_order_success_sets_redis_ttl_one_hour(service, fake_redis):
order = await service.create_order("user1", [make_item()])
assert 3590 < await fake_redis.ttl(f"order:{order.id}") <= 3600
async def test_get_order_status_cache_evicted_reads_status_from_db(service, fake_redis):
order = await service.create_order("user1", [make_item()])
await fake_redis.flushall()
assert await service.get_order_status(order.id) == "paid"
async def test_get_order_status_cache_and_db_differ_returns_cached(service, fake_redis):
order = await service.create_order("user1", [make_item()])
await fake_redis.set(f"order:{order.id}", "shipped")
assert await service.get_order_status(order.id) == "shipped"
async def test_create_order_payment_declined_leaves_no_order_rows(service, payment_gateway, db_session):
payment_gateway.charge.return_value = PaymentResult(success=False, reason="card_expired")
with pytest.raises(PaymentDeclinedError):
await service.create_order("user1", [make_item("SKU-1", 3)])
assert db_session.execute(text("SELECT COUNT(*) FROM orders")).scalar_one() == 0
async def test_create_order_payment_declined_restores_stock_level(service, payment_gateway, stock_repository):
payment_gateway.charge.return_value = PaymentResult(success=False, reason="card_expired")
with pytest.raises(PaymentDeclinedError):
await service.create_order("user1", [make_item("SKU-1", 3)])
assert stock_repository.available["SKU-1"] == 10
async def test_create_order_payment_declined_writes_nothing_to_redis(service, payment_gateway, fake_redis):
payment_gateway.charge.return_value = PaymentResult(success=False, reason="card_expired")
with pytest.raises(PaymentDeclinedError):
await service.create_order("user1", [make_item()])
assert await fake_redis.dbsize() == 0
async def test_create_order_insufficient_stock_keeps_stock_level(service, stock_repository):
with pytest.raises(InsufficientStockError):
await service.create_order("user1", [make_item("SKU-1", 11)])
assert stock_repository.available["SKU-1"] == 10
async def test_get_order_status_unknown_order_raises_order_not_found(service):
with pytest.raises(OrderNotFoundError):
await service.get_order_status("missing")
async def test_raw_redis_client_set_with_ttl_kwarg_raises_type_error(fake_redis):
# Контрактный тест (RISK-4): «голый» redis.asyncio не принимает ttl=, нужен адаптер или ex=
with pytest.raises(TypeError):
await fake_redis.set("order:x", "paid", ttl=3600)
tests/performance/test_create_order_perf.py
"""Нагрузочные тесты. Не входят в основной прогон; запуск: pytest -m slow --no-cov
(трассировка coverage замедляет код в разы и искажает замеры)."""
import time
import uuid
import pytest
from tests.helpers import make_item
pytestmark = [pytest.mark.slow, pytest.mark.asyncio]
ORDERS_COUNT = 500
TOTAL_LIMIT_S = 3.0
SINGLE_CALL_LIMIT_S = 0.050
@pytest.fixture
def unique_uuids(mocker, fixed_uuid):
# Зависимость от fixed_uuid гарантирует, что этот патч ляжет поверх него.
# UUID(int=i << 96) даёт hex[:8] == f"{i:08x}": ID уникальны и детерминированы.
return mocker.patch("uuid.uuid4", side_effect=[uuid.UUID(int=i << 96) for i in range(ORDERS_COUNT)])
async def test_create_order_500_sequential_orders_complete_within_3_seconds(service, unique_uuids):
items = [make_item("SKU-1", 1, "9.99")]
start = time.perf_counter()
for i in range(ORDERS_COUNT):
await service.create_order(f"user{i}", items)
elapsed = time.perf_counter() - start
assert elapsed < TOTAL_LIMIT_S, f"{ORDERS_COUNT} заказов заняли {elapsed:.3f} с"
async def test_create_order_single_call_completes_within_50ms(service, unique_uuids):
items = [make_item("SKU-1", 1, "9.99")]
durations = []
for i in range(ORDERS_COUNT):
t0 = time.perf_counter()
await service.create_order(f"user{i}", items)
durations.append(time.perf_counter() - t0)
assert max(durations) < SINGLE_CALL_LIMIT_S, f"max={max(durations) * 1000:.2f} мс"
3. Оценка покрытия
Цифры ниже оценены по анализу кода; их нужно подтвердить первым прогоном.
Точки ветвления order_service.py (coverage.py, --cov-branch):
| Ветвление | Истина покрыта | Ложь покрыта |
|---|---|---|
for item in items (вход в цикл / выход) |
U-CO-01 | U-CO-24 (пустой список), выход после итераций в U-CO-01 |
available < item["qty"] |
U-CO-09 | U-CO-04 |
not payment_result.success |
U-CO-13 | U-CO-01 |
except PaymentDeclinedError |
U-CO-13, U-CO-19 | U-CO-20 (другое исключение проходит мимо) |
percent < 0 or percent > 100 |
U-AD-02 | U-AD-01 |
code and not code.isalnum() |
U-AD-05 | U-AD-04 |
if cached |
U-GS-01 | U-GS-04 |
order is None |
U-GS-06 | U-GS-04 |
Ожидаемый результат:
- строки: около 100% (все исполняемые операторы достижимы и покрыты уже unit-уровнем);
- ветвления: около 100% (14 из 14 дуг).
Это с запасом выше порогов 90% / 85%.
Что coverage.py не измеряет и как это закрыто:
- Покрытие условий (отдельные операнды в
or/and). Каждый операнд покрыт явными параметрами: отрицательные значения и >100;None,"", валидный и невалидный код. - Дефектные пути (DEFECT-1…3). Строки исполняются, но корректность поведения не подтверждена: такие тесты помечены
xfail(strict=True). xfailиXPASS. Код вxfail-тестах всё равно исполняется и учитывается в покрытии. После исправления дефектаXPASSстанет красным благодаряxfail_strict = true, и маркер придётся снять.- Нагрузочные тесты в покрытие не входят (
--no-cov) и на него не влияют.
Раздельный порог для ветвлений. --cov-fail-under проверяет только общий процент. Порог 85% по ветвлениям проверяйте отдельным шагом CI:
python - <<'EOF'
import sys, xml.etree.ElementTree as ET
root = ET.parse("coverage.xml").getroot()
line, branch = float(root.get("line-rate")), float(root.get("branch-rate"))
print(f"lines={line:.1%} branches={branch:.1%}")
sys.exit(0 if line >= 0.90 and branch >= 0.85 else 1)
EOF
4. Рекомендации
4.1. Покрытие и качество тестов
- SonarQube:
sonar.python.coverage.reportPaths=coverage.xml. Благодаряrelative_files = trueпути корректны и внутри Docker-раннеров. - Мутационное тестирование (
mutmut run --paths-to-mutate order_service.py) показывает, действительно ли тесты ловят подмену<на<=или100на99. Это важнее, чем 100% покрытия строк. pytest-randomlyперемешивает порядок тестов и подтверждает их независимость.pytest-xdist -n autoускоряет прогон: фикстуры изолированы, общего состояния нет.
4.2. Property-based тесты (Hypothesis) для apply_discount
from decimal import Decimal
from hypothesis import given, strategies as st
from order_service import OrderService
svc = OrderService(None, None, None, None)
totals = st.decimals(min_value=0, max_value=10**6, places=2, allow_nan=False, allow_infinity=False)
percents = st.floats(min_value=0, max_value=100, allow_nan=False)
@given(total=totals, percent=percents)
def test_apply_discount_any_valid_input_stays_within_zero_and_total(total, percent):
assert Decimal(0) <= svc.apply_discount(total, percent) <= total
@given(total=totals, p1=percents, p2=percents)
def test_apply_discount_higher_percent_never_increases_price(total, p1, p2):
lo, hi = sorted((p1, p2))
assert svc.apply_discount(total, hi) <= svc.apply_discount(total, lo)
Для create_order подойдёт hypothesis.stateful.RuleBasedStateMachine поверх InMemoryStockRepository. Инвариант: сумма остатков плюс проданные товары постоянна при любой последовательности успешных и отклонённых оплат.
4.3. CI (пример GitHub Actions)
jobs:
tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.11" }
- run: pip install -r requirements-test.txt
- run: pytest # addopts: -m "not slow", coverage, fail-under=90
- run: python scripts/check_branch_coverage.py # скрипт из раздела 3
- uses: SonarSource/sonarqube-scan-action@v3
env: { SONAR_TOKEN: "${{ secrets.SONAR_TOKEN }}" }
perf:
if: github.event_name == 'schedule' # nightly, вне основного прогона
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.11" }
- run: pip install -r requirements-test.txt
- run: pytest -m slow --no-cov
Для трендов производительности подойдёт pytest-benchmark с --benchmark-compare-fail=mean:20%. Он надёжнее единичного порога на шумных раннерах.
4.4. Рефакторинг кода для тестируемости и надёжности
- Ловить
Exceptionв блоке оплаты:except Exception:+release+rollback+raise. Это исправляет DEFECT-1. Компенсацию обернуть вtry/finally, чтобыrollbackвыполнялся всегда (DEFECT-2). - Добавить
math.isfinite(discount_percent)в валидацию (DEFECT-3). Квантовать деньги:.quantize(Decimal("0.01"), ROUND_HALF_UP). - Внедрять
id_factoryчерез конструктор вместоuuidвнутри метода. Увеличить длину суффикса ID (сейчас 32 бита). - Логировать через
logger.exception(...)или сreason, чтобы в логе были причина и traceback. - Сделать запись в кэш после
commitbest-effort (try/except + warning) и добавить идемпотентность оплаты (idempotency key в PayFlow). - Перейти на
AsyncSession: синхронные вызовы БД блокируют event loop.
5. Допущения и тесты после уточнения
Допущения
| ID | Допущение |
|---|---|
| A1 | payment_gateway.charge(user_id, amount: Decimal) асинхронный и возвращает объект с .success: bool и .reason: str. |
| A2 | cache_client — обёртка с async get(key) -> str | None и async set(key, value, ttl=int). «Голый» redis.asyncio/fakeredis не принимает ttl= (INT-07) и без decode_responses=True возвращает bytes. В интеграционных тестах используется RedisCacheAdapter. |
| A3 | stock_repository асинхронный: get_available(sku) -> int, reserve(items), release(items). Резерв живёт во внешнем сервисе, поэтому реальная компенсация — это release; db_session.rollback() резерв не откатывает. |
| A4 | Элемент items — это dict с ключами sku, qty: int, price: float | str | Decimal. |
| A5 | В приведённом коде Order не является ORM-моделью. Для SQLite он мапится императивно только в интеграционных тестах; в продакшене маппинг, видимо, задан в другом месте. |
| A6 | Модуль не зависит от времени, поэтому freezegun не нужен. Если появятся created_at или истечение резерва, стоит внедрить clock или использовать freeze_time. |
| A7 | Код скидки проверяется только по формату; сверки со справочником промокодов нет. |
| A8 | Лимиты 3 с и 50 мс измеряются без coverage-трассировки на стандартном CI-раннере. |
Тесты, которые стоит написать после уточнения требований
items=[]: ошибка валидации или допустимый нулевой заказ? Сейчас U-CO-24 фиксирует списание 0.qty <= 0и отрицательная цена: сейчас возможен отрицательныйtotalи списание отрицательной суммы.- Две строки с одним SKU (qty 3 + 3 при остатке 5): нужно ли агрегировать позиции перед проверкой остатка?
- Отсутствующие ключи
sku/qty/price: сейчас будетKeyError. Нужно ли доменное исключение? - Правила округления денег (банковское или half-up) и минимальная итоговая сумма после скидки.
- Нужно ли прогревать кэш при промахе в
get_order_statusи какова политика инвалидации при смене статуса? - Поведение при недоступном Redis: деградация до БД в
get_order_statusи best-effort запись вcreate_order. - Нужно ли переводить заказ в статус
failedи сохранять его после отказа оплаты для аудита, вместо полного отката? - Атомарность
reserve(TOCTOU): контрактный тест на конкурентныеcreate_orderчерезasyncio.gatherс общим фейком склада. - Регистрозависимость кодов скидки и допустимость Unicode-букв и цифр (
"СКИДКА", полноширинные цифры"10"проходятisalnum()).
Похожие промпты
Проектировщик REST API
Проектирует REST API от ресурсной модели до спецификации: маршруты, статус-коды, пагинация, версионирование и формат ошибок — с примерами запросов и ответов.
Ревью Next.js-кода: App Router и Server Components
Проводит ревью Next.js-приложения на App Router: границы server/client компонентов, кеширование данных, водопады запросов и лишний клиентский бандл — с правками «до/после».
SEO-аудит Next.js-приложения
Проверяет Next.js-приложение по SEO-чеклисту: метаданные и canonical, рендеринг для ботов, sitemap/robots, Core Web Vitals — и выдаёт план правок по приоритету.
Ревью backend-кода: архитектура и безопасность
Проводит ревью серверного кода: слои и границы модулей, работа с БД и транзакциями, обработка ошибок, инъекции и утечки данных — замечания ранжированы, правки «до/после».