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
27 changes: 27 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -637,6 +637,33 @@ This is allowed as long as parameter names do not clash:
| carrots |
| tomatoes |

To not repeat steps as in example above you could want store your data in sequent Examples sections:


.. code-block:: gherkin

Feature: Outline

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

Scenario Outline: Eat food
Given there are <start> <food>
When I eat <eat> <food>
Then I should have <left> <food>

Examples: Fruits
| food |
| oranges |
| apples |

Examples: Vegetables
| food |
| carrots |
| tomatoes |


Organizing your scenarios
-------------------------
Expand Down
199 changes: 126 additions & 73 deletions pytest_bdd/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,16 @@
import textwrap
import typing
from collections import OrderedDict
from itertools import chain, product, zip_longest
from typing import List

import pytest
from attr import Factory, attrib, attrs, validate

from . import exceptions, types

if typing.TYPE_CHECKING:
from _pytest.mark import ParameterSet
SPLIT_LINE_RE = re.compile(r"(?<!\\)\|")
STEP_PARAM_RE = re.compile(r"<(.+?)>")
COMMENT_RE = re.compile(r"(^|(?<=\s))#")
Expand Down Expand Up @@ -83,7 +90,9 @@ def parse_feature(basedir: str, filename: str, encoding: str = "utf-8") -> "Feat
"""
abs_filename = os.path.abspath(os.path.join(basedir, filename))
rel_filename = os.path.join(os.path.basename(basedir), filename)
feature = Feature(
build_node: typing.Union[Feature, ScenarioTemplate]
build_example_table: typing.Optional[ExampleTable] = None
feature = build_node = Feature(
scenarios=OrderedDict(),
filename=abs_filename,
rel_filename=rel_filename,
Expand Down Expand Up @@ -150,41 +159,51 @@ def parse_feature(basedir: str, filename: str, encoding: str = "utf-8") -> "Feat
keyword, parsed_line = parse_line(clean_line)
if mode in [types.SCENARIO, types.SCENARIO_OUTLINE]:
tags = get_tags(prev_line)
feature.scenarios[parsed_line] = scenario = ScenarioTemplate(
feature.scenarios[parsed_line] = scenario = build_node = ScenarioTemplate(
feature=feature, name=parsed_line, line_number=line_number, tags=tags
)
elif mode == types.BACKGROUND:
feature.background = Background(feature=feature, line_number=line_number)
elif mode == types.EXAMPLES:
mode = types.EXAMPLES_HEADERS
(scenario or feature).examples.line_number = line_number
elif mode == types.EXAMPLES_VERTICAL:
mode = types.EXAMPLE_LINE_VERTICAL
(scenario or feature).examples.line_number = line_number
elif mode in [types.EXAMPLES, types.EXAMPLES_VERTICAL]:
if mode == types.EXAMPLES:
mode, ExampleTableBuilder = types.EXAMPLES_HEADERS, ExampleTableColumns
else:
mode, ExampleTableBuilder = types.EXAMPLE_LINE_VERTICAL, ExampleTableRows
_, table_name = parse_line(clean_line)
build_example_table = ExampleTableBuilder(name=table_name or None, line_number=line_number)
build_node.examples.example_tables.append(build_example_table)
elif mode == types.EXAMPLES_HEADERS:
(scenario or feature).examples.set_param_names([l for l in split_line(parsed_line) if l])
mode = types.EXAMPLE_LINE
build_example_table.example_params = [l for l in split_line(parsed_line) if l]
try:
validate(build_example_table)
except exceptions.ExamplesNotValidError as exc:
node_message_prefix = "Scenario" if scenario else "Feature"
message = f"{node_message_prefix} has not valid examples. {exc.args[0]}"
raise exceptions.FeatureError(message, line_number, clean_line, filename) from exc
elif mode == types.EXAMPLE_LINE:
(scenario or feature).examples.add_example([l for l in split_line(stripped_line)])
elif mode == types.EXAMPLE_LINE_VERTICAL:
param_line_parts = [l for l in split_line(stripped_line)]
try:
(scenario or feature).examples.add_example_row(param_line_parts[0], param_line_parts[1:])
build_example_table.examples += [[*split_line(stripped_line)]]
except exceptions.ExamplesNotValidError as exc:
if scenario:
raise exceptions.FeatureError(
f"Scenario has not valid examples. {exc.args[0]}",
line_number,
clean_line,
filename,
)
else:
raise exceptions.FeatureError(
f"Feature has not valid examples. {exc.args[0]}",
line_number,
clean_line,
filename,
node_message_prefix = "Scenario" if scenario else "Feature"
message = f"{node_message_prefix} has not valid examples. {exc.args[0]}"
raise exceptions.FeatureError(message, line_number, clean_line, filename) from exc
elif mode == types.EXAMPLE_LINE_VERTICAL:
try:
param, *examples = split_line(stripped_line)
except ValueError:
pass
else:
try:
build_example_table: ExampleTableRows
build_example_table.example_params += [param]
build_example_table.examples_transposed += [examples]
validate(build_example_table)
except exceptions.ExamplesNotValidError as exc:
message = "{node} has not valid examples. {original_message}".format(
node="Scenario" if scenario else "Feature", original_message=exc.args[0]
)
raise exceptions.FeatureError(message, line_number, clean_line, filename) from exc
elif mode and mode not in (types.FEATURE, types.TAG):
step = Step(name=parsed_line, type=mode, indent=line_indent, line_number=line_number, keyword=keyword)
if feature.background and not scenario:
Expand Down Expand Up @@ -258,13 +277,29 @@ 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 example_parametrizations(self) -> "typing.Optional[typing.List[ParameterSet]]":
feature_scenario_examples_combinations = product(self.feature.examples, self.examples)

def examples():
for feature_examples, scenario_examples in feature_scenario_examples_combinations:
common_param_names = set(feature_examples.keys()).intersection(scenario_examples.keys())
if all(
feature_examples[param_name] == scenario_examples[param_name] for param_name in common_param_names
):
result = {**feature_examples, **scenario_examples}
if result != {}:
yield result

return [pytest.param(example, id="-".join(example.values())) for example in examples()]

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)
example_params = set(self.examples.example_params).union(self.feature.examples.example_params)
if params and example_params and params != example_params:
raise exceptions.ScenarioExamplesNotValidError(
"""Scenario "{}" in the feature "{}" has not valid examples. """
Expand Down Expand Up @@ -384,67 +419,85 @@ def add_step(self, step):
self.steps.append(step)


@attrs
class Examples:
example_tables: List["ExampleTable"] = attrib(default=Factory(list))

"""Example table."""
def __iter__(self) -> typing.Iterable[typing.Dict[str, typing.Any]]:
if any(self.example_tables):
yield from chain.from_iterable(self.example_tables)
else:
# We must make sure that we always have at least one element, otherwise
# examples cartesian product will be empty
yield {}

def __init__(self):
"""Initialize examples instance."""
self.example_params = []
self.examples = []
self.vertical_examples = []
self.line_number = None
self.name = None
@property
def example_params(self):
return set(chain.from_iterable(set(table.example_params) for table in self.example_tables))

def set_param_names(self, keys):
"""Set parameter names.

