From d1ff34a69912eeec5221fc752e9b5d9d2c93f591 Mon Sep 17 00:00:00 2001 From: Konstantin Goloveshko Date: Wed, 14 Jul 2021 13:32:45 +0300 Subject: [PATCH 1/2] Allows use in parsers defined steps So parsers could parse values defined in Examples --- pytest_bdd/parser.py | 4 +- pytest_bdd/scenario.py | 72 ++++++++++++----- tests/feature/test_outline.py | 124 ++++++++++++++++++++++++----- tests/feature/test_parametrized.py | 51 ++++++++++++ 4 files changed, 209 insertions(+), 42 deletions(-) diff --git a/pytest_bdd/parser.py b/pytest_bdd/parser.py index 25d54904c..344c181a5 100644 --- a/pytest_bdd/parser.py +++ b/pytest_bdd/parser.py @@ -1,4 +1,3 @@ -import io import os.path import re import textwrap @@ -457,4 +456,5 @@ def get_tags(line): return {tag.lstrip("@") for tag in line.strip().split(" @") if len(tag) > 1} -STEP_PARAM_RE = re.compile(r"\<(.+?)\>") +STEP_PARAM_TEMPLATE = "<{param}>" +STEP_PARAM_RE = re.compile(STEP_PARAM_TEMPLATE.format(param="((?<=<)[^<>]+(?=>))")) diff --git a/pytest_bdd/scenario.py b/pytest_bdd/scenario.py index 5a8729d53..c8bf75f08 100644 --- a/pytest_bdd/scenario.py +++ b/pytest_bdd/scenario.py @@ -10,15 +10,16 @@ scenario_name="Publishing the article", ) """ -import collections import os import re +from itertools import chain, product import pytest from _pytest.fixtures import FixtureLookupError from . import exceptions from .feature import get_feature, get_features +from .parser import STEP_PARAM_RE, STEP_PARAM_TEMPLATE from .steps import get_step_fixture_name, inject_fixture from .utils import CONFIG_STACK, get_args, get_caller_module_locals, get_caller_module_path @@ -26,31 +27,61 @@ ALPHA_REGEX = re.compile(r"^\d+_*") +def generate_partial_substituted_step_parameters(name: str, request): + """\ + Returns step name with substituted parameters from fixtures, example tables, param marks starting + from most substituted to less, so giving chance to most specific parser + """ + matches = re.finditer(STEP_PARAM_RE, name) + for match in matches: + param_name = match.group(1) + try: + sub_name = re.sub( + STEP_PARAM_TEMPLATE.format(param=re.escape(param_name)), + str(request.getfixturevalue(param_name)), + name, + count=1, + ) + except FixtureLookupError: + continue + else: + yield from generate_partial_substituted_step_parameters(sub_name, request) + yield name + + def find_argumented_step_fixture_name(name, type_, fixturemanager, request=None): """Find argumented step fixture name.""" # happens to be that _arg2fixturedefs is changed during the iteration so we use a copy - for fixturename, fixturedefs in list(fixturemanager._arg2fixturedefs.items()): - for fixturedef in fixturedefs: - parser = getattr(fixturedef.func, "parser", None) - if parser is None: - continue - match = parser.is_matching(name) - if not match: - continue - - converters = getattr(fixturedef.func, "converters", {}) - for arg, value in parser.parse_arguments(name).items(): + + fixturedefs = chain.from_iterable(fixturedefs for _, fixturedefs in list(fixturemanager._arg2fixturedefs.items())) + fixturedef_funcs = (fixturedef.func for fixturedef in fixturedefs) + parsers_fixturedef_function_mappings = ( + (fixturedef_func.parser, fixturedef_func) + for fixturedef_func in fixturedef_funcs + if hasattr(fixturedef_func, "parser") + ) + + matched_steps_with_parsers = ( + (step_name, parser, getattr(fixturedef_function, "converters", {})) + for step_name, (parser, fixturedef_function) in product( + generate_partial_substituted_step_parameters(name, request), parsers_fixturedef_function_mappings + ) + if parser.is_matching(step_name) + ) + + for step_name, parser, converters in matched_steps_with_parsers: + if request: + for arg, value in parser.parse_arguments(step_name).items(): if arg in converters: value = converters[arg](value) - if request: - inject_fixture(request, arg, value) - parser_name = get_step_fixture_name(parser.name, type_) - if request: try: - request.getfixturevalue(parser_name) + overridable_fixture_value = request.getfixturevalue(arg) except FixtureLookupError: - continue - return parser_name + inject_fixture(request, arg, value) + else: + if overridable_fixture_value != value: + inject_fixture(request, arg, value) + return get_step_fixture_name(parser.name, type_) def _find_step_function(request, step, scenario): @@ -138,9 +169,6 @@ def _execute_scenario(feature, scenario, request): request.config.hook.pytest_bdd_after_scenario(request=request, feature=feature, scenario=scenario) -FakeRequest = collections.namedtuple("FakeRequest", ["module"]) - - def _get_scenario_decorator(feature, feature_name, scenario, scenario_name): # HACK: Ideally we would use `def decorator(fn)`, but we want to return a custom exception # when the decorator is misused. diff --git a/tests/feature/test_outline.py b/tests/feature/test_outline.py index 92a1d6d58..802bc405b 100644 --- a/tests/feature/test_outline.py +++ b/tests/feature/test_outline.py @@ -1,25 +1,28 @@ """Scenario Outline tests.""" import textwrap +from pytest import mark + from tests.utils import assert_outcomes -STEPS = """\ +FLOAT_NUMBER_PATTERN = r"[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?" +STEPS_TEMPLATE = """\ from pytest_bdd import given, when, then +from pytest_bdd.parsers import re - -@given("there are cucumbers", target_fixture="start_cucumbers") +{given_decorator_definition} def start_cucumbers(start): assert isinstance(start, int) return dict(start=start) -@when("I eat cucumbers") +{when_decorator_definition} def eat_cucumbers(start_cucumbers, eat): assert isinstance(eat, float) start_cucumbers["eat"] = eat -@then("I should have cucumbers") +{then_decorator_definition} def should_have_left_cucumbers(start_cucumbers, start, eat, left): assert isinstance(left, str) assert start - eat == int(left) @@ -29,7 +32,31 @@ def should_have_left_cucumbers(start_cucumbers, start, eat, left): """ -def test_outlined(testdir): +STRING_STEPS = STEPS_TEMPLATE.format( + given_decorator_definition='@given("there are cucumbers", target_fixture="start_cucumbers")', + when_decorator_definition='@when("I eat cucumbers")', + then_decorator_definition='@then("I should have cucumbers")', +) + +PARSER_STEPS = STEPS_TEMPLATE.format( + given_decorator_definition=f'@given(re("there are (?P{FLOAT_NUMBER_PATTERN}) cucumbers"), ' + f'target_fixture="start_cucumbers")', + when_decorator_definition=f'@when(re("I eat (?P{FLOAT_NUMBER_PATTERN}) cucumbers"))', + then_decorator_definition=f'@then(re("I should have (?P{FLOAT_NUMBER_PATTERN}) cucumbers"))', +) + +PARSER_STEPS_CONVERTED = STEPS_TEMPLATE.format( + given_decorator_definition=f'@given(re("there are (?P{FLOAT_NUMBER_PATTERN}) cucumbers"), ' + f'target_fixture="start_cucumbers", converters=dict(start=int))', + when_decorator_definition=f'@when(re("I eat (?P{FLOAT_NUMBER_PATTERN}) cucumbers"), ' + f"converters=dict(eat=float))", + then_decorator_definition=f'@then(re("I should have (?P{FLOAT_NUMBER_PATTERN}) cucumbers"), ' + f"converters=dict(left=str))", +) + + +@mark.parametrize("steps", [STRING_STEPS, PARSER_STEPS_CONVERTED]) +def test_outlined(testdir, steps): testdir.makefile( ".feature", outline=textwrap.dedent( @@ -49,7 +76,7 @@ def test_outlined(testdir): ), ) - testdir.makeconftest(textwrap.dedent(STEPS)) + testdir.makeconftest(textwrap.dedent(steps)) testdir.makepyfile( textwrap.dedent( @@ -78,8 +105,9 @@ def test_outline(request): result.assert_outcomes(passed=2) -def test_wrongly_outlined(testdir): - """Test parametrized scenario when the test function lacks parameters.""" +@mark.parametrize("steps", [STRING_STEPS, PARSER_STEPS_CONVERTED]) +def test_outline_has_subset_of_parameters(testdir, steps): + """Test parametrized scenario when the test function has a subset of the parameters of the examples.""" testdir.makefile( ".feature", @@ -98,7 +126,7 @@ def test_wrongly_outlined(testdir): """ ), ) - testdir.makeconftest(textwrap.dedent(STEPS)) + testdir.makeconftest(textwrap.dedent(steps)) testdir.makepyfile( textwrap.dedent( @@ -119,7 +147,8 @@ def test_outline(request): result.stdout.fnmatch_lines("*should match set of example values [[]'eat', 'left', 'start', 'unknown_param'[]].*") -def test_wrong_vertical_examples_scenario(testdir): +@mark.parametrize("steps", [STRING_STEPS, PARSER_STEPS]) +def test_wrong_vertical_examples_scenario(testdir, steps): """Test parametrized scenario vertical example table has wrong format.""" testdir.makefile( ".feature", @@ -138,7 +167,7 @@ def test_wrong_vertical_examples_scenario(testdir): """ ), ) - testdir.makeconftest(textwrap.dedent(STEPS)) + testdir.makeconftest(textwrap.dedent(steps)) testdir.makepyfile( textwrap.dedent( @@ -159,7 +188,8 @@ def test_outline(request): ) -def test_wrong_vertical_examples_feature(testdir): +@mark.parametrize("steps", [STRING_STEPS, PARSER_STEPS]) +def test_wrong_vertical_examples_feature(testdir, steps): """Test parametrized feature vertical example table has wrong format.""" testdir.makefile( ".feature", @@ -179,7 +209,7 @@ def test_wrong_vertical_examples_feature(testdir): """ ), ) - testdir.makeconftest(textwrap.dedent(STEPS)) + testdir.makeconftest(textwrap.dedent(steps)) testdir.makepyfile( textwrap.dedent( @@ -200,7 +230,8 @@ def test_outline(request): ) -def test_outlined_with_other_fixtures(testdir): +@mark.parametrize("steps", [STRING_STEPS, PARSER_STEPS_CONVERTED]) +def test_outlined_with_other_fixtures(testdir, steps): """Test outlined scenario also using other parametrized fixture.""" testdir.makefile( ".feature", @@ -221,7 +252,7 @@ def test_outlined_with_other_fixtures(testdir): ), ) - testdir.makeconftest(textwrap.dedent(STEPS)) + testdir.makeconftest(textwrap.dedent(steps)) testdir.makepyfile( textwrap.dedent( @@ -251,7 +282,8 @@ def test_outline(other_fixture): result.assert_outcomes(passed=6) -def test_vertical_example(testdir): +@mark.parametrize("steps", [STRING_STEPS, PARSER_STEPS_CONVERTED]) +def test_vertical_example(testdir, steps): """Test outlined scenario with vertical examples table.""" testdir.makefile( ".feature", @@ -272,7 +304,7 @@ def test_vertical_example(testdir): ), ) - testdir.makeconftest(textwrap.dedent(STEPS)) + testdir.makeconftest(textwrap.dedent(steps)) testdir.makepyfile( textwrap.dedent( @@ -301,6 +333,62 @@ def test_outline(request): result.assert_outcomes(passed=2) +def test_outlined_paramaters_parsed_indirectly(testdir): + testdir.makefile( + ".feature", + outline=textwrap.dedent( + """\ + Feature: Outline + + Examples: + | first | consume | remaining | + | 12 | 5 | 7 | + | 5 | 4 | 1 | + + Scenario Outline: Outlined modern given, when, thens + Given there were + When I ate + Then I should have had + + Examples: + | foods | + | ice-creams | + | almonds | + """ + ), + ) + + testdir.makepyfile( + textwrap.dedent( + """\ + from pytest_bdd import scenarios, given, when, then + from pytest_bdd.parsers import parse + + @given(parse('there were {start:d} {fruits}'), target_fixture='context') + def started_fruits(start, fruits): + assert isinstance(start, int) + return {fruits: dict(start=start)} + + @when(parse('I ate {eat:g} {fruits}')) + def ate_fruits(start, eat, fruits, context): + assert isinstance(eat, float) + context[fruits]['eat'] = eat + + @then(parse('I should have had {left} {fruits}')) + def should_have_had_left_fruits(start, eat, left, fruits, context): + assert isinstance(left, str) + assert start - eat == int(left) + assert context[fruits]['start'] == start + assert context[fruits]['eat'] == eat + + scenarios('outline.feature') + """ + ) + ) + result = testdir.runpytest() + result.assert_outcomes(passed=4) + + def test_outlined_feature(testdir): testdir.makefile( ".feature", diff --git a/tests/feature/test_parametrized.py b/tests/feature/test_parametrized.py index d650f4f6b..41744a5cc 100644 --- a/tests/feature/test_parametrized.py +++ b/tests/feature/test_parametrized.py @@ -59,3 +59,54 @@ def should_have_left_cucumbers(start_cucumbers, start, eat, left): ) result = testdir.runpytest() result.assert_outcomes(passed=3) + + +def test_parametrized_with_parsers(testdir): + """Test parametrized scenario.""" + testdir.makefile( + ".feature", + parametrized=textwrap.dedent( + """\ + Feature: Parametrized scenario + Scenario: Parametrized given, when, thens with parsers invocation + Given there are gherkins + When I eat gherkins + Then I should have gherkins + """ + ), + ) + + testdir.makepyfile( + textwrap.dedent( + """\ + import pytest + from pytest_bdd import given, when, then, scenario + from pytest_bdd.parsers import re, parse + + @pytest.mark.parametrize(["start", "eat", "left"], [(12, 5, 7)]) + @scenario("parametrized.feature", "Parametrized given, when, thens with parsers invocation") + def test_parametrized(request, start, eat, left): + pass + + + @given(re("there are (?P\\\\w+)"), target_fixture="start_vegetables") + def start_vegetables(start, vegetables): + return dict(start=start) + + + @when("I eat gherkins") + def eat_cucumbers(start_vegetables, start, eat): + start_vegetables["eat"] = eat + + + @then(re("I should have (?P\\\\d+) (?P\\\\w+)"), converters=dict(left=int)) + def should_have_left_vegetables(start_vegetables, start, eat, left, vegetables): + assert start - eat == left + assert start_vegetables["start"] == start + assert start_vegetables["eat"] == eat + + """ + ) + ) + result = testdir.runpytest() + result.assert_outcomes(passed=1) From 231b108fe86f39471a1a19d55734fd8c0a1cb129 Mon Sep 17 00:00:00 2001 From: Kostiantyn Goloveshko Date: Thu, 12 Aug 2021 00:42:21 +0300 Subject: [PATCH 2/2] More tests for in parsers defined steps --- tests/feature/test_outline.py | 40 +++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/feature/test_outline.py b/tests/feature/test_outline.py index 802bc405b..2d0e083dc 100644 --- a/tests/feature/test_outline.py +++ b/tests/feature/test_outline.py @@ -389,6 +389,46 @@ def should_have_had_left_fruits(start, eat, left, fruits, context): result.assert_outcomes(passed=4) +def test_unsubstitutable_indirect_parameters(testdir): + testdir.makefile( + ".feature", + unsubstitutable=textwrap.dedent( + """\ + Feature: + + Scenario Outline: Unsubstitutable parameter + Given there is parameter + + Examples: + | substitutable | + | no | + | | + """ + ), + ) + + testdir.makepyfile( + textwrap.dedent( + """\ + from pytest_bdd import scenarios, given, when, then + from pytest_bdd.parsers import parse, re + + @given(parse('there is <{is_substitutable}> parameter')) + def _(is_substitutable): + assert is_substitutable == "yes" + + @given(re('there is (?P[^<].*[^>]) parameter')) + def _(is_substitutable): + assert is_substitutable == "no" + + scenarios('unsubstitutable.feature') + """ + ) + ) + result = testdir.runpytest() + result.assert_outcomes(passed=2) + + def test_outlined_feature(testdir): testdir.makefile( ".feature",