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')
100 create_daemon_scope,
101 daemon_scoped_mark,
102 service_env,
103 service_http_ping_url,
104 service_config_path_temp,
105 service_binary,
106 service_non_http_health_checks,
107):
108 """
109 Prepares the start of the service daemon.
110 Configures the health checking to use service_http_ping_url fixture value
111 if it is not None; otherwise uses the service_non_http_health_checks info.
112
113 @see @ref pytest_userver.plugins.service.service_daemon_instance "service_daemon_instance"
114 @ingroup userver_testsuite_fixtures
115 """
116 assert service_http_ping_url or service_non_http_health_checks.tcp, (
117 '"service_http_ping_url" and "create_health_checker" fixtures '
118 'returned None. Testsuite is unable to detect if the service is ready '
119 'to accept requests.',
120 )
121
122 logger.debug(
123 'userver fixture "service_daemon_scope" would check for "%s"',
124 service_non_http_health_checks,
125 )
126
127 class LocalCounters:
128 last_log_time = 0.0
129 attempts = 0
130
131 async def _checker(*, session, process) -> bool:
132 LocalCounters.attempts += 1
133 new_log_time = time.monotonic()
134 if new_log_time - LocalCounters.last_log_time > 1.0:
135 LocalCounters.last_log_time = new_log_time
136 logger.debug(
137 'userver fixture "service_daemon_scope" checking "%s", attempt %s',
138 service_non_http_health_checks,
139 LocalCounters.attempts,
140 )
141
142 return await net.check_availability(service_non_http_health_checks)
143
144 health_check = _checker
145 if service_http_ping_url:
146 health_check = None
147
148 async with create_daemon_scope(
149 args=[str(service_binary), '--config', str(service_config_path_temp)],
150 ping_url=service_http_ping_url,
151 health_check=health_check,
152 env=service_env,
153 ) as scope:
154 yield scope
155
156
157@pytest.fixture
158def extra_client_deps() -> None:
159 """
160 Service client dependencies hook. Feel free to override, e.g.:
161
162 @code
163 @pytest.fixture
164 def extra_client_deps(some_fixtures_to_wait_before_service_start):
165 pass
166 @endcode
167
168 @ingroup userver_testsuite_fixtures
169 """
170
171
172@pytest.fixture
173def auto_client_deps(request) -> None:
174 """
175 Ensures that the following fixtures, if available, are run before service start:
176
177 * `pgsql`
178 * `mongodb`
179 * `clickhouse`
180 * `rabbitmq`
181 * kafka (`kafka_producer`, `kafka_consumer`)
182 * `redis_store`
183 * `mysql`
184 * @ref pytest_userver.plugins.ydb.ydbsupport.ydb "ydb"
185 * @ref pytest_userver.plugins.grpc.mockserver.grpc_mockserver "grpc_mockserver"
186
187 To add other dependencies prefer overriding the
188 @ref pytest_userver.plugins.service.extra_client_deps "extra_client_deps"
189 fixture.
190
191 @ingroup userver_testsuite_fixtures
192 """
193 known_deps = {
194 'pgsql',
195 'mongodb',
196 'clickhouse',
197 'rabbitmq',
198 'kafka_producer',
199 'kafka_consumer',
200 'redis_store',
201 'mysql',
202 'ydb',
203 'grpc_mockserver',
204 }
205
206 try:
207 fixture_lookup_error = pytest.FixtureLookupError
208 except AttributeError:
209 # support for an older version of the pytest
210 import _pytest.fixtures
211
212 fixture_lookup_error = _pytest.fixtures.FixtureLookupError
213
214 resolved_deps = []
215 for dep in known_deps:
216 try:
217 request.getfixturevalue(dep)
218 resolved_deps.append(dep)
219 except fixture_lookup_error:
220 pass
221
222 logger.debug(
223 'userver fixture "auto_client_deps" resolved dependencies %s',
224 resolved_deps,
225 )
226
227
228@pytest.fixture
230 testpoint,
231 cleanup_userver_dumps,
232 userver_log_capture,
233 dynamic_config,
234 mock_configs_service,
235):
236 """
237 Service client dependencies hook, like
238 @ref pytest_userver.plugins.service.extra_client_deps "extra_client_deps".
239
240 Feel free to override globally in a more specific pytest plugin
241 (one that comes after userver plugins),
242 but make sure to depend on the original fixture:
243
244 @code
245 @pytest.fixture(name='builtin_client_deps')
246 def _builtin_client_deps(builtin_client_deps, some_extra_fixtures):
247 pass
248 @endcode
249
250 @ingroup userver_testsuite_fixtures
251 """
252
253
254@pytest.fixture
256 ensure_daemon_started,
257 service_daemon_scope,
258 builtin_client_deps,
259 auto_client_deps,
260 # User defined client deps must be last in order to use
261 # fixtures defined above.
262 extra_client_deps,
263) -> DaemonInstance:
264 """
265 Calls `ensure_daemon_started` on
266 @ref pytest_userver.plugins.service.service_daemon_scope "service_daemon_scope"
267 to actually start the service. Makes sure that all the dependencies are prepared
268 before the service starts.
269
270 @see @ref pytest_userver.plugins.service.extra_client_deps "extra_client_deps"
271 @see @ref pytest_userver.plugins.service.auto_client_deps "auto_client_deps"
272 @see @ref pytest_userver.plugins.service.builtin_client_deps "builtin_client_deps"
273 @ingroup userver_testsuite_fixtures
274 """
275 # TODO also run userver_client_cleanup here
276 return await ensure_daemon_started(service_daemon_scope)
277
278
279@pytest.fixture(scope='session')
280def daemon_scoped_mark(request) -> dict[str, Any] | None:
281 """
282 Depend on this fixture directly or transitively to make your fixture a per-daemon fixture.
283
284 Example:
285
286 @code
287 @pytest.fixture(scope='session')
288 def users_cache_state(daemon_scoped_mark, ...):
289 return UsersCacheState(users_list=[])
290 @endcode
291
292 For tests marked with `@pytest.mark.uservice_oneshot(...)`, the service will be restarted,
293 and all the per-daemon fixtures will be recreated.
294
295 This fixture returns kwargs passed to the `uservice_oneshot` mark (which may be an empty dict).
296 For normal tests, this fixture returns `None`.
297
298 @ingroup userver_testsuite_fixtures
299 """
300 # === How daemon-scoped fixtures work ===
301 # pytest always keeps no more than 1 instance of each parametrized fixture.
302 # When a parametrized fixture is requested, FixtureDef checks using __eq__ whether the current value
303 # of fixture param equals the cached value. If they differ, then the fixture and all the dependent fixtures
304 # are torn down (in reverse-dependency order), then the fixture is set up again.
305 # TLDR: when the param changes, the whole tree of daemon-specific fixtures is torn down.
306 return getattr(request, 'param', None)
307
308
309# @cond
310
311
312def pytest_configure(config):
313 config.addinivalue_line(
314 'markers',
315 'uservice_oneshot: use a per-test service daemon instance',
316 )
317
318
319def _contains_oneshot_marker(parametrize: Iterable[pytest.Mark]) -> bool:
320 """
321 Check if at least one of 'parametrize' marks is of the form:
322
323 @pytest.mark.parametrize(
324 "foo, bar",
325 [
326 ("a", 10),
327 pytest.param("b", 20, marks=pytest.mark.uservice_oneshot), # <====
328 ]
329 )
330 """
331 return any(
332 True
333 for parametrize_mark in parametrize
334 if len(parametrize_mark.args) >= 2
335 for parameter_set in parametrize_mark.args[1]
336 if hasattr(parameter_set, 'marks')
337 for mark in parameter_set.marks
338 if mark.name == 'uservice_oneshot'
339 )
340
341
342def pytest_generate_tests(metafunc: pytest.Metafunc) -> None:
343 oneshot_marker = metafunc.definition.get_closest_marker('uservice_oneshot')
344 parametrize_markers = metafunc.definition.iter_markers('parametrize')
345 if oneshot_marker is not None or _contains_oneshot_marker(parametrize_markers):
346 # Set a dummy parameter value. Actual param is patched in pytest_collection_modifyitems.
347 metafunc.parametrize(
348 (daemon_scoped_mark.__name__,),
349 [(None,)],
350 indirect=True,
351 # TODO use pytest.HIDDEN_PARAM after it becomes available
352 # https://github.com/pytest-dev/pytest/issues/13228
353 ids=['uservice_oneshot'],
354 # TODO use scope='function' after it stops breaking fixture dependencies
355 # https://github.com/pytest-dev/pytest/issues/13248
356 scope=None,
357 )
358
359
360# TODO use dependent parametrize instead of patching param value after it becomes available
361# https://github.com/pytest-dev/pytest/issues/13233
362def pytest_collection_modifyitems(items: list[pytest.Item]):
363 for item in items:
364 oneshot_marker = item.get_closest_marker('uservice_oneshot')
365 if oneshot_marker and isinstance(item, pytest.Function):
366 func_item = typing.cast(pytest.Function, item)
367 func_item.callspec.params[daemon_scoped_mark.__name__] = dict(oneshot_marker.kwargs, function=func_item)
368
369
370# @endcond