Skip to content

Commit

Permalink
Merge pull request #738 from pytest-dev/multi-example-tables
Browse files Browse the repository at this point in the history
  • Loading branch information
youtux authored Nov 13, 2024
2 parents a01ca25 + d8f9674 commit f47c6d2
Show file tree
Hide file tree
Showing 8 changed files with 164 additions and 24 deletions.
2 changes: 2 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ Unreleased
- Text after the `#` character is no longer stripped from the Scenario and Feature name.
- Gherkin keyword aliases can now be used and correctly reported in json and terminal output (see `Keywords <https://cucumber.io/docs/gherkin/reference/#keywords>` for permitted list).
- Added localization support. The language of the feature file can be specified using the `# language: <language>` directive at the beginning of the file.
- Multiple example tables supported
- Added filtering by tags against example tables

8.0.0b2
----------
Expand Down
65 changes: 65 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,71 @@ Example:
assert cucumbers["start"] - cucumbers["eat"] == left
Scenario Outlines with Multiple Example Tables
----------------------------------------------

In `pytest-bdd`, you can use multiple example tables in a scenario outline to test
different sets of input data under various conditions.
You can define separate `Examples` blocks, each with its own table of data,
and optionally tag them to differentiate between positive, negative, or any other conditions.

Example:

.. code-block:: gherkin
# content of scenario_outline.feature
Feature: Scenario outlines with multiple examples tables
Scenario Outline: Outlined with multiple example tables
Given there are <start> cucumbers
When I eat <eat> cucumbers
Then I should have <left> cucumbers
@positive
Examples: Positive results
| start | eat | left |
| 12 | 5 | 7 |
| 5 | 4 | 1 |
@negative
Examples: Impossible negative results
| start | eat | left |
| 3 | 9 | -6 |
| 1 | 4 | -3 |
.. code-block:: python
from pytest_bdd import scenarios, given, when, then, parsers
scenarios("scenario_outline.feature")
@given(parsers.parse("there are {start:d} cucumbers"), target_fixture="cucumbers")
def given_cucumbers(start):
return {"start": start, "eat": 0}
@when(parsers.parse("I eat {eat:d} cucumbers"))
def eat_cucumbers(cucumbers, eat):
cucumbers["eat"] += eat
@then(parsers.parse("I should have {left:d} cucumbers"))
def should_have_left_cucumbers(cucumbers, left):
assert cucumbers["start"] - cucumbers["eat"] == left
When you filter scenarios by a tag, only the examples associated with that tag will be executed.
This allows you to run a specific subset of your test cases based on the tag.
For example, in the following scenario outline, if you filter by the @positive tag,
only the examples under the "Positive results" table will be executed, and the "Negative results" table will be ignored.

.. code-block:: bash
pytest -k "positive"
Datatables
----------

Expand Down
2 changes: 2 additions & 0 deletions src/pytest_bdd/gherkin_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ def from_dict(cls, data: dict[str, Any]) -> Self:
@dataclass
class ExamplesTable:
location: Location
tags: list[Tag]
name: str | None = None
table_header: Row | None = None
table_body: list[Row] | None = field(default_factory=list)
Expand All @@ -115,6 +116,7 @@ def from_dict(cls, data: dict[str, Any]) -> Self:
name=data.get("name"),
table_header=Row.from_dict(data["tableHeader"]) if data.get("tableHeader") else None,
table_body=[Row.from_dict(row) for row in data.get("tableBody", [])],
tags=[Tag.from_dict(tag) for tag in data["tags"]],
)


Expand Down
35 changes: 19 additions & 16 deletions src/pytest_bdd/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,18 @@
STEP_PARAM_RE = re.compile(r"<(.+?)>")


def get_tag_names(tag_data: list[GherkinTag]) -> set[str]:
"""Extract tag names from tag data.
Args:
tag_data (List[dict]): The tag data to extract names from.
Returns:
set[str]: A set of tag names.
"""
return {tag.name.lstrip("@") for tag in tag_data}


@dataclass(eq=False)
class Feature:
"""Represents a feature parsed from a feature file.
Expand Down Expand Up @@ -64,6 +76,7 @@ class Examples:
name: str | None = None
example_params: list[str] = field(default_factory=list)
examples: list[Sequence[str]] = field(default_factory=list)
tags: set[str] = field(default_factory=set)

def set_param_names(self, keys: Iterable[str]) -> None:
"""Set the parameter names for the examples.
Expand Down Expand Up @@ -124,7 +137,7 @@ class ScenarioTemplate:
description: str | None = None
tags: set[str] = field(default_factory=set)
_steps: list[Step] = field(init=False, default_factory=list)
examples: Examples | None = field(default_factory=Examples)
examples: list[Examples] = field(default_factory=list[Examples])

