diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 633b338..87db514 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -46,4 +46,4 @@ jobs: - name: Test shell: bash run: | - tox -e py --installpkg `find dist/*.tar.gz` + tox -e py,xdist --installpkg `find dist/*.tar.gz` diff --git a/README.md b/README.md index 93a59bc..4efa136 100644 --- a/README.md +++ b/README.md @@ -23,8 +23,9 @@ pip install pytest-smoke ## Usage -The plugin provides the following options, allowing you to limit the amount of tests to run (`N`) and optionally specify - the scope at which `N` is applied: +The plugin provides the following options to limit the amount of tests to run (`N`, default=`1`) and optionally specify +the scope at which `N` is applied. +If provided, the value of `N` can be either a number (e.g., `5`) or a percentage (e.g., `10%`). ``` $ pytest -h @@ -44,10 +45,9 @@ Smoke testing: NOTE: You can also implement your own custom scopes using a hook ``` -> - The value of `N` can be a number (e.g. `5`) or a percentage (e.g. `10%`) -> - If `N` is not explicitly specified, the default value of `1` will be used -> - The `--smoke-scope` option supports any custom values, as long as they are handled in the hook -> - You can overwrite the plugin's default value for `N` and `SCOPE` using INI options. See the "INI Options" section below +> - The `--smoke-scope` option also supports any custom values, as long as they are handled in the hook +> - You can override the plugin's default value for `N` and `SCOPE` using INI options. See the "INI Options" section below +> - When using the `pytest-xdist` plugin for parallel testing, you can configure the `pytest-smoke` plugin to enable a custom distribution algorithm that distributes tests based on the smoke scope ## Examples @@ -234,7 +234,7 @@ tests/test_something.py::test_something3[17] PASSED [100%] The plugin provides the following hooks to customize or extend the plugin's capabilities: ### `pytest_smoke_generate_group_id(item, scope)` -This hook allows you to implement your own custom scopes for the `--scope-smoke` option, or overwrite the logic of the +This hook allows you to implement your own custom scopes for the `--smoke-scope` option, or override the logic of the predefined scopes. Items with the same group ID are grouped together and are considered to be in the same scope, at which `N` is applied. Any custom values passed to the `--smoke-scope` option must be handled in this hook. @@ -250,7 +250,7 @@ selected. ## INI Options -You can overwrite the plugin's default values by setting the following options in a configuration +You can override the plugin's default values by setting the following options in a configuration file (pytest.ini, pyproject.toml, etc.). ### `smoke_default_n` @@ -260,3 +260,9 @@ Plugin default: `1` ### `smoke_default_scope` The default smoke scope to be applied when not explicitly specified with the `--smoke-scope` option. Plugin default: `function` + +### `smoke_xdist_dist_by_scope` +When using the `pytest-xdist` plugin (>=2.3.0) for parallel testing, a custom distribution algorithm that +distributes tests based on the smoke scope can be enabled. The custom scheduler will be automatically used when +the `-n`/`--numprocesses` option is used. +Plugin default: `false` diff --git a/pyproject.toml b/pyproject.toml index be3a415..03bc08b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ dev = [ "pytest-xdist[psutil]>=2.5.0,<4", "ruff==0.8.3", "tox>=4.0.0,<5", - "tox-uv>=1.0.0,<2" + "tox-uv>=1.0.0,<2", ] [project.urls] diff --git a/src/pytest_smoke/__init__.py b/src/pytest_smoke/__init__.py index f8be25e..ff1aeb8 100644 --- a/src/pytest_smoke/__init__.py +++ b/src/pytest_smoke/__init__.py @@ -3,5 +3,25 @@ try: __version__ = version("pytest-smoke") except PackageNotFoundError: - # package is not installed pass + + +class PytestSmoke: + def __init__(self): + try: + from xdist import __version__ as __xdist_version__ + + self._is_xdist_installed = __xdist_version__ >= "2.3.0" + except ImportError: + self._is_xdist_installed = False + + @property + def is_xdist_installed(self) -> bool: + return self._is_xdist_installed + + @is_xdist_installed.setter + def is_xdist_installed(self, v: bool): + self._is_xdist_installed = v + + +smoke = PytestSmoke() diff --git a/src/pytest_smoke/extensions/__init__.py b/src/pytest_smoke/extensions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/pytest_smoke/extensions/xdist.py b/src/pytest_smoke/extensions/xdist.py new file mode 100644 index 0000000..0a8ccab --- /dev/null +++ b/src/pytest_smoke/extensions/xdist.py @@ -0,0 +1,39 @@ +from pytest import Config, Item, Session, hookimpl + +from pytest_smoke import smoke +from pytest_smoke.types import SmokeIniOption +from pytest_smoke.utils import generate_group_id, get_scope, parse_ini_option + +if smoke.is_xdist_installed: + from xdist import is_xdist_controller + from xdist.scheduler import LoadScopeScheduling + + class PytestSmokeXdist: + """A plugin that extends pytest-smoke to seamlesslly support pytest-xdist""" + + name = "smoke-xdist" + + def __init__(self): + self._nodes = None + + @hookimpl(tryfirst=True) + def pytest_collection(self, session: Session): + if is_xdist_controller(session): + self._nodes = {item.nodeid: item for item in session.perform_collect()} + return True + + def pytest_xdist_make_scheduler(self, config: Config, log): + if parse_ini_option(config, SmokeIniOption.SMOKE_XDIST_DIST_BY_SCOPE): + return SmokeScopeScheduling(config, log, nodes=self._nodes) + + class SmokeScopeScheduling(LoadScopeScheduling): + """A custom pytest-xdist scheduler that distributes workloads by smoke scope groups""" + + def __init__(self, config: Config, log, *, nodes: dict[str, Item]): + super().__init__(config, log) + self._scope = get_scope(self.config) + self._nodes = nodes + + def _split_scope(self, nodeid: str) -> str: + item = self._nodes[nodeid] + return generate_group_id(item, self._scope) or super()._split_scope(nodeid) diff --git a/src/pytest_smoke/hooks.py b/src/pytest_smoke/hooks.py index 4388b9a..21b5307 100644 --- a/src/pytest_smoke/hooks.py +++ b/src/pytest_smoke/hooks.py @@ -5,7 +5,7 @@ def pytest_smoke_generate_group_id(item: Item, scope: str): """Return a smoke scope group ID for the predefined or custom scopes - Use this hook to either overwrite the logic of the predefined scopes or to implement logic for your own scopes + Use this hook to either override the logic of the predefined scopes or to implement logic for your own scopes NOTE: Any custom scopes given to the --smoke-scope option must be handled in this hook """ diff --git a/src/pytest_smoke/plugin.py b/src/pytest_smoke/plugin.py index 7b077a7..e3105d3 100644 --- a/src/pytest_smoke/plugin.py +++ b/src/pytest_smoke/plugin.py @@ -1,32 +1,29 @@ +import os import random from collections import Counter -from enum import auto -from functools import lru_cache -from typing import Optional, Union +from dataclasses import dataclass, field +from uuid import UUID, uuid4 import pytest -from pytest import Config, Item, Parser, PytestPluginManager +from pytest import Config, Item, Parser, PytestPluginManager, Session -from pytest_smoke.utils import StrEnum, scale_down +from pytest_smoke import smoke +from pytest_smoke.types import SmokeDefaultN, SmokeEnvVar, SmokeIniOption, SmokeScope +from pytest_smoke.utils import generate_group_id, get_scope, parse_ini_option, parse_n, parse_scope, scale_down +if smoke.is_xdist_installed: + from xdist import is_xdist_controller, is_xdist_worker -class SmokeScope(StrEnum): - FUNCTION = auto() - CLASS = auto() - AUTO = auto() - FILE = auto() - ALL = auto() + from pytest_smoke.extensions.xdist import PytestSmokeXdist -class SmokeIniOption(StrEnum): - SMOKE_DEFAULT_N = auto() - SMOKE_DEFAULT_SCOPE = auto() - +DEFAULT_N = SmokeDefaultN(1) -class SmokeDefaultN(int): ... - -DEFAULT_N = SmokeDefaultN(1) +@dataclass +class SmokeGroupIDCounter: + collected: Counter = field(default_factory=Counter) + sellected: Counter = field(default_factory=Counter) @pytest.hookimpl(trylast=True) @@ -43,7 +40,7 @@ def pytest_addoption(parser: Parser): dest="smoke", metavar="N", const=DEFAULT_N, - type=_parse_n, + type=parse_n, nargs="?", default=False, help="Run the first N (default=1) tests from each test function or specified scope", @@ -53,7 +50,7 @@ def pytest_addoption(parser: Parser): dest="smoke_last", metavar="N", const=DEFAULT_N, - type=_parse_n, + type=parse_n, nargs="?", default=False, help="Run the last N (default=1) tests from each test function or specified scope", @@ -63,7 +60,7 @@ def pytest_addoption(parser: Parser): dest="smoke_random", metavar="N", const=DEFAULT_N, - type=_parse_n, + type=parse_n, nargs="?", default=False, help="Run N (default=1) randomly selected tests from each test function or specified scope", @@ -72,7 +69,7 @@ def pytest_addoption(parser: Parser): "--smoke-scope", dest="smoke_scope", metavar="SCOPE", - type=_parse_scope, + type=parse_scope, help=( "Specify the scope at which the value of N from the above options is applied.\n" "The plugin provides the following predefined scopes:\n" @@ -85,17 +82,25 @@ def pytest_addoption(parser: Parser): "NOTE: You can also implement your own custom scopes using a hook" ), ) + parser.addini( SmokeIniOption.SMOKE_DEFAULT_N, type="string", default=str(DEFAULT_N), - help="Overwrite the plugin default value for smoke N", + help="[pytest-smoke] Override the plugin default value for smoke N", ) parser.addini( SmokeIniOption.SMOKE_DEFAULT_SCOPE, type="string", default=SmokeScope.FUNCTION, - help="Overwrite the plugin default value for smoke scope", + help="[pytest-smoke] Override the plugin default value for smoke scope", + ) + parser.addini( + SmokeIniOption.SMOKE_XDIST_DIST_BY_SCOPE, + type="bool", + default=False, + help="[pytest-smoke] When using the pytest-xdist plugin for parallel testing, a custom distribution algorithm " + "that distributes tests based on the smoke scope can be enabled", ) @@ -111,35 +116,53 @@ def pytest_configure(config: Config): "The --smoke-scope option requires one of --smoke, --smoke-last, or --smoke-random to be specified" ) + if smoke.is_xdist_installed: + if config.pluginmanager.has_plugin("xdist"): + # Register the smoke-xdist plugin if -n/--numprocesses option is given. + if smoke.is_xdist_installed and config.getoption("numprocesses", default=None): + config.pluginmanager.register(PytestSmokeXdist(), name=PytestSmokeXdist.name) + else: + smoke.is_xdist_installed = False + + +@pytest.hookimpl(wrapper=True, tryfirst=True) +def pytest_sessionstart(session: Session): + if not smoke.is_xdist_installed or is_xdist_controller(session): + os.environ[SmokeEnvVar.SMOKE_TEST_SESSION_UUID] = str(uuid4()) + return (yield) + @pytest.hookimpl(wrapper=True, trylast=True) -def pytest_collection_modifyitems(config: Config, items: list[Item]): +def pytest_collection_modifyitems(session: Session, config: Config, items: list[Item]): try: return (yield) finally: if not items: return - if n := config.option.smoke or config.option.smoke_last or config.option.smoke_random: + if n := (config.option.smoke or config.option.smoke_last or config.option.smoke_random): if isinstance(n, SmokeDefaultN): # N was not explicitly provided to the option. Apply the INI config value or the plugin default - n = _parse_n(config.getini(SmokeIniOption.SMOKE_DEFAULT_N)) + n = parse_ini_option(config, SmokeIniOption.SMOKE_DEFAULT_N) if is_scale := isinstance(n, str) and n.endswith("%"): num_smoke = float(n[:-1]) else: num_smoke = n - scope = config.option.smoke_scope - if scope is None: - # --scope-smoke option was not explicitly given. Apply the INI config value or the plugin default - scope = _parse_scope(config.getini(SmokeIniOption.SMOKE_DEFAULT_SCOPE)) + scope = get_scope(config) selected_items = [] deselected_items = [] - counter_collected = Counter(filter(None, (_generate_group_id(item, scope) for item in items))) - counter_selected = Counter() + counter = SmokeGroupIDCounter( + collected=Counter(filter(None, (generate_group_id(item, scope) for item in items))) + ) smoke_groups_reached_threshold = set() if config.option.smoke_random: - items_to_filter = random.sample(items, len(items)) + if smoke.is_xdist_installed and (is_xdist_controller(session) or is_xdist_worker(session)): + # Set the seed to ensure XDIST controler and workers collect the same items + random_ = random.Random(UUID(os.environ[SmokeEnvVar.SMOKE_TEST_SESSION_UUID]).time) + else: + random_ = random + items_to_filter = random_.sample(items, len(items)) elif config.option.smoke_last: items_to_filter = items[::-1] else: @@ -151,92 +174,23 @@ def pytest_collection_modifyitems(config: Config, items: list[Item]): selected_items.append(item) continue - group_id = _generate_group_id(item, scope) + group_id = generate_group_id(item, scope) if group_id is None or group_id in smoke_groups_reached_threshold: deselected_items.append(item) continue - if is_scale: - num_tests = counter_collected[group_id] - threshold = scale_down(num_tests, num_smoke) - else: - threshold = num_smoke - - if counter_selected[group_id] < threshold: - counter_selected.update([group_id]) + threshold = scale_down(counter.collected[group_id], num_smoke) if is_scale else num_smoke + if counter.sellected[group_id] < threshold: + counter.sellected.update([group_id]) selected_items.append(item) else: smoke_groups_reached_threshold.add(group_id) deselected_items.append(item) - if len(selected_items) < len(items): + if deselected_items: + config.hook.pytest_deselected(items=deselected_items) if config.option.smoke_random or config.option.smoke_last: # retain the original test order selected_items.sort(key=lambda x: items.index(x)) - items.clear() items.extend(selected_items) - config.hook.pytest_deselected(items=deselected_items) - - -def _parse_n(value: str) -> Union[int, str]: - v = value.strip() - try: - if is_scale := v.endswith("%"): - num = float(v[:-1]) - else: - num = int(v) - - if num < 1 or is_scale and num > 100: - raise ValueError - - if is_scale: - return f"{num}%" - else: - return num - except ValueError: - raise pytest.UsageError( - f"The smoke N value must be a positive number or a valid percentage. '{value}' was given." - ) - - -def _parse_scope(value: str) -> str: - if (v := value.strip()) == "": - raise pytest.UsageError(f"Invalid scope: '{value}'") - return v - - -@lru_cache -def _generate_group_id(item: Item, scope: str) -> Optional[str]: - if item.config.hook.pytest_smoke_exclude(item=item, scope=scope): - return - - if (group_id := item.config.hook.pytest_smoke_generate_group_id(item=item, scope=scope)) is not None: - return group_id - - if scope not in [str(x) for x in SmokeScope]: - raise pytest.UsageError( - f"The logic for the custom scope '{scope}' must be implemented using the " - f"pytest_smoke_generate_group_id hook" - ) - - if scope == SmokeScope.ALL: - return "*" - - cls = getattr(item, "cls", None) - if not cls and scope == SmokeScope.CLASS: - return - - group_id = str(item.path or item.location[0]) - if scope == SmokeScope.FILE: - return group_id - - if cls: - group_id += f"::{cls.__name__}" - if scope in [SmokeScope.CLASS, SmokeScope.AUTO]: - return group_id - - # The default scope - func_name = item.function.__name__ # type: ignore - group_id += f"::{func_name}" - return group_id diff --git a/src/pytest_smoke/types.py b/src/pytest_smoke/types.py new file mode 100644 index 0000000..4b01aaa --- /dev/null +++ b/src/pytest_smoke/types.py @@ -0,0 +1,34 @@ +import sys +from enum import Enum, auto + +if sys.version_info < (3, 11): + + class StrEnum(str, Enum): + def _generate_next_value_(name, start, count, last_values) -> str: + return name.lower() + + def __str__(self) -> str: + return str(self.value) +else: + from enum import StrEnum + + +class SmokeEnvVar(StrEnum): + SMOKE_TEST_SESSION_UUID = auto() + + +class SmokeScope(StrEnum): + FUNCTION = auto() + CLASS = auto() + AUTO = auto() + FILE = auto() + ALL = auto() + + +class SmokeIniOption(StrEnum): + SMOKE_DEFAULT_N = auto() + SMOKE_DEFAULT_SCOPE = auto() + SMOKE_XDIST_DIST_BY_SCOPE = auto() + + +class SmokeDefaultN(int): ... diff --git a/src/pytest_smoke/utils.py b/src/pytest_smoke/utils.py index 0191f2b..89409ec 100644 --- a/src/pytest_smoke/utils.py +++ b/src/pytest_smoke/utils.py @@ -1,18 +1,11 @@ -import sys from decimal import ROUND_HALF_UP, Decimal -from enum import Enum from functools import lru_cache +from typing import Optional, Union -if sys.version_info.major == 3 and sys.version_info.minor < 11: +import pytest +from pytest import Config, Item - class StrEnum(str, Enum): - def _generate_next_value_(name, start, count, last_values) -> str: - return name.lower() - - def __str__(self) -> str: - return str(self.value) -else: - from enum import StrEnum # noqa: F401 +from pytest_smoke.types import SmokeIniOption, SmokeScope @lru_cache @@ -30,5 +23,89 @@ def scale_down(value: float, percentage: float, precision: int = 0, min_value: i return max(val, min_value) +@lru_cache +def generate_group_id(item: Item, scope: str) -> Optional[str]: + assert scope + if item.config.hook.pytest_smoke_exclude(item=item, scope=scope): + return + + if (group_id := item.config.hook.pytest_smoke_generate_group_id(item=item, scope=scope)) is not None: + return group_id + + if scope not in [str(x) for x in SmokeScope]: + raise pytest.UsageError( + f"The logic for the custom scope '{scope}' must be implemented using the " + f"pytest_smoke_generate_group_id hook" + ) + + if scope == SmokeScope.ALL: + return "*" + + cls = getattr(item, "cls", None) + if not cls and scope == SmokeScope.CLASS: + return + + group_id = str(item.path or item.location[0]) + if scope == SmokeScope.FILE: + return group_id + + if cls: + group_id += f"::{cls.__name__}" + if scope in [SmokeScope.CLASS, SmokeScope.AUTO]: + return group_id + + # The default scope + func_name = item.function.__name__ # type: ignore + group_id += f"::{func_name}" + return group_id + + +def parse_n(value: str) -> Union[int, str]: + v = value.strip() + try: + if is_scale := v.endswith("%"): + num = float(v[:-1]) + else: + num = int(v) + + if num < 1 or is_scale and num > 100: + raise ValueError + + if is_scale: + return f"{num}%" + else: + return num + except ValueError: + raise pytest.UsageError( + f"The smoke N value must be a positive number or a valid percentage. '{value}' was given." + ) + + +def parse_scope(value: str) -> str: + if (v := value.strip()) == "": + raise pytest.UsageError(f"Invalid scope: '{value}'") + return v + + +def parse_ini_option(config: Config, option: SmokeIniOption) -> Union[str, int, bool]: + try: + v = config.getini(option) + if option == SmokeIniOption.SMOKE_DEFAULT_N: + return parse_n(v) + elif option == SmokeIniOption.SMOKE_DEFAULT_SCOPE: + return parse_scope(v) + else: + return v + except ValueError as e: + raise pytest.UsageError(f"{option}: {e}") + + +@lru_cache +def get_scope(config: Config) -> str: + scope = config.option.smoke_scope or parse_ini_option(config, SmokeIniOption.SMOKE_DEFAULT_SCOPE) + assert scope + return scope + + def _round_half_up(x: float, precision: int) -> float: return float(Decimal(str(x)).quantize(Decimal("10") ** -precision, rounding=ROUND_HALF_UP)) diff --git a/tests/conftest.py b/tests/conftest.py index 83b611a..320be02 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,14 @@ +import os + import pytest from pytest import Pytester -from pytest_smoke.utils import StrEnum +from pytest_smoke import smoke + +if os.environ.get("IGNORE_XDIST") == "true": + smoke.is_xdist_installed = False + +from pytest_smoke.types import StrEnum from tests.helper import TestClassSpec, TestFileSpec, TestFuncSpec, generate_test_code pytest_plugins = "pytester" diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 147da1a..13a8677 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -6,11 +6,24 @@ import pytest from pytest import ExitCode, Pytester -from pytest_smoke.plugin import SmokeIniOption, SmokeScope +from pytest_smoke import smoke +from pytest_smoke.types import SmokeIniOption, SmokeScope from pytest_smoke.utils import scale_down from tests.helper import TestFileSpec, TestFuncSpec, generate_test_code, get_num_tests, get_num_tests_to_be_selected +if smoke.is_xdist_installed: + from xdist.scheduler import LoadScheduling + + from pytest_smoke.extensions.xdist import SmokeScopeScheduling + + +requires_xdist = pytest.mark.skipif(not smoke.is_xdist_installed, reason="pytest-xdist is required") SMOKE_OPTIONS = ["--smoke", "--smoke-last", "--smoke-random"] +SMOKE_INI_OPTIONS = [ + SmokeIniOption.SMOKE_DEFAULT_N, + SmokeIniOption.SMOKE_DEFAULT_SCOPE, + pytest.param(SmokeIniOption.SMOKE_XDIST_DIST_BY_SCOPE, marks=requires_xdist), +] def test_smoke_command_help(pytester: Pytester): @@ -27,7 +40,9 @@ def test_smoke_command_help(pytester: Pytester): ) + r"\n".join(rf"\s+- {scope}: .+" for scope in SmokeScope) assert re.search(pattern1, stdout, re.DOTALL) - pattern2 = r"\[pytest\] ini-options.+" + r"\n".join(rf" {opt} \([^)]+\):.+" for opt in SmokeIniOption) + pattern2 = r"\[pytest\] ini-options.+" + r"\n".join( + rf" {opt} \([^)]+\):\s+\[pytest-smoke\] .+" for opt in SmokeIniOption + ) assert re.search(pattern2, stdout, re.DOTALL) @@ -152,7 +167,7 @@ def test_smoke_ini_option_smoke_default_n(pytester: Pytester, option: str): pytester.makepyfile(generate_test_code(TestFuncSpec(num_params=num_tests))) pytester.makeini(f""" [pytest] - smoke_default_n = {default_n} + {SmokeIniOption.SMOKE_DEFAULT_N} = {default_n} """) result = pytester.runpytest(option) @@ -170,13 +185,38 @@ def test_smoke_ini_option_smoke_default_scope(pytester: Pytester, option: str): ) pytester.makeini(f""" [pytest] - smoke_default_scope = {SmokeScope.FILE} + {SmokeIniOption.SMOKE_DEFAULT_SCOPE} = {SmokeScope.FILE} """) result = pytester.runpytest(option) assert result.ret == ExitCode.OK result.assert_outcomes(passed=1, deselected=num_tests_1 + num_tests_2 - 1) +@requires_xdist +@pytest.mark.parametrize("option", SMOKE_OPTIONS) +@pytest.mark.parametrize("value", ["true", "false"]) +def test_smoke_ini_option_smoke_xdist_dist_by_scope(pytester: Pytester, option: str, value): + """Test smoke_xdist_dist_by_scope INI option""" + num_tests_1 = 5 + num_tests_2 = 10 + pytester.makepyfile( + generate_test_code(TestFileSpec([TestFuncSpec(num_params=num_tests_1), TestFuncSpec(num_params=num_tests_2)])) + ) + pytester.makeini(f""" + [pytest] + {SmokeIniOption.SMOKE_XDIST_DIST_BY_SCOPE} = {value} + """) + result = pytester.runpytest(option, "2", "-v", "-n", "2") + assert result.ret == ExitCode.OK + result.assert_outcomes(passed=4, deselected=num_tests_1 + num_tests_2 - 4) + if value == "true": + default_scheduler = SmokeScopeScheduling.__name__ + else: + default_scheduler = LoadScheduling.__name__ + result.stdout.re_match_lines(f"scheduling tests via {default_scheduler}") + + +@pytest.mark.filterwarnings("ignore::pluggy.PluggyTeardownRaisedWarning") @pytest.mark.parametrize("n", ["-1", "0", "0.5", "1.1", "foo", " -1%", "0%", "101%", "bar%"]) @pytest.mark.parametrize("option", SMOKE_OPTIONS) def test_smoke_with_invalid_n(pytester: Pytester, option: str, n: str): @@ -208,7 +248,7 @@ def test_smoke_options_are_mutually_exclusive(pytester: Pytester, options: Seque result.stderr.re_match_lines(["ERROR: --smoke, --smoke-last, and --smoke-random options are mutually exclusive"]) -@pytest.mark.parametrize("ini_option", [*SmokeIniOption]) +@pytest.mark.parametrize("ini_option", SMOKE_INI_OPTIONS) @pytest.mark.parametrize("value", ["foo", ""]) @pytest.mark.parametrize("option", SMOKE_OPTIONS) def test_smoke_ini_option_with_invalid_value(pytester: Pytester, option: str, ini_option: str, value: str): @@ -218,5 +258,20 @@ def test_smoke_ini_option_with_invalid_value(pytester: Pytester, option: str, in [pytest] {ini_option} = {value} """) - result = pytester.runpytest(option) + args = [option] + if ini_option == SmokeIniOption.SMOKE_XDIST_DIST_BY_SCOPE: + args.extend(["-n", "2"]) + result = pytester.runpytest(*args) assert result.ret == ExitCode.USAGE_ERROR + + +@requires_xdist +@pytest.mark.parametrize("option", SMOKE_OPTIONS) +def test_smoke_with_xdist_disabled(pytester: Pytester, option: str): + """Test that pytest-smoke does not access the pytest-xdist plugin when it is explicitly disabled""" + num_tests = 10 + pytester.makepyfile(generate_test_code(TestFuncSpec(num_params=num_tests))) + args = [option, "-p", "no:xdist"] + result = pytester.runpytest(*args) + assert result.ret == ExitCode.OK + result.assert_outcomes(passed=1, deselected=num_tests - 1) diff --git a/tox.ini b/tox.ini index 416fa1b..dd61c06 100644 --- a/tox.ini +++ b/tox.ini @@ -1,14 +1,16 @@ [tox] env_list = - linting py{39,310}-pytest{7.0,7.1,7.2,7.3,7.4,8.0,8.1,8.2,8.3} py311-pytest{7.2,7.3,7.4,8.0,8.1,8.2,8.3} py312-pytest{7.3,7.4,8.0,8.1,8.2,8.3} py313-pytest{8.2,8.3} + xdist{2,3} + linting [testenv] package = wheel wheel_build_env = .pkg +extras = test deps = pytest-xdist[psutil] pytest8.3: pytest~=8.3.0 @@ -20,7 +22,14 @@ deps = pytest7.2: pytest~=7.2.0 pytest7.1: pytest~=7.1.0 pytest7.0: pytest~=7.0.0 -commands = pytest tests {posargs:-n auto} + xdist2: pytest-xdist~=2.0 + xdist3: pytest-xdist~=3.0 +set_env = + IGNORE_XDIST=true + xdist{,2,3}: IGNORE_XDIST=false + xdist{,2,3}: _TOX_PYTEST_FILTER_ARGS=-k xdist +commands = + pytest tests {env:_TOX_PYTEST_FILTER_ARGS:} {posargs:-n auto} [testenv:linting] skip_install = True