
Сценарии (часть 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 mandatory\npath: 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)
Продолжение следует в Части 4.