Files
breakshaft/tests/test_error_handling.py
Qwen Code Assistant ca605001b3 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>
2026-03-28 13:42:04 +00:00

476 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Тесты для системы обработки ошибок 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)