Сценарии (часть 2)
Git tag: Step06
Теперь, когда мы реализовали объекты запроса и ответа, добавляем их. Помещаем в файл tests/use_cases/test_storageroom_list_use_case.py
следующий код:
import pytest
from unittest import mock
from rentomatic.domain.storageroom import StorageRoom
from rentomatic.use_cases import request_objects as ro
from rentomatic.use_cases import storageroom_use_cases as uc
@pytest.fixture
def domain_storagerooms():
storageroom_1 = StorageRoom(
code='f853578c-fc0f-4e65-81b8-566c5dffa35a',
size=215,
price=39,
longitude='-0.09998975',
latitude='51.75436293',
)
storageroom_2 = StorageRoom(
code='fe2c3195-aeff-487a-a08f-e0bdc0ec6e9a',
size=405,
price=66,
longitude='0.18228006',
latitude='51.74640997',
)
storageroom_3 = StorageRoom(
code='913694c6-435a-4366-ba0d-da5334a611b2',
size=56,
price=60,
longitude='0.27891577',
latitude='51.45994069',
)
storageroom_4 = StorageRoom(
code='eed76e77-55c1-41ce-985d-ca49bf6c0585',
size=93,
price=48,
longitude='0.33894476',
latitude='51.39916678',
)
return [storageroom_1, storageroom_2, storageroom_3, storageroom_4]
def test_storageroom_list_without_parameters(domain_storagerooms):
repo = mock.Mock()
repo.list.return_value = domain_storagerooms
storageroom_list_use_case = uc.StorageRoomListUseCase(repo)
request_object = ro.StorageRoomListRequestObject.from_dict({})
response_object = storageroom_list_use_case.execute(request_object)
assert bool(response_object) is True
repo.list.assert_called_with()
assert response_object.value == domain_storagerooms
Новая версия файла rentomatic/use_case/storageroom_use_cases.py
теперь выглядит следующим образом:
from rentomatic.shared import response_object as ro
class StorageRoomListUseCase(object):
def __init__(self, repo):
self.repo = repo
def execute(self, request_object):
storage_rooms = self.repo.list()
return ro.ResponseSuccess (storage_rooms)
Давайте рассмотрим, чего же мы добились с этой чистой архитектурой. У нас есть очень легкая модель, сериализуемая в JSON и полностью независимая от других частей системы. Так же в коде содержится сценарий, в котором хранилище, предоставленное данным API, извлекает все модели и возвращает их внутри структурированного объекта.
Правда, мы упустили некоторые объекты. Например, мы не реализовали какой-либо отрицательный ответ или валидированный входящий объект запроса.
Для того, чтобы можно было рассмотреть эти упущенные части архитектуры, давайте изменим текущий сценарий, чтобы он принимал параметр filters
, представляющий некоторые фильтры, применяемые к извлечению списка моделей. При передачи этого параметра могут возникать ошибки, поэтому нам придётся реализовать проверку для входящего объекта запроса.
Запросы и валидация
Git tag: Step07
Я хочу добавить параметр filters
для запроса. Благодаря этому параметру вызывающая сторона может добавлять различные фильтры, указав имя и значение для каждого (например, { 'price_lt': 100}
для получения всех результатов с ценой меньше, чем 100).
Первое, где мы начинаем наши правки, это тест. Новая версия файла tests/use_cases/test_storageroom_list_request_objects.py
выглядит так:
import pytest
from rentomatic.use_cases import request_objects as ro
def test_valid_request_object_cannot_be_used():
with pytest.raises(NotImplementedError):
ro.ValidRequestObject.from_dict({})
def test_build_storageroom_list_request_object_without_parameters():
req = ro.StorageRoomListRequestObject()
assert req.filters is None
assert bool(req) is True
def test_build_file_list_request_object_from_empty_dict():
req = ro.StorageRoomListRequestObject.from_dict({})
assert req.filters is None
assert bool(req) is True
def test_build_storageroom_list_request_object_with_empty_filters():
req = ro.StorageRoomListRequestObject(filters={})
assert req.filters == {}
assert bool(req) is True
def test_build_storageroom_list_request_object_from_dict_with_empty_filters():
req = ro.StorageRoomListRequestObject.from_dict({'filters': {}})
assert req.filters == {}
assert bool(req) is True
def test_build_storageroom_list_request_object_with_filters():
req = ro.StorageRoomListRequestObject(filters={'a': 1, 'b': 2})
assert req.filters == {'a': 1, 'b': 2}
assert bool(req) is True
def test_build_storageroom_list_request_object_from_dict_with_filters():
req = ro.StorageRoomListRequestObject.from_dict({'filters': {'a': 1, 'b': 2}})
assert req.filters == {'a': 1, 'b': 2}
assert bool(req) is True
def test_build_storageroom_list_request_object_from_dict_with_invalid_filters():
req = ro.StorageRoomListRequestObject.from_dict({'filters': 5})
assert req.has_errors()
assert req.errors[0]['parameter'] == 'filters'
assert bool(req) is False
Проверяем assert req.filters is None
для первоначальных двух тестов, а затем я добавил ещё 5 тестов для проверки, могут ли фильтры быть уточнены, и проверить поведение объекта с недопустимым параметром фильтра.
Для того, чтобы тесты прошли, необходимо изменить наш класс StorageRoomListRequestObject
. Естественно, имеется множество возможных решений, которые вы можете придумать, и я рекомендую вам попробовать найти свой собственный. Здесь описано то решение, которое я обычно используют сам. Файл rentomatic/use_cases/request_object.py
теперь выглядит как
import collections
class InvalidRequestObject(object):
def __init__(self):
self.errors = []
def add_error(self, parameter, message):
self.errors.append({'parameter': parameter, 'message': message})
def has_errors(self):
return len(self.errors) > 0
def __nonzero__(self):
return False
__bool__ = __nonzero__
class ValidRequestObject(object):
@classmethod
def from_dict(cls, adict):
raise NotImplementedError
def __nonzero__(self):
return True
__bool__ = __nonzero__
class StorageRoomListRequestObject(ValidRequestObject):
def __init__(self, filters=None):
self.filters = filters
@classmethod
def from_dict(cls, adict):
invalid_req = InvalidRequestObject()
if 'filters' in adict and not isinstance(adict['filters'], collections.Mapping):
invalid_req.add_error('filters', 'Is not iterable')
if invalid_req.has_errors():
return invalid_req
return StorageRoomListRequestObject(filters=adict.get('filters', None))
Давайте я поясню этот новый код.
Во-первых, были введены два вспомогательные объекты, ValidRequestObject
и InvalidRequestObject
. Они отличаются друг от друга потому, что неправильный запрос должен содержать ошибки валидации, но при этом они оба должны преобразовываться к булеву значению.
Во-вторых, StorageRoomListRequestObject
принимает опциональный параметр filters
в момент создания. В методе __init __ ()
, нет никаких проверок на валидность, так, как он считается внутренним методом, который вызывается уже после того, как параметры уже были подтверждены.
В итоге, метод from_dict()
проверяет наличие параметра filters
. Я использую абстрактный класс collections.Mapping
для проверки, что входящие параметры являются словарями, и что возвращается экземпляр объектов InvalidRequestObject
или ValidRequestObject
.
Раз уж мы теперь можем сообщить о наличии плохих или хороших запросов, нам необходимо ввести новый тип ответа для управления плохими ответами или ошибками в сценарии.
Ответы и провалы
Git tag: Step08
Что произойдет, если в сценарии возникает ошибка? Сценарии могут столкнуться с большим количеством ошибок: не только ошибки валидации, о которых мыговорили в предыдущем разделе, но и бизнес ошибки или ошибки из слоя хранилища. Какой бы ни была ошибка, сценарий должен всегда возвращать объект с известной структурой (ответ), поэтому нам нужен новый объект, который обеспечивает хорошую поддержку для различных типов провалов.
Как и с запросами, нет единственного верного способа представления такого объекта, и следующий код является лишь одним из возможных решений.
Первое, что нужно сделать, это расширить файл tests/shared/test_response_object.py
, добавив, тесты для провальных случаев.
import pytest
from rentomatic.shared import response_object as res
from rentomatic.use_cases import request_objects as req
@pytest.fixture
def response_value():
return {'key': ['value1', 'value2']}
@pytest.fixture
def response_type():
return 'ResponseError'
@pytest.fixture
def response_message():
return 'This is a response error'
Это шаблонный код, основанный на фикстурах pytest
, которые мы будем использовать в следующих тестах.
def test_response_success_is_true(response_value):
assert bool(res.ResponseSuccess(response_value)) is True
def test_response_failure_is_false(response_type, response_message):
assert bool(res.ResponseFailure(response_type, response_message)) is False
Два базовых теста для проверки того, что прежний ResponseSuccess
и новый ResponseFailure
ведут себя согласовано при преобразовании в булево значение.
def test_response_success_contains_value(response_value):
response = res.ResponseSuccess(response_value)
assert response.value == response_value
Объект ResponseSuccess
содержит результат вызова в атрибуте value
.
def test_response_failure_has_type_and_message(response_type, response_message):
response = res.ResponseFailure(response_type, response_message)
assert response.type == response_type
assert response.message == response_message
def test_response_failure_contains_value(response_type, response_message):
response = res.ResponseFailure(response_type, response_message)
assert response.value == {'type': response_type, 'message': response_message}
Эти два теста гарантируют, что объект ResponseFailure
обеспечивает тот же интерфейс, что и при успехе, и что у этого объекта имеются параметры type
и message
.
def test_response_failure_initialization_with_exception():
response = res.ResponseFailure(response_type, Exception('Just an error message'))
assert bool(response) is False
assert response.type == response_type
assert response.message == "Exception: Just an error message"
def test_response_failure_from_invalid_request_object():
response = res.ResponseFailure.build_from_invalid_request_object(req.InvalidRequestObject())
assert bool(response) is False
def test_response_failure_from_invalid_request_object_with_errors():
request_object = req.InvalidRequestObject()
request_object.add_error('path', 'Is mandatory')
request_object.add_error('path', "can't be blank")
response = res.ResponseFailure.build_from_invalid_request_object(request_object)
assert bool(response) is False
assert response.type == res.ResponseFailure.PARAMETERS_ERROR
assert response.message == "path: Is mandatorynpath: can't be blank"
Иногда необходимо создать ответы от Python-исключений, которые могут произойти в сценариях, поэтому мы проверяем, что объекты ResponseFailure
можно инициализировать с исключением.
И последнее, у нас есть тесты для метода build_from_invalid_request_object()
, автоматизирующие инициализацию ответа от недопустимого запроса. Если запрос содержит ошибки (помним, запрос проверяет себя), мы должны передать их в ответном сообщении.
Последний тест использует атрибут класса для классификации ошибки. Класс ResponseFailure
будет содержать три предопределенных ошибки, которые могут произойти при выполнении сценария: RESOURCE_ERROR
, PARAMETERS_ERROR
и SYSTEM_ERROR
. Подобным разделением мы пытаемся охватить различные виды ошибок, которые могут произойти при работе с внешней системой через API. RESOURCE_ERROR
содержит ошибки, связанные с ресурсами, содержащимися в хранилище, например, когда вы не можете найти запись по её уникальному идентификатору. PARAMETERS_ERROR
описывает ошибки, возникающие при неправильных или пропущенных параметрах запроса. SYSTEM_ERROR
охватывает ошибки, происходящие в базовой системе на уровне операционной системы, такие как сбой в работе файловой системы или ошибка подключения к сети во время выборки данных из базы данных.
Сценарий ответственен за взаимодействие с различными ошибками, возникающими в Python-коде, и преобразует их один из трёх только что описанных типов сообщения, имеющего описание данной ошибки.
Давайте напишем класс ResponseFailure
, который позволяет тестам успешно выполняться. Создадим его в rentomatic/shared/response_object.py
class ResponseFailure(object):
RESOURCE_ERROR = 'RESOURCE_ERROR'
PARAMETERS_ERROR = 'PARAMETERS_ERROR'
SYSTEM_ERROR = 'SYSTEM_ERROR'
def __init__(self, type_, message):
self.type = type_
self.message = self._format_message(message)
def _format_message(self, msg):
if isinstance(msg, Exception):
return "{}: {}".format(msg.__class__.__name__, "{}".format(msg))
return msg
С помощью метода _format_message()
мы позволяем классу принимать как строковое сообщение, так и Python-исключение, что очень удобно при работе с внешними библиотеками, которые могут вызывать неизвестные или не интересующие нас исключения.
@property
def value(self):
return {'type': self.type, 'message': self.message}
Это свойство делает класс согласованным с API ResponseSuccess
, предоставляя атрибут value
, являющийся словарём.
def __bool__(self):
return False
@classmethod
def build_from_invalid_request_object(cls, invalid_request_object):
message = "n".join(["{}: {}".format(err['parameter'], err['message'])
for err in invalid_request_object.errors])
return cls(cls.PARAMETERS_ERROR, message)
Как было объяснено выше, тип PARAMETERS_ERROR
охватывает все те ошибки, которые происходят при неверном наборе передаваемых параметров, то есть, некоторые параметры содержат ошибки или пропущены.
Поскольку нам часто придётся создавать ответы с провалом, полезно иметь вспомогательные методы. Добавляю три теста для функций-построителей в файле tests/shared/test_response_object.py
def test_response_failure_build_resource_error():
response = res.ResponseFailure.build_resource_error("test message")
assert bool(response) is False
assert response.type == res.ResponseFailure.RESOURCE_ERROR
assert response.message == "test message"
def test_response_failure_build_parameters_error():
response = res.ResponseFailure.build_parameters_error("test message")
assert bool(response) is False
assert response.type == res.ResponseFailure.PARAMETERS_ERROR
assert response.message == "test message"
def test_response_failure_build_system_error():
response = res.ResponseFailure.build_system_error("test message")
assert bool(response) is False
assert response.type == res.ResponseFailure.SYSTEM_ERROR
assert response.message == "test message"
Мы добавили соответствующие методы в классе и добавили использование нового метода build_parameters_error()
в методе build_from_invalid_request_object()
. В файле rentomatic/shared/response_object.py
теперь должен находиться такой код
@classmethod
def build_resource_error(cls, message=None):
return cls(cls.RESOURCE_ERROR, message)
@classmethod
def build_system_error(cls, message=None):
return cls(cls.SYSTEM_ERROR, message)
@classmethod
def build_parameters_error(cls, message=None):
return cls(cls.PARAMETERS_ERROR, message)
@classmethod
def build_from_invalid_request_object(cls, invalid_request_object):
message = "n".join(["{}: {}".format(err['parameter'], err['message'])
for err in invalid_request_object.errors])
return cls.build_parameters_error(message)
Продолжение следует...
Автор: CJay