def add_step(self, step: Step) -> None:
"""Add a step to the scenario.
Expand Down Expand Up @@ -327,18 +340,6 @@ def __init__(self, basedir: str, filename: str, encoding: str = "utf-8"):
self.rel_filename = os.path.join(os.path.basename(basedir), filename)
self.encoding = encoding

@staticmethod
def get_tag_names(tag_data: list[GherkinTag]) -> set[str]:
"""Extract tag names from tag data.
Args:
tag_data (List[dict]): The tag data to extract names from.
Returns:
set[str]: A set of tag names.
"""
return {tag.name.lstrip("@") for tag in tag_data}

def parse_steps(self, steps_data: list[GherkinStep]) -> list[Step]:
"""Parse a list of step data into Step objects.
Expand Down Expand Up @@ -395,16 +396,18 @@ def parse_scenario(self, scenario_data: GherkinScenario, feature: Feature) -> Sc
name=scenario_data.name,
line_number=scenario_data.location.line,
templated=templated,
tags=self.get_tag_names(scenario_data.tags),
tags=get_tag_names(scenario_data.tags),
description=textwrap.dedent(scenario_data.description),
)
for step in self.parse_steps(scenario_data.steps):
scenario.add_step(step)

# Loop over multiple example tables if they exist
for example_data in scenario_data.examples:
examples = Examples(
line_number=example_data.location.line,
name=example_data.name,
tags=get_tag_names(example_data.tags),
)
if example_data.table_header is not None:
param_names = [cell.value for cell in example_data.table_header.cells]
Expand All @@ -413,7 +416,7 @@ def parse_scenario(self, scenario_data: GherkinScenario, feature: Feature) -> Sc
for row in example_data.table_body:
values = [cell.value or "" for cell in row.cells]
examples.add_example(values)
scenario.examples = examples
scenario.examples.append(examples)

return scenario

Expand Down Expand Up @@ -444,7 +447,7 @@ def parse(self) -> Feature:
filename=self.abs_filename,
rel_filename=self.rel_filename,
name=feature_data.name,
tags=self.get_tag_names(feature_data.tags),
tags=get_tag_names(feature_data.tags),
background=None,
line_number=feature_data.location.line,
description=textwrap.dedent(feature_data.description),
Expand Down
2 changes: 1 addition & 1 deletion src/pytest_bdd/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ def _pytest_bdd_example() -> dict:
If no outline is used, we just return an empty dict to render
the current template without any actual variable.
Otherwise pytest_bdd will add all the context variables in this fixture
Otherwise, pytest_bdd will add all the context variables in this fixture
from the example definitions in the feature file.
"""
return {}
Expand Down
26 changes: 19 additions & 7 deletions src/pytest_bdd/scenario.py
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,7 @@ def scenario_wrapper(request: FixtureRequest, _pytest_bdd_example: dict[str, str
example_parametrizations,
)(scenario_wrapper)

for tag in templated_scenario.tags.union(feature.tags):
for tag in templated_scenario.tags | feature.tags:
config = CONFIG_STACK[-1]
config.hook.pytest_bdd_apply_tag(tag=tag, function=scenario_wrapper)

Expand All @@ -303,12 +303,24 @@ def scenario_wrapper(request: FixtureRequest, _pytest_bdd_example: dict[str, str
def collect_example_parametrizations(
templated_scenario: ScenarioTemplate,
) -> list[ParameterSet] | None:
if templated_scenario.examples is None:
return None
if contexts := list(templated_scenario.examples.as_contexts()):
return [pytest.param(context, id="-".join(context.values())) for context in contexts]
else:
return None
parametrizations = []

for examples in templated_scenario.examples:
tags: set = examples.tags or set()

example_marks = [getattr(pytest.mark, tag) for tag in tags]

for context in examples.as_contexts():
param_id = "-".join(context.values())
parametrizations.append(
pytest.param(
context,
id=param_id,
marks=example_marks,
),
)

return parametrizations or None


def scenario(
Expand Down
55 changes: 55 additions & 0 deletions tests/feature/test_outline.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,61 @@ def test_outline(request):
# fmt: on


def test_multiple_outlined(pytester):
pytester.makefile(
".feature",
outline_multi_example=textwrap.dedent(
"""\
Feature: Outline With Multiple Examples
Scenario Outline: Outlined given, when, thens with multiple examples tables
Given there are <start> cucumbers
When I eat <eat> cucumbers
Then I should have <left> cucumbers
@positive
Examples: Positive results
| start | eat | left |
| 12 | 5 | 7 |
| 5 | 4 | 1 |
@negative
Examples: Negative results
| start | eat | left |
| 3 | 9 | -6 |
| 1 | 4 | -3 |
"""
),
)

pytester.makeconftest(textwrap.dedent(STEPS))

pytester.makepyfile(
textwrap.dedent(
"""\
from pytest_bdd import scenarios
scenarios('outline_multi_example.feature')
"""
)
)
result = pytester.runpytest("-s")
result.assert_outcomes(passed=4)
# fmt: off
assert collect_dumped_objects(result) == [
12, 5.0, "7",
5, 4.0, "1",
3, 9.0, "-6",
1, 4.0, "-3",
]
# fmt: on
result = pytester.runpytest("-k", "positive", "-vv")
result.assert_outcomes(passed=2, deselected=2)

result = pytester.runpytest("-k", "positive or negative", "-vv")
result.assert_outcomes(passed=4, deselected=0)


def test_unused_params(pytester):
"""Test parametrized scenario when the test function lacks parameters."""

Expand Down
1 change: 1 addition & 0 deletions tests/parser/test_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ def test_parser():
ExamplesTable(
location=Location(column=5, line=26),
name="",
tags=[],
table_header=Row(
id="11",
location=Location(column=7, line=27),
Expand Down

0 comments on commit f47c6d2

Please sign in to comment.