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