feat: масштабное улучшение системы обработки ошибок и тестирования
Основные изменения: - Добавлена иерархия исключений (17 классов) с кодами ошибок и контекстом - Улучшена обработка ошибок: детальные сообщения с подсказками - Добавлено 24 теста для экстремальных случаев (комбинаторика, циклы, async) - Добавлено 23 теста для системы обработки ошибок - Исправлен баг с optional-аргументами в renderer.py - Обновлены импорты в тестах (src.breakshaft → breakshaft) Документация: - ERROR_DESIGN.md — проектирование системы ошибок - COMMUTATIVITY_DESIGN.md — анализ проблемы некоммутативности (10 вариантов решений) Файлы: - src/breakshaft/exceptions.py (новый) — модуль исключений - tests/test_error_handling.py (новый) — тесты ошибок - tests/test_extreme_cases.py (новый) — экстремальные кейсы Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
476
tests/test_error_handling.py
Normal file
476
tests/test_error_handling.py
Normal file
@@ -0,0 +1,476 @@
|
||||
"""
|
||||
Тесты для системы обработки ошибок breakshaft.
|
||||
|
||||
Покрывают все категории ошибок:
|
||||
- INJECTOR_*: Ошибки регистрации инжекторов
|
||||
- GRAPH_*: Ошибки построения графа
|
||||
- CODEGEN_*: Ошибки генерации кода
|
||||
- RUNTIME_*: Ошибки выполнения
|
||||
- CONFIG_*: Ошибки конфигурации
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Generator
|
||||
from contextlib import contextmanager
|
||||
|
||||
import pytest
|
||||
|
||||
from breakshaft import ConvRepo
|
||||
from breakshaft.exceptions import (
|
||||
BreakshaftError,
|
||||
MissingReturnType,
|
||||
MissingParamType,
|
||||
NoConversionPath,
|
||||
AmbiguousPath,
|
||||
InvalidOptions,
|
||||
MissingDependency,
|
||||
InvalidGeneratedCode,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Базовые типы для тестов
|
||||
# =============================================================================
|
||||
|
||||
@dataclass
|
||||
class A:
|
||||
a: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class B:
|
||||
b: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class C:
|
||||
c: str
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# INJECTOR_*: Ошибки регистрации инжекторов
|
||||
# =============================================================================
|
||||
|
||||
class TestInjectorErrors:
|
||||
"""Тесты ошибок регистрации инжекторов."""
|
||||
|
||||
def test_missing_return_type(self):
|
||||
"""INJECTOR_001: У инжектора не указан тип возврата."""
|
||||
repo = ConvRepo()
|
||||
|
||||
with pytest.raises(MissingReturnType) as exc_info:
|
||||
@repo.mark_injector()
|
||||
def int_to_a(i: int): # Нет -> A
|
||||
return A(i)
|
||||
|
||||
assert exc_info.value.code == "INJECTOR_001"
|
||||
assert "int_to_a" in str(exc_info.value)
|
||||
assert "return type" in str(exc_info.value).lower()
|
||||
|
||||
def test_missing_param_type(self):
|
||||
"""INJECTOR_002: У параметра инжектора не указан тип."""
|
||||
repo = ConvRepo()
|
||||
|
||||
with pytest.raises(MissingParamType) as exc_info:
|
||||
@repo.mark_injector()
|
||||
def convert(value) -> A: # Нет типа у параметра
|
||||
return A(42)
|
||||
|
||||
assert exc_info.value.code == "INJECTOR_002"
|
||||
assert "value" in str(exc_info.value)
|
||||
assert "convert" in str(exc_info.value)
|
||||
|
||||
def test_missing_param_type_second_param(self):
|
||||
"""INJECTOR_002: У второго параметра не указан тип."""
|
||||
repo = ConvRepo()
|
||||
|
||||
with pytest.raises(MissingParamType) as exc_info:
|
||||
@repo.mark_injector()
|
||||
def convert(i: int, value) -> A: # Нет типа у value
|
||||
return A(i)
|
||||
|
||||
assert exc_info.value.code == "INJECTOR_002"
|
||||
assert "value" in str(exc_info.value)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# GRAPH_*: Ошибки построения графа
|
||||
# =============================================================================
|
||||
|
||||
class TestGraphErrors:
|
||||
"""Тесты ошибок построения графа преобразований."""
|
||||
|
||||
def test_no_conversion_path(self):
|
||||
"""GRAPH_001: Невозможно построить путь преобразования."""
|
||||
repo = ConvRepo()
|
||||
|
||||
@repo.mark_injector()
|
||||
def int_to_a(i: int) -> A:
|
||||
return A(i)
|
||||
|
||||
def consumer(dep: B) -> str: # B нельзя получить из int
|
||||
return str(dep.b)
|
||||
|
||||
with pytest.raises(NoConversionPath) as exc_info:
|
||||
repo.get_conversion((int,), consumer)
|
||||
|
||||
assert exc_info.value.code == "GRAPH_001"
|
||||
assert "int" in str(exc_info.value)
|
||||
assert "B" in str(exc_info.value)
|
||||
# Проверяем что есть контекст с missing_types
|
||||
assert "missing_types" in exc_info.value.context
|
||||
|
||||
def test_no_conversion_path_shows_missing_types(self):
|
||||
"""GRAPH_001: Ошибка показывает недостающие типы."""
|
||||
repo = ConvRepo()
|
||||
|
||||
@repo.mark_injector()
|
||||
def int_to_a(i: int) -> A:
|
||||
return A(i)
|
||||
|
||||
@repo.mark_injector()
|
||||
def a_to_c(a: A) -> C:
|
||||
return C(str(a.a))
|
||||
|
||||
def consumer(dep: B) -> str: # B отсутствует
|
||||
return str(dep.b)
|
||||
|
||||
with pytest.raises(NoConversionPath) as exc_info:
|
||||
repo.get_conversion((int,), consumer)
|
||||
|
||||
# Проверяем что B в missing_types
|
||||
missing = exc_info.value.context.get("missing_types", set())
|
||||
assert B in missing
|
||||
|
||||
def test_ambiguous_path_with_multiple_consumers_in_pipeline(self):
|
||||
"""
|
||||
GRAPH_002: Найдено несколько путей в конвейере.
|
||||
|
||||
В get_conversion библиотека автоматически выбирает путь,
|
||||
поэтому AmbiguousPath возникает только в специфичных случаях.
|
||||
"""
|
||||
repo = ConvRepo()
|
||||
|
||||
@repo.mark_injector()
|
||||
def int_to_a_v1(i: int) -> A:
|
||||
return A(i * 10)
|
||||
|
||||
@repo.mark_injector()
|
||||
def int_to_a_v2(i: int) -> A:
|
||||
return A(i + 100)
|
||||
|
||||
def consumer1(dep: A) -> B:
|
||||
return B(float(dep.a))
|
||||
|
||||
def consumer2(dep: B) -> C:
|
||||
return C(str(dep.b))
|
||||
|
||||
# В конвейере с несколькими consumer может возникнуть неоднозначность
|
||||
# Проверяем что библиотека вообще работает с множественными путями
|
||||
fn = repo.get_conversion((int,), consumer1, force_commutative=False)
|
||||
result = fn(42)
|
||||
# Один из путей будет выбран
|
||||
assert isinstance(result, B)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CONFIG_*: Ошибки конфигурации
|
||||
# =============================================================================
|
||||
|
||||
class TestConfigurationErrors:
|
||||
"""Тесты ошибок конфигурации."""
|
||||
|
||||
def test_invalid_options_force_async_without_allow_async(self):
|
||||
"""CONFIG_001: force_async=True без allow_async=True."""
|
||||
repo = ConvRepo()
|
||||
|
||||
@repo.mark_injector()
|
||||
def int_to_a(i: int) -> A:
|
||||
return A(i)
|
||||
|
||||
def consumer(dep: A) -> int:
|
||||
return dep.a
|
||||
|
||||
with pytest.raises(InvalidOptions) as exc_info:
|
||||
repo.get_conversion(
|
||||
(int,),
|
||||
consumer,
|
||||
force_async=True,
|
||||
allow_async=False # Конфликт!
|
||||
)
|
||||
|
||||
assert exc_info.value.code == "CONFIG_001"
|
||||
assert "force_async" in str(exc_info.value)
|
||||
assert "allow_async" in str(exc_info.value)
|
||||
|
||||
def test_invalid_options_message_is_helpful(self):
|
||||
"""CONFIG_001: Сообщение об ошибке содержит подсказку."""
|
||||
repo = ConvRepo()
|
||||
|
||||
@repo.mark_injector()
|
||||
def int_to_a(i: int) -> A:
|
||||
return A(i)
|
||||
|
||||
def consumer(dep: A) -> int:
|
||||
return dep.a
|
||||
|
||||
with pytest.raises(InvalidOptions) as exc_info:
|
||||
repo.get_conversion((int,), consumer, force_async=True, allow_async=False)
|
||||
|
||||
assert "hint" in str(exc_info.value).lower() or "requires" in str(exc_info.value).lower()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# RUNTIME_*: Ошибки выполнения
|
||||
# =============================================================================
|
||||
|
||||
class TestRuntimeErrors:
|
||||
"""Тесты ошибок выполнения."""
|
||||
|
||||
def test_injector_call_failed_propagates_original_error(self):
|
||||
"""
|
||||
RUNTIME_001: Ошибка при вызове инжектора пробрасывается.
|
||||
|
||||
Примечание: В текущей реализации ошибки инжекторов пробрасываются
|
||||
как есть. Для перехвата и обёртывания в InjectorCallFailed нужно
|
||||
изменить шаблон генерации кода.
|
||||
"""
|
||||
repo = ConvRepo()
|
||||
|
||||
@repo.mark_injector()
|
||||
def int_to_a(i: int) -> A:
|
||||
return A(i / 0) # ZeroDivisionError
|
||||
|
||||
def consumer(dep: A) -> int:
|
||||
return dep.a
|
||||
|
||||
fn = repo.get_conversion((int,), consumer)
|
||||
|
||||
# Ошибка пробрасывается как есть
|
||||
with pytest.raises(ZeroDivisionError):
|
||||
fn(42)
|
||||
|
||||
def test_context_manager_error_propagates_original_error(self):
|
||||
"""
|
||||
RUNTIME_002: Ошибка контекст-менеджера пробрасывается.
|
||||
|
||||
Примечание: В текущей реализации ошибки контекст-менеджеров
|
||||
пробрасываются как есть.
|
||||
"""
|
||||
repo = ConvRepo()
|
||||
|
||||
@repo.mark_injector()
|
||||
@contextmanager
|
||||
def failing_ctx(i: int) -> Generator[A, None, None]:
|
||||
raise ConnectionError("Failed to connect")
|
||||
yield A(i)
|
||||
|
||||
def consumer(dep: A) -> int:
|
||||
return dep.a
|
||||
|
||||
fn = repo.get_conversion((int,), consumer)
|
||||
|
||||
# Ошибка пробрасывается как есть
|
||||
with pytest.raises(ConnectionError, match="Failed to connect"):
|
||||
fn(42)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CODEGEN_*: Ошибки генерации кода
|
||||
# =============================================================================
|
||||
|
||||
class TestCodegenErrors:
|
||||
"""Тесты ошибок генерации кода."""
|
||||
|
||||
def test_invalid_generated_code(self):
|
||||
"""CODEGEN_002: Сгенерированный код некорректен."""
|
||||
# Этот тест сложно спровоцировать в нормальных условиях,
|
||||
# т.к. шаблон всегда генерирует валидный код.
|
||||
# Проверяем что исключение вообще существует и работает.
|
||||
from breakshaft.exceptions import InvalidGeneratedCode
|
||||
|
||||
exc = InvalidGeneratedCode(
|
||||
source_code="def foo():\n return invalid_syntax_here @@@@",
|
||||
original_error="invalid syntax"
|
||||
)
|
||||
assert exc.code == "CODEGEN_002"
|
||||
assert "invalid" in str(exc).lower()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Тесты общих исключений
|
||||
# =============================================================================
|
||||
|
||||
class TestBreakshaftError:
|
||||
"""Тесты базового исключения BreakshaftError."""
|
||||
|
||||
def test_breakshaft_error_has_code(self):
|
||||
"""BreakshaftError содержит код ошибки."""
|
||||
exc = BreakshaftError(
|
||||
code="TEST_001",
|
||||
message="Test error"
|
||||
)
|
||||
assert exc.code == "TEST_001"
|
||||
assert "TEST_001" in str(exc)
|
||||
|
||||
def test_breakshaft_error_has_context(self):
|
||||
"""BreakshaftError содержит контекст."""
|
||||
exc = BreakshaftError(
|
||||
code="TEST_002",
|
||||
message="Test with context",
|
||||
context={"key": "value", "types": {A, B}}
|
||||
)
|
||||
assert exc.context["key"] == "value"
|
||||
assert A in exc.context["types"]
|
||||
|
||||
def test_breakshaft_error_has_hint(self):
|
||||
"""BreakshaftError содержит подсказку."""
|
||||
exc = BreakshaftError(
|
||||
code="TEST_003",
|
||||
message="Test with hint",
|
||||
hint="Try doing X instead"
|
||||
)
|
||||
assert "hint" in str(exc).lower() or "Try" in str(exc)
|
||||
|
||||
def test_breakshaft_error_formats_types_nicely(self):
|
||||
"""BreakshaftError красиво форматирует типы."""
|
||||
exc = BreakshaftError(
|
||||
code="TEST_004",
|
||||
message="Type error",
|
||||
context={"types": {A, B, C}}
|
||||
)
|
||||
msg = str(exc)
|
||||
# Проверяем что имена типов присутствуют
|
||||
assert "A" in msg or "B" in msg or "C" in msg
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Тесты MissingDependency
|
||||
# =============================================================================
|
||||
|
||||
class TestMissingDependency:
|
||||
"""Тесты ошибки MissingDependency."""
|
||||
|
||||
def test_missing_dependency_empty_repo(self):
|
||||
"""GRAPH_001: Пустой репозиторий."""
|
||||
repo = ConvRepo()
|
||||
|
||||
def consumer(dep: A) -> int:
|
||||
return dep.a
|
||||
|
||||
with pytest.raises(NoConversionPath) as exc_info:
|
||||
repo.get_conversion((int,), consumer)
|
||||
|
||||
assert exc_info.value.code == "GRAPH_001"
|
||||
|
||||
def test_missing_dependency_shows_available_types(self):
|
||||
"""GRAPH_005: Ошибка показывает доступные типы."""
|
||||
repo = ConvRepo()
|
||||
|
||||
@repo.mark_injector()
|
||||
def int_to_a(i: int) -> A:
|
||||
return A(i)
|
||||
|
||||
def consumer(dep: B) -> float: # B недоступен
|
||||
return dep.b
|
||||
|
||||
with pytest.raises(NoConversionPath) as exc_info:
|
||||
repo.get_conversion((int,), consumer)
|
||||
|
||||
# Проверяем что available_types содержит A
|
||||
available = exc_info.value.context.get("available_types", set())
|
||||
assert A in available
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Тесты интеграции с существующим кодом
|
||||
# =============================================================================
|
||||
|
||||
class TestIntegrationWithExistingCode:
|
||||
"""Тесты что новые исключения работают со старым кодом."""
|
||||
|
||||
def test_existing_tests_still_work(self):
|
||||
"""Существующие тесты продолжают работать."""
|
||||
repo = ConvRepo()
|
||||
|
||||
@repo.mark_injector()
|
||||
def int_to_a(i: int) -> A:
|
||||
return A(i)
|
||||
|
||||
@repo.mark_injector()
|
||||
def a_to_b(a: A) -> B:
|
||||
return B(float(a.a))
|
||||
|
||||
def consumer(dep: B) -> float:
|
||||
return dep.b
|
||||
|
||||
fn = repo.get_conversion((int,), consumer)
|
||||
result = fn(42)
|
||||
assert result == 42.0
|
||||
|
||||
def test_exceptions_are_catchable_as_breakshaft_error(self):
|
||||
"""Все исключения можно поймать как BreakshaftError."""
|
||||
repo = ConvRepo()
|
||||
|
||||
with pytest.raises(BreakshaftError):
|
||||
@repo.mark_injector()
|
||||
def no_return(i: int):
|
||||
return A(i)
|
||||
|
||||
def test_exceptions_are_catchable_as_base_exception(self):
|
||||
"""Исключения наследуются от Exception."""
|
||||
repo = ConvRepo()
|
||||
|
||||
with pytest.raises(Exception):
|
||||
@repo.mark_injector()
|
||||
def no_return(i: int):
|
||||
return A(i)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Тесты edge cases
|
||||
# =============================================================================
|
||||
|
||||
class TestEdgeCases:
|
||||
"""Тесты граничных случаев."""
|
||||
|
||||
def test_no_conversion_path_with_union_types(self):
|
||||
"""GRAPH_001: Union-типы в ошибке."""
|
||||
repo = ConvRepo()
|
||||
|
||||
@repo.mark_injector()
|
||||
def int_to_a(i: int) -> A:
|
||||
return A(i)
|
||||
|
||||
def consumer(dep: B | C) -> str: # B и C недоступны
|
||||
return "test"
|
||||
|
||||
with pytest.raises(NoConversionPath) as exc_info:
|
||||
repo.get_conversion((int,), consumer)
|
||||
|
||||
assert exc_info.value.code == "GRAPH_001"
|
||||
|
||||
def test_missing_param_type_with_default(self):
|
||||
"""INJECTOR_002: Параметр с default но без типа."""
|
||||
repo = ConvRepo()
|
||||
|
||||
with pytest.raises(MissingParamType) as exc_info:
|
||||
@repo.mark_injector()
|
||||
def func(a = 42) -> A: # Нет типа у a
|
||||
return A(42)
|
||||
|
||||
assert exc_info.value.code == "INJECTOR_002"
|
||||
|
||||
def test_error_message_contains_function_name(self):
|
||||
"""Сообщение об ошибке содержит имя функции."""
|
||||
repo = ConvRepo()
|
||||
|
||||
# Функция без return type
|
||||
def my_special_function(x: int): # Нет return type
|
||||
return A(x)
|
||||
|
||||
with pytest.raises(MissingReturnType) as exc_info:
|
||||
repo.mark_injector()(my_special_function)
|
||||
|
||||
# Проверяем что имя функции присутствует
|
||||
assert "my_special_function" in str(exc_info.value)
|
||||
Reference in New Issue
Block a user