:param names: `list` of `string` parameter names.
"""
self.example_params = [str(key) for key in keys]
@attrs
class ExampleTable:
"""Example table."""

def add_example(self, values):
"""Add example.
examples: list
examples_transposed: list
example_params = attrib(default=Factory(list))

@example_params.validator
def unique(self, attribute, value):
unique_items = set()
for item in value:
if item in unique_items:
raise exceptions.ExamplesNotValidError(
f"""Example rows should contain unique parameters. "{item}" appeared more than once"""
)
unique_items.add(item)
return True

:param values: `list` of `string` parameter values.
"""
self.examples.append(values)
line_number = attrib(default=None)
name = attrib(default=None)
tags = attrib(default=Factory(list))

def add_example_row(self, param, values):
"""Add example row.
def __iter__(self) -> typing.Iterable[typing.Dict[str, typing.Any]]:
examples = self.examples
for example in examples:
assert len(self.example_params) == len(example)
yield dict(zip(self.example_params, example))

:param param: `str` parameter name
:param values: `list` of `string` parameter values
"""
if param in self.example_params:
raise exceptions.ExamplesNotValidError(
f"""Example rows should contain unique parameters. "{param}" appeared more than once"""
)
self.example_params.append(param)
self.vertical_examples.append(values)
def __bool__(self):
"""Bool comparison."""
return bool(self.examples)

def as_contexts(self) -> typing.Iterable[typing.Dict[str, typing.Any]]:
param_count = len(self.example_params)
if self.vertical_examples and not self.examples:
for value_index in range(len(self.vertical_examples[0])):
example = []
for param_index in range(param_count):
example.append(self.vertical_examples[param_index][value_index])
self.examples.append(example)

if not self.examples:
return
@attrs
class ExampleTableRows(ExampleTable):
examples_transposed = attrib(default=Factory(list))

header, rows = self.example_params, self.examples
@examples_transposed.validator
def each_row_contains_same_count_of_values(self, attribute, value):
if value:
if not all(len(value[0]) == len(item) for item in value):
raise exceptions.ExamplesNotValidError(
f"""All example columns in Examples: Vertical must have same count of values"""
)
return True

for row in rows:
assert len(header) == len(row)
@property
def examples(self):
return list(zip_longest(*self.examples_transposed))

yield dict(zip(header, row))

def __bool__(self):
"""Bool comparison."""
return bool(self.vertical_examples or self.examples)
@attrs
class ExampleTableColumns(ExampleTable):
examples = attrib(default=Factory(list))

@examples.validator
def each_row_contains_same_count_of_values(self, attribute, value):
if value:
if not (all(len(value[0]) == len(item) for item in value) and (len(value[0]) != len(self.example_params))):
raise exceptions.ExamplesNotValidError(f"""All example rows must have same count of values""")
return True


def get_tags(line):
Expand Down
27 changes: 2 additions & 25 deletions pytest_bdd/scenario.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,6 @@
from .utils import CONFIG_STACK, get_args, get_caller_module_locals, get_caller_module_path

if typing.TYPE_CHECKING:
from _pytest.mark.structures import ParameterSet

from .parser import Feature, Scenario, ScenarioTemplate

PYTHON_REPLACE_REGEX = re.compile(r"\W")
Expand Down Expand Up @@ -175,8 +173,8 @@ def scenario_wrapper(request, _pytest_bdd_example):
fixture_values = [request.getfixturevalue(arg) for arg in args]
return fn(*fixture_values)

example_parametrizations = collect_example_parametrizations(templated_scenario)
if example_parametrizations is not None:
example_parametrizations = templated_scenario.example_parametrizations
if example_parametrizations:
# Parametrize the scenario outlines
scenario_wrapper = pytest.mark.parametrize(
"_pytest_bdd_example",
Expand All @@ -194,27 +192,6 @@ def scenario_wrapper(request, _pytest_bdd_example):
return decorator


def collect_example_parametrizations(
templated_scenario: "ScenarioTemplate",
) -> "typing.Optional[typing.List[ParameterSet]]":
# We need to evaluate these iterators and store them as lists, otherwise
# we won't be able to do the cartesian product later (the second iterator will be consumed)
feature_contexts = list(templated_scenario.feature.examples.as_contexts())
scenario_contexts = list(templated_scenario.examples.as_contexts())

contexts = [
{**feature_context, **scenario_context}
# We must make sure that we always have at least one element in each list, otherwise
# the cartesian product will result in an empty list too, even if one of the 2 sets
# is non empty.
for feature_context in feature_contexts or [{}]
for scenario_context in scenario_contexts or [{}]
]
if contexts == [{}]:
return None
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):
"""Scenario decorator.

Expand Down
2 changes: 2 additions & 0 deletions requirements-testing.txt
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
execnet
deepdiff
packaging
1 change: 1 addition & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ classifiers =
[options]
python_requires = >=3.6
install_requires =
attrs
glob2
Mako
parse
Expand Down
4 changes: 3 additions & 1 deletion tests/feature/test_cucumber_json.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import os.path
import textwrap

from deepdiff import DeepDiff


def runandparse(testdir, *args):
"""Run tests in testdir and parse json output."""
Expand Down Expand Up @@ -225,4 +227,4 @@ def test_passing_outline():
}
]

assert jsonobject == expected
assert DeepDiff(jsonobject, expected, ignore_order=True, report_repetition=True)
Loading