Merge pull request #738 from pytest-dev/multi-example-tables

This commit is contained in:
Alessio Bogon 2024-11-13 23:16:36 +01:00 committed by GitHub
commit f47c6d2fb5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 164 additions and 24 deletions

View File

@ -7,6 +7,8 @@ Unreleased
- Text after the `#` character is no longer stripped from the Scenario and Feature name. - 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). - 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. - 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 8.0.0b2
---------- ----------

View File

@ -514,6 +514,71 @@ Example:
assert cucumbers["start"] - cucumbers["eat"] == left 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 Datatables
---------- ----------

View File

@ -104,6 +104,7 @@ class Row:
@dataclass @dataclass
class ExamplesTable: class ExamplesTable:
location: Location location: Location
tags: list[Tag]
name: str | None = None name: str | None = None
table_header: Row | None = None table_header: Row | None = None
table_body: list[Row] | None = field(default_factory=list) table_body: list[Row] | None = field(default_factory=list)
@ -115,6 +116,7 @@ class ExamplesTable:
name=data.get("name"), name=data.get("name"),
table_header=Row.from_dict(data["tableHeader"]) if data.get("tableHeader") else None, 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", [])], table_body=[Row.from_dict(row) for row in data.get("tableBody", [])],
tags=[Tag.from_dict(tag) for tag in data["tags"]],
) )

View File

@ -22,6 +22,18 @@ from .types import STEP_TYPE_BY_PARSER_KEYWORD
STEP_PARAM_RE = re.compile(r"<(.+?)>") 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) @dataclass(eq=False)
class Feature: class Feature:
"""Represents a feature parsed from a feature file. """Represents a feature parsed from a feature file.
@ -64,6 +76,7 @@ class Examples:
name: str | None = None name: str | None = None
example_params: list[str] = field(default_factory=list) example_params: list[str] = field(default_factory=list)
examples: list[Sequence[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: def set_param_names(self, keys: Iterable[str]) -> None:
"""Set the parameter names for the examples. """Set the parameter names for the examples.
@ -124,7 +137,7 @@ class ScenarioTemplate:
description: str | None = None description: str | None = None
tags: set[str] = field(default_factory=set) tags: set[str] = field(default_factory=set)
_steps: list[Step] = field(init=False, default_factory=list) _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: def add_step(self, step: Step) -> None:
"""Add a step to the scenario. """Add a step to the scenario.
@ -327,18 +340,6 @@ class FeatureParser:
self.rel_filename = os.path.join(os.path.basename(basedir), filename) self.rel_filename = os.path.join(os.path.basename(basedir), filename)
self.encoding = encoding 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]: def parse_steps(self, steps_data: list[GherkinStep]) -> list[Step]:
"""Parse a list of step data into Step objects. """Parse a list of step data into Step objects.
@ -395,16 +396,18 @@ class FeatureParser:
name=scenario_data.name, name=scenario_data.name,
line_number=scenario_data.location.line, line_number=scenario_data.location.line,
templated=templated, templated=templated,
tags=self.get_tag_names(scenario_data.tags), tags=get_tag_names(scenario_data.tags),
description=textwrap.dedent(scenario_data.description), description=textwrap.dedent(scenario_data.description),
) )
for step in self.parse_steps(scenario_data.steps): for step in self.parse_steps(scenario_data.steps):
scenario.add_step(step) scenario.add_step(step)
# Loop over multiple example tables if they exist
for example_data in scenario_data.examples: for example_data in scenario_data.examples:
examples = Examples( examples = Examples(
line_number=example_data.location.line, line_number=example_data.location.line,
name=example_data.name, name=example_data.name,
tags=get_tag_names(example_data.tags),
) )
if example_data.table_header is not None: if example_data.table_header is not None:
param_names = [cell.value for cell in example_data.table_header.cells] param_names = [cell.value for cell in example_data.table_header.cells]
@ -413,7 +416,7 @@ class FeatureParser:
for row in example_data.table_body: for row in example_data.table_body:
values = [cell.value or "" for cell in row.cells] values = [cell.value or "" for cell in row.cells]
examples.add_example(values) examples.add_example(values)
scenario.examples = examples scenario.examples.append(examples)
return scenario return scenario
@ -444,7 +447,7 @@ class FeatureParser:
filename=self.abs_filename, filename=self.abs_filename,
rel_filename=self.rel_filename, rel_filename=self.rel_filename,
name=feature_data.name, name=feature_data.name,
tags=self.get_tag_names(feature_data.tags), tags=get_tag_names(feature_data.tags),
background=None, background=None,
line_number=feature_data.location.line, line_number=feature_data.location.line,
description=textwrap.dedent(feature_data.description), description=textwrap.dedent(feature_data.description),

View File

@ -49,7 +49,7 @@ def _pytest_bdd_example() -> dict:
If no outline is used, we just return an empty dict to render If no outline is used, we just return an empty dict to render
the current template without any actual variable. 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. from the example definitions in the feature file.
""" """
return {} return {}

View File

@ -289,7 +289,7 @@ def _get_scenario_decorator(
example_parametrizations, example_parametrizations,
)(scenario_wrapper) )(scenario_wrapper)
for tag in templated_scenario.tags.union(feature.tags): for tag in templated_scenario.tags | feature.tags:
config = CONFIG_STACK[-1] config = CONFIG_STACK[-1]
config.hook.pytest_bdd_apply_tag(tag=tag, function=scenario_wrapper) config.hook.pytest_bdd_apply_tag(tag=tag, function=scenario_wrapper)
@ -303,12 +303,24 @@ def _get_scenario_decorator(
def collect_example_parametrizations( def collect_example_parametrizations(
templated_scenario: ScenarioTemplate, templated_scenario: ScenarioTemplate,
) -> list[ParameterSet] | None: ) -> list[ParameterSet] | None:
if templated_scenario.examples is None: parametrizations = []
return None
if contexts := list(templated_scenario.examples.as_contexts()): for examples in templated_scenario.examples:
return [pytest.param(context, id="-".join(context.values())) for context in contexts] tags: set = examples.tags or set()
else:
return None 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( def scenario(

View File

@ -79,6 +79,61 @@ def test_outlined(pytester):
# fmt: on # 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): def test_unused_params(pytester):
"""Test parametrized scenario when the test function lacks parameters.""" """Test parametrized scenario when the test function lacks parameters."""

View File

@ -163,6 +163,7 @@ def test_parser():
ExamplesTable( ExamplesTable(
location=Location(column=5, line=26), location=Location(column=5, line=26),
name="", name="",
tags=[],
table_header=Row( table_header=Row(
id="11", id="11",
location=Location(column=7, line=27), location=Location(column=7, line=27),