Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
Changelog
=========

Unreleased
-----------
- Add options to control free variables in Examples and step definitions


5.0.0
-----
This release introduces breaking changes, please refer to the :ref:`Migration from 4.x.x`.
Expand Down
78 changes: 78 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -696,7 +696,85 @@ With a parametrized.feature file:


The significant downside of this approach is inability to see the test table from the feature file.
It's possible to disallow steps free parameters substitution from fixtures (so test case will fail):

.. code-block:: python

@pytest.mark.parametrize(
["start", "eat", "left"],
[(12, 5, 7)],
)
@scenario(
"parametrized.feature",
"Parametrized given, when, thens",
allow_step_free_variables=False,
)
def test_parametrized(start, eat, left):
"""We don't need to do anything here, everything will be managed by the scenario decorator."""

Sometimes you want leave a column not used in steps for specific reason in examples section:

.. code-block:: gherkin

Feature: Scenario outlines
Scenario Outline: Outlined given, when, thens
Given there are <start> cucumbers
When I eat <eat> cucumbers
Then I should have <left> cucumbers

Examples:
| start | eat | left | comment |
| 12 | 5 | 7 | sweet cucumbers!|


.. code-block:: python

from pytest_bdd import given, when, then, scenario


@scenario(
"outline.feature",
"Outlined given, when, thens",
allow_example_free_variables=True,
)
def test_outlined():
pass

Or leave some parameter as is without substitution:

.. code-block:: gherkin

Feature: Outline
Scenario Outline: Outlined with wrong examples
Given there are <start> cucumbers
When I eat <eat> cucumbers
Then I should have <left> cucumbers in my <right> bucket

Examples:
| start | eat | left |
| 12 | 5 | 7 |

.. code-block:: python

@scenario(
"outline.feature",
"Outlined with wrong examples",
allow_step_free_variables=False
)
def test_outline(request):
pass

@then(parsers.parse('I should have {left} cucumbers in my <right> bucket'))
def stepdef(left):
pass


Also you could grant such possibility for whole session using pytest.ini (or any other same config)

.. code-block:: ini

[pytest]
bdd_allow_step_free_variables=false

Organizing your scenarios
-------------------------
Expand Down
60 changes: 53 additions & 7 deletions pytest_bdd/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,9 @@ def __init__(self, feature: Feature, name: str, line_number: int, tags=None):
self.line_number = line_number
self.tags = tags or set()

self.allow_example_free_variables = None
self.allow_step_free_variables = None

def add_step(self, step):
"""Add step to the scenario.

Expand All @@ -258,18 +261,57 @@ def render(self, context: typing.Mapping[str, typing.Any]) -> "Scenario":
]
return Scenario(feature=self.feature, name=self.name, line_number=self.line_number, steps=steps, tags=self.tags)

@property
def params(self):
return frozenset(sum((list(step.params) for step in self.steps), []))

def get_example_params(self):
return set(self.examples.example_params + self.feature.examples.example_params)

def validate(self):
"""Validate the scenario.

:raises ScenarioValidationError: when scenario is not valid
"""
params = frozenset(sum((list(step.params) for step in self.steps), []))
example_params = set(self.examples.example_params + self.feature.examples.example_params)
if params and example_params and params != example_params:
if self.params or self.get_example_params():
self._validate_example_free_variables()
self._validate_step_free_variables()

def _validate_example_free_variables(self):
params = self.params
example_params = self.get_example_params()
if self.allow_example_free_variables or example_params.issubset(params):
return
else:
raise exceptions.ScenarioExamplesNotValidError(
"""Scenario "{}" in the feature "{}" has not valid examples. """
"""Set of step parameters {} should match set of example values {}.""".format(
self.name, self.feature.filename, sorted(params), sorted(example_params)
(
"""Scenario "{}" in the feature "{}" does not have valid examples. """
"""Set of example parameters {} should be a subset of step """
"""parameters {} if examples free variables are not allowed"""
).format(
self.name,
self.feature.filename,
sorted(example_params),
sorted(params),
)
)

def _validate_step_free_variables(self):
params = self.params
example_params = self.get_example_params()
if self.allow_step_free_variables or params.issubset(example_params):
return
else:
raise exceptions.ScenarioExamplesNotValidError(
(
"""Scenario "{}" in the feature "{}" does not have valid examples. """
"""Set of step parameters {} should be a subset of example """
"""parameters {} if steps free variables are not allowed"""
).format(
self.name,
self.feature.filename,
sorted(params),
sorted(example_params),
)
)

Expand Down Expand Up @@ -359,7 +401,11 @@ def params(self):
def render(self, context: typing.Mapping[str, typing.Any]):
def replacer(m: typing.Match):
varname = m.group(1)
return str(context[varname])
try:
return str(context[varname])
except KeyError:
# Unavailability of varname is handled by bdd_allow_step_free_variables ini option
return f"<{varname}>"

return STEP_PARAM_RE.sub(replacer, self.name)

Expand Down
14 changes: 13 additions & 1 deletion pytest_bdd/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,19 @@ def pytest_addoption(parser):


def add_bdd_ini(parser):
parser.addini("bdd_features_base_dir", "Base features directory.")
parser.addini(name="bdd_features_base_dir", help="Base features directory.")
parser.addini(
name="bdd_allow_step_free_variables",
help="Allow use <parameters> not defined in examples. They will be skipped during parametrization",
type="bool",
default=False,
)
parser.addini(
name="bdd_allow_example_free_variables",
help="Allow use <parameters> not defined in steps. They will be skipped during parametrization",
type="bool",
default=True,
)


@pytest.mark.trylast
Expand Down
48 changes: 39 additions & 9 deletions pytest_bdd/scenario.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@
import collections
import os
import re
import sys
import typing
from pathlib import Path

import pytest
from _pytest.fixtures import FixtureLookupError
Expand Down Expand Up @@ -215,12 +217,23 @@ def collect_example_parametrizations(
return [pytest.param(context, id="-".join(context.values())) for context in contexts]


def scenario(feature_name: str, scenario_name: str, encoding: str = "utf-8", features_base_dir=None):
def scenario(
feature_name: str,
scenario_name: str,
*,
allow_example_free_variables: typing.Optional[typing.Any] = None,
allow_step_free_variables: typing.Optional[typing.Any] = None,
encoding: str = "utf-8",
features_base_dir: typing.Optional[typing.Union[str, Path]] = None,
):
"""Scenario decorator.

