userver: /data/code/userver/testsuite/pytest_plugins/pytest_userver/plugins/service.py Source File
All Classes Namespaces Files Functions Variables Typedefs Enumerations Enumerator Friends Macros Modules Pages Concepts
service.py
1"""
2Start the service in testsuite.
3"""
4
5# pylint: disable=redefined-outer-name
6import logging
7import pathlib
8import time
9import typing
10from typing import Any
11from typing import Dict
12from typing import Iterable
13from typing import List
14from typing import Optional
15
16import pytest
17
18from testsuite.daemons.pytest_plugin import DaemonInstance
19from testsuite.utils import url_util
20
21from pytest_userver.utils import net
22
23logger = logging.getLogger(__name__)
24
25
26def pytest_addoption(parser) -> None:
27 group = parser.getgroup('userver')
28 group.addoption(
29 '--service-logs-file',
30 type=pathlib.Path,
31 help='Write service output to specified file',
32 )
33 group.addoption(
34 '--service-logs-pretty',
35 action='store_true',
36 help='Enable pretty print and colorize service logs',
37 )
38 group.addoption(
39 '--service-logs-pretty-verbose',
40 dest='service_logs_pretty',
41 action='store_const',
42 const='verbose',
43 help='Enable pretty print and colorize service logs in verbose mode',
44 )
45 group.addoption(
46 '--service-logs-pretty-disable',
47 action='store_false',
48 dest='service_logs_pretty',
49 help='Disable pretty print and colorize service logs',
50 )
51 group.addoption(
52 '--service-live-logs-disable',
53 action='store_true',
54 help='Disable service live logs (enabled with -s)',
55 )
56
57
58@pytest.fixture(scope='session')
60 """
61 Override this to pass extra environment variables to the service.
62
63 @snippet samples/redis_service/testsuite/conftest.py service_env
64 @ingroup userver_testsuite_fixtures
65 """
66 return None
67
68
69@pytest.fixture(scope='session')
70async def service_http_ping_url(service_config, service_baseurl) -> Optional[str]:
71 """
72 Returns the service HTTP ping URL that is used by the testsuite to detect
73 that the service is ready to work. Returns None if there's no such URL.
74
75 By default, attempts to find server::handlers::Ping component by
76 "handler-ping" name in static config. Override this fixture to change the
77 behavior.
78
79 @ingroup userver_testsuite_fixtures
80 """
81 components = service_config['components_manager']['components']
82 ping_handler = components.get('handler-ping')
83 if ping_handler:
84 return url_util.join(service_baseurl, ping_handler['path'])
85 return None
86
87
88@pytest.fixture(scope='session')
89def service_non_http_health_checks( # pylint: disable=invalid-name
90 service_config,
91) -> net.HealthChecks:
92 """
93 Returns a health checks info.
94
95 By default, returns pytest_userver.utils.net.get_health_checks_info().
96
97 Override this fixture to change the way testsuite detects the tested
98 service being alive.
99
100 @ingroup userver_testsuite_fixtures
101 """
102
103 return net.get_health_checks_info(service_config)
104
105
106@pytest.fixture(scope='session')
108 create_daemon_scope,
109 daemon_scoped_mark,
110 service_env,
111 service_http_ping_url,
112 service_config_path_temp,
113 service_binary,
114 service_non_http_health_checks,
115):
116 """
117 Prepares the start of the service daemon.
118 Configures the health checking to use service_http_ping_url fixture value
119 if it is not None; otherwise uses the service_non_http_health_checks info.
120
121 @see @ref pytest_userver.plugins.service.service_daemon_instance "service_daemon_instance"
122 @ingroup userver_testsuite_fixtures
123 """
124 assert service_http_ping_url or service_non_http_health_checks.tcp, (
125 '"service_http_ping_url" and "create_health_checker" fixtures '
126 'returned None. Testsuite is unable to detect if the service is ready '
127 'to accept requests.',
128 )
129
130 logger.debug(
131 'userver fixture "service_daemon_scope" would check for "%s"',
132 service_non_http_health_checks,
133 )
134
135 class LocalCounters:
136 last_log_time = 0.0
137 attempts = 0
138
139 async def _checker(*, session, process) -> bool:
140 LocalCounters.attempts += 1
141 new_log_time = time.monotonic()
142 if new_log_time - LocalCounters.last_log_time > 1.0:
143 LocalCounters.last_log_time = new_log_time
144 logger.debug(
145 'userver fixture "service_daemon_scope" checking "%s", attempt %s',
146 service_non_http_health_checks,
147 LocalCounters.attempts,
148 )
149
150 return await net.check_availability(service_non_http_health_checks)
151
152 health_check = _checker
153 if service_http_ping_url:
154 health_check = None
155
156 async with create_daemon_scope(
157 args=[str(service_binary), '--config', str(service_config_path_temp)],
158 ping_url=service_http_ping_url,
159 health_check=health_check,
160 env=service_env,
161 ) as scope:
162 yield scope
163
164
165@pytest.fixture
166def extra_client_deps() -> None:
167 """
168 Service client dependencies hook. Feel free to override, e.g.:
169
170 @code
171 @pytest.fixture
172 def extra_client_deps(some_fixtures_to_wait_before_service_start):
173 pass
174 @endcode
175
176 @ingroup userver_testsuite_fixtures
177 """
178
179
180@pytest.fixture
181def auto_client_deps(request) -> None:
182 """
183 Ensures that the following fixtures, if available, are run before service start:
184
185 * `pgsql`
186 * `mongodb`
187 * `clickhouse`
188 * `rabbitmq`
189 * kafka (`kafka_producer`, `kafka_consumer`)
190 * `redis_store`
191 * `mysql`
192 * @ref pytest_userver.plugins.ydb.ydbsupport.ydb "ydb"
193 * @ref pytest_userver.plugins.grpc.mockserver.grpc_mockserver_session "grpc_mockserver_session"
194
195 To add other dependencies prefer overriding the
196 @ref pytest_userver.plugins.service.extra_client_deps "extra_client_deps"
197 fixture.
198
199 @ingroup userver_testsuite_fixtures
200 """
201 known_deps = {
202 'pgsql',
203 'mongodb',
204 'clickhouse',
205 'rabbitmq',
206 'kafka_producer',
207 'kafka_consumer',
208 'redis_store',
209 'mysql',
210 'ydb',
211 'grpc_mockserver_session',
212 }
213
214 try:
215 fixture_lookup_error = pytest.FixtureLookupError
216 except AttributeError:
217 # support for an older version of the pytest
218 import _pytest.fixtures
219
220 fixture_lookup_error = _pytest.fixtures.FixtureLookupError
221
222 resolved_deps = []
223 for dep in known_deps:
224 try:
225 request.getfixturevalue(dep)
226 resolved_deps.append(dep)
227 except fixture_lookup_error:
228 pass
229
230 logger.debug(
231 'userver fixture "auto_client_deps" resolved dependencies %s',
232 resolved_deps,
233 )
234
235
236@pytest.fixture
238 testpoint,
239 cleanup_userver_dumps,
240 userver_log_capture,
241 dynamic_config,
242 mock_configs_service,
243):
244 """
245 Service client dependencies hook, like
246 @ref pytest_userver.plugins.service.extra_client_deps "extra_client_deps".
247
248 Feel free to override globally in a more specific pytest plugin
249 (one that comes after userver plugins),
250 but make sure to depend on the original fixture:
251
252 @code
253 @pytest.fixture(name='builtin_client_deps')
254 def _builtin_client_deps(builtin_client_deps, some_extra_fixtures):
255 pass
256 @endcode
257
258 @ingroup userver_testsuite_fixtures
259 """
260
261
262@pytest.fixture
264 ensure_daemon_started,
265 service_daemon_scope,
266 builtin_client_deps,
267 auto_client_deps,
268 # User defined client deps must be last in order to use
269 # fixtures defined above.
270 extra_client_deps,
271) -> DaemonInstance:
272 """
273 Calls `ensure_daemon_started` on
274 @ref pytest_userver.plugins.service.service_daemon_scope "service_daemon_scope"
275 to actually start the service. Makes sure that all the dependencies are prepared
276 before the service starts.
277
278 @see @ref pytest_userver.plugins.service.extra_client_deps "extra_client_deps"
279 @see @ref pytest_userver.plugins.service.auto_client_deps "auto_client_deps"
280 @see @ref pytest_userver.plugins.service.builtin_client_deps "builtin_client_deps"
281 @ingroup userver_testsuite_fixtures
282 """
283 # TODO also run userver_client_cleanup here
284 return await ensure_daemon_started(service_daemon_scope)
285
286
287@pytest.fixture(scope='session')
288def daemon_scoped_mark(request) -> Optional[Dict[str, Any]]:
289 """
290 Depend on this fixture directly or transitively to make your fixture a per-daemon fixture.
291
292 Example:
293
294 @code
295 @pytest.fixture(scope='session')
296 def users_cache_state(daemon_scoped_mark, ...):
297 return UsersCacheState(users_list=[])
298 @endcode
299
300 For tests marked with `@pytest.mark.uservice_oneshot(...)`, the service will be restarted,
301 and all the per-daemon fixtures will be recreated.
302
303 This fixture returns kwargs passed to the `uservice_oneshot` mark (which may be an empty dict).
304 For normal tests, this fixture returns `None`.
305
306 @ingroup userver_testsuite_fixtures
307 """
308 # === How daemon-scoped fixtures work ===
309 # pytest always keeps no more than 1 instance of each parametrized fixture.
310 # When a parametrized fixture is requested, FixtureDef checks using __eq__ whether the current value
311 # of fixture param equals the cached value. If they differ, then the fixture and all the dependent fixtures
312 # are torn down (in reverse-dependency order), then the fixture is set up again.
313 # TLDR: when the param changes, the whole tree of daemon-specific fixtures is torn down.
314 return getattr(request, 'param', None)
315
316
317# @cond
318
319
320def pytest_configure(config):
321 config.addinivalue_line(
322 'markers',
323 'uservice_oneshot: use a per-test service daemon instance',
324 )
325
326
327def _contains_oneshot_marker(parametrize: Iterable[pytest.Mark]) -> bool:
328 """
329 Check if at least one of 'parametrize' marks is of the form:
330
331 @pytest.mark.parametrize(
332 "foo, bar",
333 [
334 ("a", 10),
335 pytest.param("b", 20, marks=pytest.mark.uservice_oneshot), # <====
336 ]
337 )
338 """
339 return any(
340 True
341 for parametrize_mark in parametrize
342 if len(parametrize_mark.args) >= 2
343 for parameter_set in parametrize_mark.args[1]
344 if hasattr(parameter_set, 'marks')
345 for mark in parameter_set.marks
346 if mark.name == 'uservice_oneshot'
347 )
348
349
350def pytest_generate_tests(metafunc: pytest.Metafunc) -> None:
351 oneshot_marker = metafunc.definition.get_closest_marker('uservice_oneshot')
352 parametrize_markers = metafunc.definition.iter_markers('parametrize')
353 if oneshot_marker is not None or _contains_oneshot_marker(parametrize_markers):
354 # Set a dummy parameter value. Actual param is patched in pytest_collection_modifyitems.
355 metafunc.parametrize(
356 (daemon_scoped_mark.__name__,),
357 [(None,)],
358 indirect=True,
359 # TODO use pytest.HIDDEN_PARAM after it becomes available
360 # https://github.com/pytest-dev/pytest/issues/13228
361 ids=['uservice_oneshot'],
362 # TODO use scope='function' after it stops breaking fixture dependencies
363 # https://github.com/pytest-dev/pytest/issues/13248
364 scope=None,
365 )
366
367
368# TODO use dependent parametrize instead of patching param value after it becomes available
369# https://github.com/pytest-dev/pytest/issues/13233
370def pytest_collection_modifyitems(items: List[pytest.Item]):
371 for item in items:
372 oneshot_marker = item.get_closest_marker('uservice_oneshot')
373 if oneshot_marker and isinstance(item, pytest.Function):
374 func_item = typing.cast(pytest.Function, item)
375 func_item.callspec.params[daemon_scoped_mark.__name__] = dict(oneshot_marker.kwargs, function=func_item)
376
377
378# @endcond