Skip to content

Commit d628253

Browse files
Kostiantyn GoloveshkoKostiantyn Goloveshko
Kostiantyn Goloveshko
authored and
Kostiantyn Goloveshko
committed
Allow explicit free variables control at Examples and steps
1 parent 8523790 commit d628253

File tree

6 files changed

+246
-14
lines changed

6 files changed

+246
-14
lines changed

CHANGES.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
Changelog
22
=========
33

4+
Unreleased
5+
-----------
6+
- Add options to control free variables in Examples and step definitions
7+
8+
49
5.0.0
510
-----
611
This release introduces breaking changes, please refer to the :ref:`Migration from 4.x.x`.

README.rst

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -696,6 +696,46 @@ With a parametrized.feature file:
696696
697697
698698
The significant downside of this approach is inability to see the test table from the feature file.
699+
It's possible to disallow steps free parameters substitution from fixtures (so test case will fail):
700+
701+
.. code-block:: python
702+
@pytest.mark.parametrize(
703+
["start", "eat", "left"],
704+
[(12, 5, 7)],
705+
)
706+
@scenario(
707+
"parametrized.feature",
708+
"Parametrized given, when, thens",
709+
allow_step_free_variables=False,
710+
)
711+
def test_parametrized(start, eat, left):
712+
"""We don't need to do anything here, everything will be managed by the scenario decorator."""
713+
714+
Sometimes you want leave a column not used in steps for specific reason in examples section:
715+
716+
.. code-block:: gherkin
717+
Feature: Scenario outlines
718+
Scenario Outline: Outlined given, when, thens
719+
Given there are <start> cucumbers
720+
When I eat <eat> cucumbers
721+
Then I should have <left> cucumbers
722+
723+
Examples:
724+
| start | eat | left | comment |
725+
| 12 | 5 | 7 | sweet cucumbers!|
726+
727+
728+
.. code-block:: python
729+
from pytest_bdd import given, when, then, scenario
730+
731+
732+
@scenario(
733+
"outline.feature",
734+
"Outlined given, when, thens",
735+
allow_example_free_variables=True,
736+
)
737+
def test_outlined():
738+
pass
699739
700740
701741
Organizing your scenarios

pytest_bdd/parser.py

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,9 @@ def __init__(self, feature: Feature, name: str, line_number: int, tags=None):
232232
self.line_number = line_number
233233
self.tags = tags or set()
234234