:param str feature_name: Feature file name. Absolute or relative to the configured feature base path.
:param str scenario_name: Scenario name.
:param str encoding: Feature file encoding.
:param feature_name: Feature file name. Absolute or relative to the configured feature base path.
:param scenario_name: Scenario name.
:param allow_example_free_variables: Examples could contain free(unused) variables
:param allow_step_free_variables: Steps could contain free(unused) variables which could be taken from fixtures
:param encoding: Feature file encoding.
:param features_base_dir: Base directory to build features path
"""

scenario_name = str(scenario_name)
Expand All @@ -229,11 +242,22 @@ def scenario(feature_name: str, scenario_name: str, encoding: str = "utf-8", fea
# Get the feature
if features_base_dir is None:
features_base_dir = get_features_base_dir(caller_module_path)
feature = get_feature(features_base_dir, feature_name, encoding=encoding)
feature = get_feature(str(features_base_dir), feature_name, encoding=encoding)

# Get the scenario
try:
scenario = feature.scenarios[scenario_name]
scenario.allow_example_free_variables = (
allow_example_free_variables
if allow_example_free_variables is not None
else get_from_ini("bdd_allow_example_free_variables")
)
scenario.allow_step_free_variables = (
allow_step_free_variables
if allow_step_free_variables is not None
else get_from_ini("bdd_allow_step_free_variables")
)

except KeyError:
feature_name = feature.name or "[Empty]"
raise exceptions.ScenarioNotFound(
Expand All @@ -253,7 +277,7 @@ def get_features_base_dir(caller_module_path):
return get_from_ini("bdd_features_base_dir", default_base_dir)


def get_from_ini(key, default):
def get_from_ini(key, default=None):
"""Get value from ini config. Return default if value has not been set.

Use if the default value is dynamic. Otherwise set default on addini call.
Expand Down Expand Up @@ -294,10 +318,16 @@ def get_name():
suffix = f"_{index}"


def scenarios(*feature_paths, **kwargs):
class ScenarioKwargs(typing.TypedDict if sys.version_info >= (3, 8) else object):
allow_example_free_variables: typing.Optional[typing.Any]
allow_step_free_variables: typing.Optional[typing.Any]
features_base_dir: typing.Optional[typing.Union[str, Path]]


def scenarios(*feature_paths: typing.Union[str, Path], **kwargs: ScenarioKwargs):
"""Parse features from the paths and put all found scenarios in the caller module.

:param *feature_paths: feature file paths to use for scenarios
:param feature_paths: feature file paths to use for scenarios
"""
caller_locals = get_caller_module_locals()
caller_path = get_caller_module_path()
Expand All @@ -309,7 +339,7 @@ def scenarios(*feature_paths, **kwargs):
abs_feature_paths = []
for path in feature_paths:
if not os.path.isabs(path):
path = os.path.abspath(os.path.join(features_base_dir, path))
path = os.path.abspath(os.path.join(str(features_base_dir), path))
abs_feature_paths.append(path)
found = False

Expand Down
Loading