Skip to content

Commit

Permalink
refactor testsuite: clean up daemon fixtures
Browse files Browse the repository at this point in the history
* Fully move support for `@pytest.mark.uservice_oneshot` into userver
* Make `service_daemon` and related fixtures `package`-scoped
* Add `grpc_service_port_fallback` fixture. If `grpc-server` normally uses Unix socket, then testing via `start` now forces usage of the specified port instead, 11080 by default
commit_hash:7115590bcf11d927372640bbb84b06a3e727fe34
  • Loading branch information
Anton3 committed Feb 25, 2025
1 parent 7bfbd7e commit 11058ac
Show file tree
Hide file tree
Showing 8 changed files with 168 additions and 78 deletions.
2 changes: 1 addition & 1 deletion grpc/functional_tests/basic_server/tests-tls/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ def grpc_client(grpc_channel):


@pytest.fixture(scope='session')
async def grpc_session_channel(grpc_service_endpoint, daemon_scoped_mark):
async def grpc_session_channel(grpc_service_endpoint):
with open(TESTDIR / 'cert.crt', 'rb') as fi:
root_ca = fi.read()
credentials = grpc.ssl_channel_credentials(root_ca)
Expand Down
7 changes: 7 additions & 0 deletions testsuite/pytest_plugins/pytest_userver/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,13 @@ async def request(
response = await self._client.request(http_method, path, **kwargs)
return await self._wrap_client_response(response)

@property
def raw_aiohttp_client(self):
"""
@deprecated Use pytest_userver.client.Client directly instead.
"""
return self._client

def _wrap_client_response(
self,
response: aiohttp.ClientResponse,
Expand Down
58 changes: 48 additions & 10 deletions testsuite/pytest_plugins/pytest_userver/plugins/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,16 @@

# pylint: disable=redefined-outer-name
import copy
import dataclasses
import logging
import os
import pathlib
import subprocess
import types
import typing
from typing import Any
from typing import Callable
from typing import List
from typing import Optional

import pytest
import yaml
Expand Down Expand Up @@ -62,7 +66,8 @@ def pytest_plugin_registered(self, plugin, manager):
self._config_hooks.extend(uhooks)


class _UserverConfig(typing.NamedTuple):
@dataclasses.dataclass(frozen=True)
class _UserverConfig:
config_yaml: dict
config_vars: dict

Expand Down Expand Up @@ -149,7 +154,7 @@ def db_dump_schema_path(service_binary, service_tmpdir) -> pathlib.Path:


@pytest.fixture(scope='session')
def service_config_vars_path(pytestconfig) -> typing.Optional[pathlib.Path]:
def service_config_vars_path(pytestconfig) -> Optional[pathlib.Path]:
"""
Returns the path to config_vars.yaml file set by command line
`--service-config-vars` option.
Expand All @@ -163,7 +168,7 @@ def service_config_vars_path(pytestconfig) -> typing.Optional[pathlib.Path]:


@pytest.fixture(scope='session')
def service_secdist_path(pytestconfig) -> typing.Optional[pathlib.Path]:
def service_secdist_path(pytestconfig) -> Optional[pathlib.Path]:
"""
Returns the path to secure_data.json file set by command line
`--service-secdist` option.
Expand Down Expand Up @@ -271,7 +276,7 @@ def _substitute_values(config, service_config_vars: dict, service_env) -> None:
env = config.get(f'{key}#env')
if env:
if service_env:
new_value = service_env.get(service_env)
new_value = service_env.get(env)
if not new_value:
new_value = os.environ.get(env)
if new_value:
Expand All @@ -281,6 +286,9 @@ def _substitute_values(config, service_config_vars: dict, service_env) -> None:
fallback = config.get(f'{key}#fallback')
if fallback:
config[key] = fallback
continue

config[key] = None

if isinstance(config, list):
for i, value in enumerate(config):
Expand All @@ -296,11 +304,42 @@ def _substitute_values(config, service_config_vars: dict, service_env) -> None:
config[i] = new_value


@pytest.fixture(scope='session')
def substitute_config_vars(service_env) -> Callable[[Any, dict], Any]:
"""
A function that takes `config_yaml`, `config_vars` and applies all
substitutions just like the service would.
Useful when patching the service config. It's a good idea to pass
a component's config instead of the whole `config_yaml` to avoid
unnecessary work.
@warning The returned YAML is a clone, mutating it will not modify
the actual config while in a config hook!
@ingroup userver_testsuite_fixtures
"""

def substitute(config_yaml, config_vars, /):
if config_yaml is not None and not isinstance(config_yaml, dict) and not isinstance(config_yaml, list):
raise TypeError(
f'{substitute_config_vars.__name__} can only be meaningfully '
'called with dict and list nodes of config_yaml, while given: '
f'{config_yaml!r}. Pass a containing object instead.',
)

config = copy.deepcopy(config_yaml)
_substitute_values(config, config_vars, service_env)
return config

return substitute


@pytest.fixture(scope='session')
def service_config(
service_config_yaml,
service_config_vars,
service_env,
substitute_config_vars,
) -> dict:
"""
Returns the static config values after the USERVER_CONFIG_HOOKS were
Expand All @@ -309,8 +348,7 @@ def service_config(
@ingroup userver_testsuite_fixtures
"""
config = copy.deepcopy(service_config_yaml)
_substitute_values(config, service_config_vars, service_env)
config = substitute_config_vars(service_config_yaml, service_config_vars)
config.pop('config_vars', None)
return config

Expand Down Expand Up @@ -396,7 +434,7 @@ def _patch_config(config_yaml, config_vars):


@pytest.fixture(scope='session')
def allowed_url_prefixes_extra() -> typing.List[str]:
def allowed_url_prefixes_extra() -> List[str]:
"""
By default, userver HTTP client is only allowed to talk to mockserver
when running in testsuite. This makes tests repeatable and encapsulated.
Expand Down Expand Up @@ -556,7 +594,7 @@ def patch_config(config, config_vars) -> None:
testsuite_support['testsuite-grpc-is-tls-enabled'] = False
_set_postgresql_options(testsuite_support)
_set_redis_timeout(testsuite_support)
service_runner = pytestconfig.getoption('--service-runner-mode', False)
service_runner = pytestconfig.option.service_runner_mode
if not service_runner:
_disable_cache_periodic_update(testsuite_support)
testsuite_support['testsuite-tasks-enabled'] = not service_runner
Expand Down
2 changes: 1 addition & 1 deletion testsuite/pytest_plugins/pytest_userver/plugins/dumps.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ def cleanup() -> None:
def _userver_config_dumps(pytestconfig, userver_dumps_root):
def patch_config(_config, config_vars) -> None:
config_vars['userver-dumps-root'] = str(userver_dumps_root)
if not pytestconfig.getoption('--service-runner-mode', False):
if not pytestconfig.option.service_runner_mode:
config_vars['userver-dumps-periodic'] = False

return patch_config
Original file line number Diff line number Diff line change
Expand Up @@ -469,7 +469,7 @@ async def update(self, client, dynamic_config) -> None:
# unspecified in tests, on the testsuite side. For that, we ask the service
# for the dynamic config defaults after it's launched. It's enough to update
# defaults once per service launch.
@pytest.fixture(scope='package')
@pytest.fixture(scope='session')
def _dynamic_config_defaults_storage() -> _ConfigDefaults:
return _ConfigDefaults(snapshot=None)

Expand Down
48 changes: 41 additions & 7 deletions testsuite/pytest_plugins/pytest_userver/plugins/grpc/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,11 @@

DEFAULT_TIMEOUT = 15.0

USERVER_CONFIG_HOOKS = ['userver_config_grpc_endpoint']


@pytest.fixture(scope='session')
def grpc_service_port(service_config) -> int:
def grpc_service_port(service_config) -> Optional[int]:
"""
Returns the gRPC listener port number of the service that is set in the
static configuration file.
Expand All @@ -33,7 +35,18 @@ def grpc_service_port(service_config) -> int:
components = service_config['components_manager']['components']
if 'grpc-server' not in components:
raise RuntimeError('No grpc-server component')
return int(components['grpc-server']['port'])
return components['grpc-server'].get('port', None)


@pytest.fixture(scope='session')
def grpc_service_port_fallback() -> int:
"""
Returns the gRPC port that should be used in service runner mode in case
no port is specified in the source config_yaml.
@ingroup userver_testsuite_fixtures
"""
return 11080


@pytest.fixture(scope='session')
Expand Down Expand Up @@ -93,7 +106,6 @@ async def prepare(

@pytest.fixture(scope='session')
async def grpc_session_channel(
daemon_scoped_mark,
grpc_service_endpoint,
_grpc_channel_interceptor,
):
Expand All @@ -106,8 +118,8 @@ async def grpc_session_channel(

@pytest.fixture
async def grpc_channel(
service_client, # For daemon setup and userver_client_cleanup
grpc_service_endpoint,
grpc_service_deps,
grpc_service_timeout,
grpc_session_channel,
_grpc_channel_interceptor,
Expand All @@ -132,14 +144,36 @@ async def grpc_channel(
return grpc_session_channel


@pytest.fixture
def grpc_service_deps(service_client):
@pytest.fixture(scope='session')
def userver_config_grpc_endpoint(
pytestconfig,
grpc_service_port_fallback,
substitute_config_vars,
):
"""
gRPC service dependencies hook. Feel free to override it.
Returns a function that adjusts the static config for testsuite.
Ensures that in service runner mode, Unix socket is never used.
@ingroup userver_testsuite_fixtures
"""

def patch_config(config_yaml, config_vars):
components = config_yaml['components_manager']['components']
grpc_server = components.get('grpc-server', None)
if not grpc_server:
return

service_runner = pytestconfig.option.service_runner_mode
if not service_runner:
return

grpc_server.pop('unix-socket-path', None)
if 'port' not in substitute_config_vars(grpc_server, config_vars):
grpc_server['port'] = grpc_service_port_fallback
config_vars['grpc_server_port'] = grpc_service_port_fallback

return patch_config


# Taken from
# https://github.com/grpc/grpc/blob/master/examples/python/interceptors/headers/generic_client_interceptor.py
Expand Down
87 changes: 49 additions & 38 deletions testsuite/pytest_plugins/pytest_userver/plugins/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@
# pylint: disable=redefined-outer-name
import logging
import pathlib
import re
import time
import typing
from typing import Any
from typing import Dict
from typing import Iterable
from typing import List
from typing import Optional
from typing import Tuple

import pytest

Expand Down Expand Up @@ -67,10 +67,7 @@ def service_env():


@pytest.fixture(scope='session')
async def service_http_ping_url(
service_config,
service_baseurl,
) -> Optional[str]:
async def service_http_ping_url(service_config, service_baseurl) -> Optional[str]:
"""
Returns the service HTTP ping URL that is used by the testsuite to detect
that the service is ready to work. Returns None if there's no such URL.
Expand Down Expand Up @@ -313,41 +310,55 @@ def pytest_configure(config):
)


def pytest_generate_tests(metafunc: pytest.Metafunc) -> None:
oneshot_marker = metafunc.definition.get_closest_marker('uservice_oneshot')
if oneshot_marker:
param = oneshot_marker.kwargs
if param.get('shared', False):
# Make sure that the param is not __eq__ to params of any other test.
param = dict(param, function=metafunc.definition)
metafunc.parametrize((daemon_scoped_mark.__name__,), [(param,)], indirect=True, ids=[_HIDDEN_PARAM])


_HIDDEN_PARAM = '__HIDDEN_PARAM__'
_HIDDEN_PATTERNS: Tuple[re.Pattern, ...] = (
re.compile(rf'\[{_HIDDEN_PARAM}\d*]'),
re.compile(rf'-{_HIDDEN_PARAM}\d*'),
re.compile(rf'{_HIDDEN_PARAM}\d*-'),
)
_PARAM_PATTERN = re.compile(r'\[([^\]]*)]')
def _contains_oneshot_marker(parametrize: Iterable[pytest.Mark]) -> bool:
"""
Check if at least one of 'parametrize' marks is of the form:
@pytest.mark.parametrize(
"foo, bar",
[
("a", 10),
pytest.param("b", 20, marks=pytest.mark.uservice_oneshot), # <====
]
)
"""
return any(
True
for parametrize_mark in parametrize
if len(parametrize_mark.args) >= 2
for parameter_set in parametrize_mark.args[1]
if hasattr(parameter_set, 'marks')
for mark in parameter_set.marks
if mark.name == 'uservice_oneshot'
)


def pytest_collection_modifyitems(session: pytest.Session, config: pytest.Config, items: List[pytest.Item]):
def pytest_generate_tests(metafunc: pytest.Metafunc) -> None:
oneshot_marker = metafunc.definition.get_closest_marker('uservice_oneshot')
parametrize_markers = metafunc.definition.iter_markers('parametrize')
if oneshot_marker is not None or _contains_oneshot_marker(parametrize_markers):
# Set a dummy parameter value. Actual param is patched in pytest_collection_modifyitems.
metafunc.parametrize(
(daemon_scoped_mark.__name__,),
[(None,)],
indirect=True,
# TODO use pytest.HIDDEN_PARAM after it becomes available
# https://github.com/pytest-dev/pytest/issues/13228
ids=['uservice_oneshot'],
# TODO use scope='function' after it stops breaking fixture dependencies
# https://github.com/pytest-dev/pytest/issues/13248
scope=None,
)


# TODO use dependent parametrize instead of patching param value after it becomes available
# https://github.com/pytest-dev/pytest/issues/13233
def pytest_collection_modifyitems(items: List[pytest.Item]):
for item in items:
# The fact that daemon_scoped_mark works through fixture parametrization is an implementation detail.
# Hide the param from test output, making the feature transparent for the user.
if _HIDDEN_PARAM not in item.name:
continue
for regex in _HIDDEN_PATTERNS:
if regex.search(item.name):
break
else:
assert False, f'Failed to mask test {item.name}'
item.name = regex.sub('', item.name)
item._nodeid = regex.sub('', item._nodeid) # pylint: disable=protected-access
item.keywords[item.name] = True
if leftover_params := _PARAM_PATTERN.search(item.name):
item.keywords[leftover_params.group(1)] = True
oneshot_marker = item.get_closest_marker('uservice_oneshot')
if oneshot_marker and isinstance(item, pytest.Function):
func_item = typing.cast(pytest.Function, item)
func_item.callspec.params[daemon_scoped_mark.__name__] = dict(oneshot_marker.kwargs, function=func_item)


# @endcond
Loading

0 comments on commit 11058ac

Please sign in to comment.