235+
self.allow_example_free_variables = None
236+
self.allow_step_free_variables = None
237+
235238
def add_step(self, step):
236239
"""Add step to the scenario.
237240
@@ -258,18 +261,57 @@ def render(self, context: typing.Mapping[str, typing.Any]) -> "Scenario":
258261
]
259262
return Scenario(feature=self.feature, name=self.name, line_number=self.line_number, steps=steps, tags=self.tags)
260263

264+
@property
265+
def params(self):
266+
return frozenset(sum((list(step.params) for step in self.steps), []))
267+
268+
def get_example_params(self):
269+
return set(self.examples.example_params + self.feature.examples.example_params)
270+
261271
def validate(self):
262272
"""Validate the scenario.
263273
264274
:raises ScenarioValidationError: when scenario is not valid
265275
"""
266-
params = frozenset(sum((list(step.params) for step in self.steps), []))
267-
example_params = set(self.examples.example_params + self.feature.examples.example_params)
268-
if params and example_params and params.issubset(example_params):
276+
if self.params or self.get_example_params():
277+
self._validate_example_free_variables()
278+
self._validate_step_free_variables()
279+
280+
def _validate_example_free_variables(self):
281+
params = self.params
282+
example_params = self.get_example_params()
283+
if self.allow_example_free_variables or example_params.issubset(params):
284+
return
285+
else:
286+
raise exceptions.ScenarioExamplesNotValidError(
287+
(
288+
"""Scenario "{}" in the feature "{}" does not have valid examples. """
289+
"""Set of example parameters {} should be a subset of step """
290+
"""parameters {} if examples free variables are not allowed"""
291+
).format(
292+
self.name,
293+
self.feature.filename,
294+
sorted(example_params),
295+
sorted(params),
296+
)
297+
)
298+
299+
def _validate_step_free_variables(self):
300+
params = self.params
301+
example_params = self.get_example_params()
302+
if self.allow_step_free_variables or params.issubset(example_params):
303+
return
304+
else:
269305
raise exceptions.ScenarioExamplesNotValidError(
270-
"""Scenario "{}" in the feature "{}" does not have valid examples. """
271-
"""Set of step parameters {} should be a subset of example values {}.""".format(
272-
self.name, self.feature.filename, sorted(params), sorted(example_params)
306+
(
307+
"""Scenario "{}" in the feature "{}" does not have valid examples. """
308+
"""Set of step parameters {} should be a subset of example """
309+
"""parameters {} if steps free variables are not allowed"""
310+
).format(
311+
self.name,
312+
self.feature.filename,
313+
sorted(params),
314+
sorted(example_params),
273315
)
274316
)
275317

pytest_bdd/scenario.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -215,11 +215,21 @@ def collect_example_parametrizations(
215215
return [pytest.param(context, id="-".join(context.values())) for context in contexts]
216216

217217

218-
def scenario(feature_name: str, scenario_name: str, encoding: str = "utf-8", features_base_dir=None):
218+
def scenario(
219+
feature_name: str,
220+
scenario_name: str,
221+
*,
222+
allow_example_free_variables=False,
223+
allow_step_free_variables=True,
224+
encoding: str = "utf-8",
225+
features_base_dir=None,
226+
):
219227
"""Scenario decorator.
220228
221229
:param str feature_name: Feature file name. Absolute or relative to the configured feature base path.
222230
:param str scenario_name: Scenario name.
231+
:param allow_example_free_variables: Examples could contain free(unused) variables
232+
:param allow_step_free_variables: Steps could contain free(unused) variables which could be taken from fixtures
223233
:param str encoding: Feature file encoding.
224234
"""
225235

@@ -234,6 +244,9 @@ def scenario(feature_name: str, scenario_name: str, encoding: str = "utf-8", fea
234244
# Get the scenario
235245
try:
236246
scenario = feature.scenarios[scenario_name]
247+
scenario.allow_example_free_variables = allow_example_free_variables
248+
scenario.allow_step_free_variables = allow_step_free_variables
249+
237250
except KeyError:
238251
feature_name = feature.name or "[Empty]"
239252
raise exceptions.ScenarioNotFound(
@@ -294,10 +307,12 @@ def get_name():
294307
suffix = f"_{index}"
295308

296309

297-
def scenarios(*feature_paths, **kwargs):
310+
def scenarios(*feature_paths, allow_example_free_variables=False, allow_step_free_variables=True, **kwargs):
298311
"""Parse features from the paths and put all found scenarios in the caller module.
299312
300313
:param *feature_paths: feature file paths to use for scenarios
314+
:param allow_example_free_variables: Examples could contain free(unused) variables
315+
:param allow_step_free_variables: Steps could contain free(unused) variables which could be taken from fixtures
301316
"""
302317
caller_locals = get_caller_module_locals()
303318
caller_path = get_caller_module_path()
@@ -324,7 +339,13 @@ def scenarios(*feature_paths, **kwargs):
324339
# skip already bound scenarios
325340
if (scenario_object.feature.filename, scenario_name) not in module_scenarios:
326341

327-
@scenario(feature.filename, scenario_name, **kwargs)
342+
@scenario(
343+
feature.filename,
344+
scenario_name,
345+
allow_example_free_variables=allow_example_free_variables,
346+
allow_step_free_variables=allow_step_free_variables,
347+
**kwargs,
348+
)
328349
def _scenario():
329350
pass # pragma: no cover
330351

tests/feature/test_outline.py

Lines changed: 58 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,54 @@ def test_outline(request):
8181
# fmt: on
8282

8383

84+
def test_disallow_free_example_params(testdir):
85+
"""Test parametrized scenario when the test function lacks parameters."""
86+
87+
testdir.makefile(
88+
".feature",
89+
outline=textwrap.dedent(
90+
"""\
91+
Feature: Outline
92+
Scenario Outline: Outlined with wrong examples
93+
Given there are <start> cucumbers
94+
When I eat <eat> cucumbers
95+
Then I should have <left> cucumbers
96+
97+
Examples:
98+
| start | eat | left | unknown_param |
99+
| 12 | 5 | 7 | value |
100+
101+
"""
102+
),
103+
)
104+
testdir.makeconftest(textwrap.dedent(STEPS))
105+
106+
testdir.makepyfile(
107+
textwrap.dedent(
108+
"""\
109+
from pytest_bdd import scenario
110+
111+
@scenario(
112+
"outline.feature",
113+
"Outlined with wrong examples",
114+
allow_example_free_variables=False
115+
)
116+
def test_outline(request):
117+
pass
118+
"""
119+
)
120+
)
121+
result = testdir.runpytest()
122+
assert_outcomes(result, errors=1)
123+
result.stdout.fnmatch_lines(
124+
'*ScenarioExamplesNotValidError: Scenario "Outlined with wrong examples"*does not have valid examples*'
125+
)
126+
result.stdout.fnmatch_lines(
127+
"*Set of example parameters [[]'eat', 'left', 'start', 'unknown_param'[]] should be "
128+
"a subset of step parameters [[]'eat', 'left', 'start'[]]*"
129+
)
130+
131+
84132
def test_outline_has_subset_of_parameters(testdir):
85133
"""Test parametrized scenario when the test function has a subset of the parameters of the examples."""
86134

@@ -108,8 +156,11 @@ def test_outline_has_subset_of_parameters(testdir):
108156
"""\
109157
from pytest_bdd import scenario
110158
111-
@scenario("outline.feature", "Outlined with subset of examples",
112-
example_converters=dict(start=int, eat=float, left=str))
159+
@scenario(
160+
"outline.feature", "
161+
Outlined with subset of examples",
162+
allow_example_free_variables=True
163+
)
113164
def test_outline(request):
114165
pass
115166
"""
@@ -120,7 +171,8 @@ def test_outline(request):
120171

121172

122173
def test_wrongly_outlined_parameters_not_a_subset_of_examples(testdir):
123-
"""Test parametrized scenario when the test function has a parameter set which is not a subset of those in the examples table."""
174+
"""Test parametrized scenario when the test function has a parameter set
175+
which is not a subset of those in the examples table."""
124176

125177
testdir.makefile(
126178
".feature",
@@ -145,8 +197,9 @@ def test_wrongly_outlined_parameters_not_a_subset_of_examples(testdir):
145197
textwrap.dedent(
146198
"""\
147199
from pytest_bdd import scenario, then
200+
import pytest_bdd.parsers as parsers
148201
149-
@scenario("outline.feature", "Outlined with wrong examples")
202+
@scenario("outline.feature", "Outlined with wrong examples", allow_step_free_variables=False)
150203
def test_outline(request):
151204
pass
152205
@@ -161,7 +214,7 @@ def stepdef(left, right):
161214
result.stdout.fnmatch_lines(
162215
'*ScenarioExamplesNotValidError: Scenario "Outlined with wrong examples"*does not have valid examples*',
163216
)
164-
result.stdout.fnmatch_lines("*should be a subset of example values [[]'eat', 'left', 'start'[]].*")
217+
result.stdout.fnmatch_lines("*should be a subset of example parameters [[]'eat', 'left', 'start'[]]*")
165218

