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