diff --git a/tests/plugins/mockserver/test_invalid_request.py b/tests/plugins/mockserver/test_invalid_request.py index aae10ef2..1d3d4c68 100644 --- a/tests/plugins/mockserver/test_invalid_request.py +++ b/tests/plugins/mockserver/test_invalid_request.py @@ -20,5 +20,5 @@ def _foo_handler(request): ) assert response.status_code == 500 # pylint: disable=protected-access - error = mockserver._session._errors.pop() + error, _report_msg = mockserver._session.reporter._errors.pop() assert isinstance(error, http.InvalidRequestError) diff --git a/tests/plugins/mockserver/test_mockserver.py b/tests/plugins/mockserver/test_mockserver.py index 0b790035..47689e30 100644 --- a/tests/plugins/mockserver/test_mockserver.py +++ b/tests/plugins/mockserver/test_mockserver.py @@ -2,14 +2,9 @@ import aiohttp import pytest -from testsuite.mockserver import exceptions from testsuite._internal import fixture_types -class UserError(Exception): - pass - - class Client: def __init__(self, *, base_url, session): self._session = session @@ -76,38 +71,3 @@ def _foo_handler(request): data = await response.content.read() assert data == b'hello' - - -async def test_user_error( - mockserver: fixture_types.MockserverFixture, - mockserver_client: Client, -): - @mockserver.json_handler('/foo') - def _foo_handler(request): - raise UserError - - response = await mockserver_client.get('/foo') - assert response.status == 500 - - session = mockserver._session - assert len(session._errors) == 1 - - error = session._errors.pop() - assert isinstance(error, UserError) - - -async def test_nohandler( - mockserver: fixture_types.MockserverFixture, - mockserver_client: Client, -): - response = await mockserver_client.get( - '/foo123', - headers={mockserver.trace_id_header: mockserver.trace_id}, - ) - assert response.status == 500 - - session = mockserver._session - assert len(session._errors) == 1 - - error = session._errors.pop() - assert isinstance(error, exceptions.HandlerNotFoundError) diff --git a/tests/plugins/mockserver/test_tracing_disabled.py b/tests/plugins/mockserver/test_tracing_disabled.py index 0bbd220c..9d8e3402 100644 --- a/tests/plugins/mockserver/test_tracing_disabled.py +++ b/tests/plugins/mockserver/test_tracing_disabled.py @@ -3,6 +3,7 @@ import pytest from testsuite.mockserver import exceptions +from testsuite.mockserver import reporter_plugin from testsuite.mockserver import server @@ -42,17 +43,19 @@ async def test_mockserver_raises_on_unhandled_request_from_other_sources( http_headers, mockserver_info, ): + reporter = reporter_plugin.MockserverReporterPlugin(colors_enabled=False) mockserver = server.Server( mockserver_info, tracing_enabled=False, + reporter=reporter, ) - with mockserver.new_session() as session: + with mockserver.new_session(): request = aiohttp.test_utils.make_mocked_request( 'POST', '/arbitrary/path', headers=http_headers, ) await mockserver._handle_request(request) - assert len(session._errors) == 1 - error = session._errors.pop() + assert len(reporter._errors) == 1 + error, _report_msg = reporter._errors[0] assert isinstance(error, exceptions.HandlerNotFoundError) diff --git a/tests/plugins/mockserver/test_tracing_enabled.py b/tests/plugins/mockserver/test_tracing_enabled.py index 707889ed..4d6c8be0 100644 --- a/tests/plugins/mockserver/test_tracing_enabled.py +++ b/tests/plugins/mockserver/test_tracing_enabled.py @@ -1,7 +1,6 @@ import aiohttp.web import pytest -from testsuite.mockserver import exceptions from testsuite.mockserver import server # pylint: disable=protected-access @@ -96,6 +95,3 @@ async def test_mockserver_responds_500_on_unhandled_request_from_other_sources( client = create_service_client(mockserver.base_url, headers=http_headers) response = await client.post('arbitrary/path') assert response.status_code == 500 - - session = mockserver._session - assert len(session._errors) == 0 diff --git a/tests/plugins/mockserver/test_unix_mockserver.py b/tests/plugins/mockserver/test_unix_mockserver.py index 941385b4..fa83b2f6 100644 --- a/tests/plugins/mockserver/test_unix_mockserver.py +++ b/tests/plugins/mockserver/test_unix_mockserver.py @@ -21,6 +21,7 @@ def unix_mockserver( @pytest.fixture(scope='session') async def _unix_mockserver( testsuite_logger, + _mockserver_reporter, pytestconfig, tmp_path_factory, ): @@ -28,6 +29,7 @@ async def _unix_mockserver( tmp_path_factory.mktemp('mockserver') / 'mockserver.socket', loop=None, testsuite_logger=testsuite_logger, + mockserver_reporter=_mockserver_reporter, pytestconfig=pytestconfig, ) as result: yield result diff --git a/testsuite/mockserver/pytest_plugin.py b/testsuite/mockserver/pytest_plugin.py index c59999bb..e181b443 100644 --- a/testsuite/mockserver/pytest_plugin.py +++ b/testsuite/mockserver/pytest_plugin.py @@ -7,6 +7,7 @@ from . import classes from . import exceptions +from . import reporter_plugin from . import server MOCKSERVER_DEFAULT_PORT = 9999 @@ -114,6 +115,15 @@ def pytest_addoption(parser): ) +def pytest_configure(config): + config.pluginmanager.register( + reporter_plugin.MockserverReporterPlugin( + colors_enabled=colors.should_enable_color(config), + ), + 'mockserver_reporter', + ) + + def pytest_register_object_hooks(): return { '$mockserver': {'$fixture': '_mockserver_hook'}, @@ -205,14 +215,16 @@ async def _mockserver( pytestconfig, testsuite_logger, loop, + _mockserver_reporter, _mockserver_getport, ) -> annotations.AsyncYieldFixture[server.Server]: if pytestconfig.option.mockserver_unix_socket: async with server.create_unix_server( - socket_path=pytestconfig.option.mockserver_unix_socket, - loop=loop, - testsuite_logger=testsuite_logger, - pytestconfig=pytestconfig, + pytestconfig.option.mockserver_unix_socket, + loop, + testsuite_logger, + _mockserver_reporter, + pytestconfig, ) as result: yield result else: @@ -221,11 +233,12 @@ async def _mockserver( MOCKSERVER_DEFAULT_PORT, ) async with server.create_server( - host=pytestconfig.option.mockserver_host, - port=port, - loop=loop, - testsuite_logger=testsuite_logger, - pytestconfig=pytestconfig, + pytestconfig.option.mockserver_host, + port, + loop, + testsuite_logger, + _mockserver_reporter, + pytestconfig, ssl_info=None, ) as result: yield result @@ -237,6 +250,7 @@ async def _mockserver_ssl( testsuite_logger, loop, mockserver_ssl_cert, + _mockserver_reporter, _mockserver_getport, ) -> annotations.AsyncYieldFixture[typing.Optional[server.Server]]: if mockserver_ssl_cert: @@ -245,18 +259,26 @@ async def _mockserver_ssl( MOCKSERVER_SSL_DEFAULT_PORT, ) async with server.create_server( - host=pytestconfig.option.mockserver_ssl_host, - port=port, - loop=loop, - testsuite_logger=testsuite_logger, - pytestconfig=pytestconfig, - ssl_info=mockserver_ssl_cert, + pytestconfig.option.mockserver_ssl_host, + port, + loop, + testsuite_logger, + _mockserver_reporter, + pytestconfig, + mockserver_ssl_cert, ) as result: yield result else: yield None +@pytest.fixture(scope='session') +def _mockserver_reporter( + pytestconfig, +) -> reporter_plugin.MockserverReporterPlugin: + return pytestconfig.pluginmanager.get_plugin('mockserver_reporter') + + @pytest.fixture def _mockserver_trace_id() -> str: return server.generate_trace_id() diff --git a/testsuite/mockserver/reporter_plugin.py b/testsuite/mockserver/reporter_plugin.py new file mode 100644 index 00000000..b857726b --- /dev/null +++ b/testsuite/mockserver/reporter_plugin.py @@ -0,0 +1,52 @@ +import io +import typing + +import pytest + +from testsuite.utils import colors + +from . import exceptions + + +class MockserverReporterPlugin: + _errors: typing.List[typing.Tuple[Exception, str]] + + def __init__(self, *, colors_enabled: bool): + self._colors_enabled = colors_enabled + self._errors = [] + + @pytest.hookimpl(hookwrapper=True) + def pytest_runtest_call(self, item): + self._errors = [] + yield + if self._errors: + report = self._get_report() + item.add_report_section('errors', 'mockserver', report) + item.user_properties.append(('mockserver errors', report)) + first_error, _message = self._errors[0] + raise exceptions.MockServerError( + 'There were errors while processing mockserver requests.', + ) from first_error + + def report_error( + self, + error: Exception, + message: typing.Optional[str] = None, + ) -> None: + if message is None: + message = str(error) + self._errors.append((error, message)) + + def _get_report(self) -> str: + if not self._errors: + return '' + output = io.StringIO() + if self._colors_enabled: + output.write(colors.Colors.BRIGHT_RED) + for index, (_error, message) in enumerate(self._errors): + if index > 0: + output.write('\n\n') + output.write(message) + if self._colors_enabled: + output.write(colors.Colors.DEFAULT) + return output.getvalue() diff --git a/testsuite/mockserver/server.py b/testsuite/mockserver/server.py index 4fcf335d..ef508fe3 100644 --- a/testsuite/mockserver/server.py +++ b/testsuite/mockserver/server.py @@ -23,6 +23,7 @@ from . import classes from . import exceptions from . import magicargs +from . import reporter_plugin DEFAULT_TRACE_ID_HEADER = 'X-YaTraceId' DEFAULT_SPAN_ID_HEADER = 'X-YaSpanId' @@ -86,6 +87,9 @@ class Session: def __init__( self, + reporter: typing.Optional[ + reporter_plugin.MockserverReporterPlugin + ] = None, *, tracing_enabled=True, trace_id=None, @@ -95,13 +99,13 @@ def __init__( if trace_id is None: trace_id = generate_trace_id() self.trace_id = trace_id + self.reporter = reporter self.tracing_enabled = tracing_enabled self.handlers = {} self.prefix_handlers = [] self.regex_handlers = [] self.http_proxy_enabled = http_proxy_enabled self.mockserver_host = mockserver_host - self._errors = [] def get_handler(self, path: str) -> typing.Tuple[Handler, RouteParams]: handler = self.handlers.get(path) @@ -140,18 +144,8 @@ def _get_handler_not_found_message(self, path: str) -> str: f'{handlers_list}' ) - async def handle_request( - self, - request: aiohttp.web.BaseRequest, - nofail_404: bool, - ): - try: - handler, kwargs = self._get_handler_for_request(request) - except exceptions.HandlerNotFoundError as exc: - if not nofail_404: - self._errors.append(exc) - return _internal_error(f'Internal server error: {exc!r}') - + async def handle_request(self, request: aiohttp.web.BaseRequest): + handler, kwargs = self._get_handler_for_request(request) try: response = await handler(request, **kwargs) if isinstance(response, aiohttp.web.Response): @@ -160,18 +154,20 @@ async def handle_request( 'aiohttp.web.Response instance is expected ' f'{response!r} given', ) + except exceptions.HandlerNotFoundError: + raise except http.MockedError as exc: return _mocked_error_response(request, exc.error_code) except Exception as exc: - self._errors.append(exc) - return _internal_error(f'Internal server error: {exc!r}') + self._report_handler_failure(request.path, exc) + raise - def raise_errors(self): - for exc in self._errors: - raise exceptions.MockServerError( - f'There were {len(self._errors)} errors while processing ' - f'mockserver requests, showing the last one', - ) from exc + def _report_handler_failure(self, path: str, exc: Exception): + if self.reporter: + self.reporter.report_error( + exc, + f'Exception in mockserver handler for {path!r}: {exc !r}', + ) def register_handler( self, @@ -217,6 +213,9 @@ def __init__( *, nofail=False, mockserver_debug=False, + reporter: typing.Optional[ + reporter_plugin.MockserverReporterPlugin + ] = None, tracing_enabled=True, trace_id_header=DEFAULT_TRACE_ID_HEADER, span_id_header=DEFAULT_SPAN_ID_HEADER, @@ -225,6 +224,7 @@ def __init__( self._info = mockserver_info self._nofail = nofail self._mockserver_debug = mockserver_debug + self._reporter = reporter self._tracing_enabled = tracing_enabled self._trace_id_header = trace_id_header self._span_id_header = span_id_header @@ -254,18 +254,17 @@ def server_info(self) -> classes.MockserverInfo: @contextlib.contextmanager def new_session(self, trace_id: typing.Optional[str] = None): - session = Session( + self.session = Session( + self._reporter, tracing_enabled=self._tracing_enabled, trace_id=trace_id, http_proxy_enabled=self._http_proxy_enabled, mockserver_host=self._info.get_host_header(), ) - self.session = session try: - yield session + yield self.session finally: self.session = None - session.raise_errors() async def handle_request(self, request): started = time.perf_counter() @@ -303,9 +302,11 @@ def _log_request(self, started, request, response=None, exc=None): async def _handle_request(self, request: aiohttp.web.BaseRequest): trace_id = request.headers.get(self.trace_id_header) - nofail = self._nofail - if self.tracing_enabled and not _is_from_client_fixture(trace_id): - nofail = True + nofail = ( + self._nofail + or self.tracing_enabled + and not _is_from_client_fixture(trace_id) + ) if not self.session: error_message = 'Internal error: missing mockserver fixture' if nofail: @@ -319,12 +320,24 @@ async def _handle_request(self, request: aiohttp.web.BaseRequest): self._report_other_test_request(request, trace_id) return _internal_error(REQUEST_FROM_ANOTHER_TEST_ERROR) try: - return await self.session.handle_request(request, nofail_404=nofail) + return await self.session.handle_request(request) except exceptions.HandlerNotFoundError as exc: + self._report_handler_not_found(exc, nofail=nofail) return _internal_error( 'Internal error: mockserver handler not found', ) + def _report_handler_not_found( + self, + exc: exceptions.HandlerNotFoundError, + *, + nofail: bool, + ): + level = logging.WARNING if nofail else logging.ERROR + logger.log(level, '%s', exc) + if not nofail and self._reporter is not None: + self._reporter.report_error(exc) + def _report_other_test_request(self, request, trace_id): logger.warning( 'Mockserver called path %s with previous test trace_id %s', @@ -600,20 +613,29 @@ def _mocked_error_response(request, error_code) -> aiohttp.web.Response: ) -def _create_server_obj(mockserver_info, pytestconfig) -> Server: +def _create_server_obj( + mockserver_info, mockserver_reporter, pytestconfig +) -> Server: return Server( mockserver_info, nofail=pytestconfig.option.mockserver_nofail, mockserver_debug=pytestconfig.option.mockserver_debug, + reporter=mockserver_reporter, tracing_enabled=pytestconfig.getini('mockserver-tracing-enabled'), trace_id_header=pytestconfig.getini('mockserver-trace-id-header'), span_id_header=pytestconfig.getini('mockserver-span-id-header'), - http_proxy_enabled=pytestconfig.getini('mockserver-http-proxy-enabled'), + http_proxy_enabled=pytestconfig.getini( + 'mockserver-http-proxy-enabled', + ), ) def _create_web_server(server: Server, loop) -> aiohttp.web.Server: - return aiohttp.web.Server(server.handle_request, loop=loop, access_log=None) + return aiohttp.web.Server( + server.handle_request, + loop=loop, + access_log=None, + ) @compat.asynccontextmanager @@ -622,6 +644,7 @@ async def create_server( port: int, loop, testsuite_logger, + mockserver_reporter: reporter_plugin.MockserverReporterPlugin, pytestconfig, ssl_info: typing.Optional[classes.SslCertInfo], ) -> typing.AsyncGenerator[Server, None]: @@ -642,7 +665,9 @@ async def create_server( host, ssl_info, ) - server = _create_server_obj(mockserver_info, pytestconfig) + server = _create_server_obj( + mockserver_info, mockserver_reporter, pytestconfig + ) web_server = _create_web_server(server, loop) yield server @@ -652,6 +677,7 @@ async def create_unix_server( socket_path: pathlib.Path, loop, testsuite_logger, + mockserver_reporter: reporter_plugin.MockserverReporterPlugin, pytestconfig, ) -> typing.AsyncGenerator[Server, None]: async with net_utils.create_unix_server( @@ -659,7 +685,9 @@ async def create_unix_server( path=socket_path, ): mockserver_info = _create_unix_mockserver_info(socket_path) - server = _create_server_obj(mockserver_info, pytestconfig) + server = _create_server_obj( + mockserver_info, mockserver_reporter, pytestconfig + ) web_server = _create_web_server(server, loop) yield server