166219

167220
def test_wrong_vertical_examples_scenario(testdir):

tests/feature/test_parametrized.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,3 +77,74 @@ def should_have_left_cucumbers(start_cucumbers, start, eat, left):
7777
2, 1, 1,
7878
]
7979
# fmt: on
80+
81+
82+
def test_outlining_using_fixtures(testdir):
83+
"""Test parametrized scenario."""
84+
testdir.makefile(
85+
".feature",
86+
parametrized=textwrap.dedent(
87+
"""\
88+
Feature: Parametrized scenario
89+
Scenario: Parametrized given, when, thens
90+
Given there are <start> cucumbers
91+
When I eat <eat> cucumbers
92+
Then I should have <left> cucumbers
93+
"""
94+
),
95+
)
96+
97+
testdir.makepyfile(
98+
textwrap.dedent(
99+
"""\
100+
import pytest
101+
from pytest_bdd import given, when, then, scenario
102+
103+
@pytest.fixture
104+
def start():
105+
return 12
106+
107+
@pytest.fixture
108+
def eat():
109+
return 5
110+
111+
@pytest.fixture
112+
def left():
113+
return 7
114+
115+
116+
@pytest.fixture(params=[1, 2])
117+
def foo_bar(request):
118+
return "bar" * request.param
119+
120+
121+
@scenario("parametrized.feature", "Parametrized given, when, thens")
122+
def test_parametrized(request, start, eat, left):
123+
pass
124+
125+
126+
@scenario("parametrized.feature", "Parametrized given, when, thens")
127+
def test_parametrized_with_other_fixtures(request, start, eat, left, foo_bar):
128+
pass
129+
130+
@given("there are <start> cucumbers", target_fixture="start_cucumbers")
131+
def start_cucumbers(start):
132+
return dict(start=start)
133+
134+
135+
@when("I eat <eat> cucumbers")
136+
def eat_cucumbers(start_cucumbers, start, eat):
137+
start_cucumbers["eat"] = eat
138+
139+
140+
@then("I should have <left> cucumbers")
141+
def should_have_left_cucumbers(start_cucumbers, start, eat, left):
142+
assert start - eat == left
143+
assert start_cucumbers["start"] == start
144+
assert start_cucumbers["eat"] == eat
145+
146+
"""
147+
)
148+
)
149+
result = testdir.runpytest()
150+
result.assert_outcomes(passed=3)

0 commit comments

Comments
 (0)