Skip to content

Commit f250ea6

Browse files
author
Konstantin Goloveshko
committed
Add tags support for Examples sections
- Example tags could be used for test selection - Example tags could not be used for invoking pytest_bdd_apply_tag hook for now - Add explicit support for multiple Examples sections - "Examples: Vertical" replaced with "Examples Transposed:" for more convenient naming - Examples sections names are parsed (not used now)
1 parent 2e94b4d commit f250ea6

14 files changed

+380
-118
lines changed

CHANGES.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ Unreleased
66
- Drop compatibility for python 2 and officially support only python >= 3.6.
77
- Fix error when using `--cucumber-json-expanded` in combination with `example_converters` (marcbrossaissogeti).
88
- Fix `--generate-missing` not correctly recognizing steps with parsers
9+
- Add partial support of tags for ``Examples:`` ; Fixes minor bugs with Examples
910

1011
4.0.2
1112
-----

README.rst

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -496,13 +496,13 @@ pytest-bdd feature file format also supports example tables in different way:
496496
When I eat <eat> cucumbers
497497
Then I should have <left> cucumbers
498498
499-
Examples: Vertical
499+
Examples Transposed:
500500
| start | 12 | 2 |
501501
| eat | 5 | 1 |
502502
| left | 7 | 1 |
503503
504504
This form allows to have tables with lots of columns keeping the maximum text width predictable without significant
505-
readability change.
505+
readability change. "Examples: Vertical" examples header also could be used, but will be removed in future
506506

507507
The code will look like:
508508

@@ -601,6 +601,41 @@ This is allowed as long as parameter names do not clash:
601601
| carrots |
602602
| tomatoes |
603603
604+
Feature and scenario outlines support tags and multiple example tables.
605+
Outline tags could be used for test suite selection but not for applying hook tag
606+
607+
.. code-block:: gherkin
608+
609+
Feature: Outlined
610+
Background:
611+
Given I have cucumbers at <tableware>
612+
613+
@indoor
614+
Examples: Indoor
615+
|tableware|
616+
|dish |
617+
|plate |
618+
619+
@outdoor
620+
Examples Transposed: Outdoor
621+
|tableware|basket|lunchbox|
622+
623+
Scenario Outline: Outlined given, when, thens
624+
Given there are <start> cucumbers
625+
When I eat <eat> cucumbers
626+
Then I should have <left> cucumbers
627+
628+
@breakfast
629+
Examples: Vertical Breakfast
630+
| start | 12 | 2 |
631+
| eat | 5 | 1 |
632+
| left | 7 | 1 |
633+
634+
@lunch
635+
Examples: Lunch
636+
|start|eat|left|
637+
|3 |1 |2 |
638+
|2 |2 |0 |
604639
605640
Combine scenario outline and pytest parametrization
606641
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

pytest_bdd/parser.py

Lines changed: 91 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@
33
import re
44
import textwrap
55
from collections import OrderedDict
6+
from itertools import chain
7+
from operator import attrgetter
8+
from typing import List, Type
9+
10+
from attr import attrs, attrib, Factory
611

712
from . import types, exceptions
813

@@ -11,7 +16,8 @@
1116
STEP_PREFIXES = [
1217
("Feature: ", types.FEATURE),
1318
("Scenario Outline: ", types.SCENARIO_OUTLINE),
14-
("Examples: Vertical", types.EXAMPLES_VERTICAL),
19+
("Examples: Vertical", types.EXAMPLES_VERTICAL_LEGACY),
20+
("Examples Transposed:", types.EXAMPLES_TRANSPOSED),
1521
("Examples:", types.EXAMPLES),
1622
("Scenario: ", types.SCENARIO),
1723
("Background:", types.BACKGROUND),
@@ -142,8 +148,7 @@ def parse_feature(basedir, filename, encoding="utf-8"):
142148
clean_line,
143149
filename,
144150
)
145-
146-
prev_mode = mode
151+
prev_prev_mode, prev_mode = prev_mode, mode
147152

148153
# Remove Feature, Given, When, Then, And
149154
keyword, parsed_line = parse_line(clean_line)
@@ -154,19 +159,35 @@ def parse_feature(basedir, filename, encoding="utf-8"):
154159
feature.background = Background(feature=feature, line_number=line_number)
155160
elif mode == types.EXAMPLES:
156161
mode = types.EXAMPLES_HEADERS
157-
(scenario or feature).examples.line_number = line_number
158-
elif mode == types.EXAMPLES_VERTICAL:
159-
mode = types.EXAMPLE_LINE_VERTICAL
160-
(scenario or feature).examples.line_number = line_number
162+
_, example_table_name = parse_line(clean_line)
163+
obj = scenario or feature
164+
obj.examples.add_example_table(UsualExampleTable)
165+
obj.examples.last_example_table.name = example_table_name or None
166+
obj.examples.last_example_table.line_number = line_number
167+
if prev_prev_mode == types.TAG:
168+
obj.examples.last_example_table.tags = get_tags(prev_line)
169+
elif (
170+
mode == types.EXAMPLES_TRANSPOSED or mode == types.EXAMPLES_VERTICAL_LEGACY
171+
): # Deprecated; Has to be removed in future versions
172+
mode = types.EXAMPLE_LINE_TRANSPOSED
173+
_, example_table_name = parse_line(clean_line)
174+
obj = scenario or feature
175+
obj.examples.add_example_table(TransposedExampleTable)
176+
obj.examples.last_example_table.name = example_table_name or None
177+
obj.examples.last_example_table.line_number = line_number
178+
if prev_prev_mode == types.TAG:
179+
obj.examples.last_example_table.tags = get_tags(prev_line)
161180
elif mode == types.EXAMPLES_HEADERS:
162-
(scenario or feature).examples.set_param_names([l for l in split_line(parsed_line) if l])
181+
(scenario or feature).examples.last_example_table.set_param_names([l for l in split_line(parsed_line) if l])
163182
mode = types.EXAMPLE_LINE
164183
elif mode == types.EXAMPLE_LINE:
165-
(scenario or feature).examples.add_example([l for l in split_line(stripped_line)])
166-
elif mode == types.EXAMPLE_LINE_VERTICAL:
184+
example_table: UsualExampleTable = (scenario or feature).examples.last_example_table
185+
example_table.add_example([l for l in split_line(stripped_line)])
186+
elif mode == types.EXAMPLE_LINE_TRANSPOSED:
167187
param_line_parts = [l for l in split_line(stripped_line)]
168188
try:
169-
(scenario or feature).examples.add_example_row(param_line_parts[0], param_line_parts[1:])
189+
example_table: TransposedExampleTable = (scenario or feature).examples.last_example_table
190+
example_table.add_example_row(param_line_parts[0], param_line_parts[1:])
170191
except exceptions.ExamplesNotValidError as exc:
171192
if scenario:
172193
raise exceptions.FeatureError(
@@ -267,12 +288,20 @@ def params(self):
267288

268289
def get_example_params(self):
269290
"""Get example parameter names."""
270-
return set(self.examples.example_params + self.feature.examples.example_params)
291+
return set(
292+
chain(
293+
*map(
294+
attrgetter("example_params"),
295+
chain(self.examples.example_tables, self.feature.examples.example_tables),
296+
),
297+
)
298+
)
271299

272300
def get_params(self, builtin=False):
273301
"""Get converted example params."""
274302
for examples in [self.feature.examples, self.examples]:
275-
yield examples.get_params(self.example_converters, builtin=builtin)
303+
for example_table in examples.example_tables:
304+
yield example_table.tags, example_table.get_params(self.example_converters, builtin=builtin)
276305

277306
def validate(self):
278307
"""Validate the scenario.
@@ -373,17 +402,28 @@ def add_step(self, step):
373402
self.steps.append(step)
374403

375404

405+
@attrs
376406
class Examples:
407+
example_tables: List["ExampleTable"] = attrib(default=Factory(list))
408+
409+
def add_example_table(self, builder: Type["ExampleTable"]):
410+
self.example_tables.append(builder())
411+
412+
@property
413+
def last_example_table(self) -> "ExampleTable":
414+
return self.example_tables[-1]
377415

416+
417+
@attrs
418+
class ExampleTable(object):
378419
"""Example table."""
379420

380-
def __init__(self):
381-
"""Initialize examples instance."""
382-
self.example_params = []
383-
self.examples = []
384-
self.vertical_examples = []
385-
self.line_number = None
386-
self.name = None
421+
example_params = attrib(default=Factory(list))
422+
examples = attrib(default=Factory(list))
423+
424+
line_number = attrib(default=None)
425+
name = attrib(default=None)
426+
tags = attrib(default=Factory(list))
387427

388428
def set_param_names(self, keys):
389429
"""Set parameter names.
@@ -392,12 +432,28 @@ def set_param_names(self, keys):
392432
"""
393433
self.example_params = [str(key) for key in keys]
394434

395-
def add_example(self, values):
396-
"""Add example.
435+
def get_params(self, converters, builtin=False):
436+
"""Get scenario pytest parametrization table.
397437
398-
:param values: `list` of `string` parameter values.
438+
:param converters: `dict` of converter functions to convert parameter values
399439
"""
400-
self.examples.append(values)
440+
params = []
441+
442+
for example in self.examples:
443+
example = list(example)
444+
for index, param in enumerate(self.example_params):
445+
raw_value = example[index]
446+
if converters and param in converters:
447+
value = converters[param](raw_value)
448+
if not builtin or value.__class__.__module__ in {"__builtin__", "builtins"}:
449+
example[index] = value
450+
params.append(example)
451+
return self.example_params, params
452+
453+
454+
@attrs
455+
class TransposedExampleTable(ExampleTable):
456+
example_param_values = attrib(default=Factory(list))
401457

402458
def add_example_row(self, param, values):
403459
"""Add example row.
@@ -410,39 +466,24 @@ def add_example_row(self, param, values):
410466
f"""Example rows should contain unique parameters. "{param}" appeared more than once"""
411467
)
412468
self.example_params.append(param)
413-
self.vertical_examples.append(values)
469+
self.example_param_values.append(values)
414470

415471
def get_params(self, converters, builtin=False):
416472
"""Get scenario pytest parametrization table.
417473
418474
:param converters: `dict` of converter functions to convert parameter values
419475
"""
420-
param_count = len(self.example_params)
421-
if self.vertical_examples and not self.examples:
422-
for value_index in range(len(self.vertical_examples[0])):
423-
example = []
424-
for param_index in range(param_count):
425-
example.append(self.vertical_examples[param_index][value_index])
426-
self.examples.append(example)
427-
428-
if self.examples:
429-
params = []
430-
for example in self.examples:
431-
example = list(example)
432-
for index, param in enumerate(self.example_params):
433-
raw_value = example[index]
434-
if converters and param in converters:
435-
value = converters[param](raw_value)
436-
if not builtin or value.__class__.__module__ in {"__builtin__", "builtins"}:
437-
example[index] = value
438-
params.append(example)
439-
return [self.example_params, params]
440-
else:
441-
return []
476+
self.examples = list(zip(*self.example_param_values))
477+
return super().get_params(converters, builtin=builtin)
478+
442479

443-
def __bool__(self):
444-
"""Bool comparison."""
445-
return bool(self.vertical_examples or self.examples)
480+
class UsualExampleTable(ExampleTable):
481+
def add_example(self, values):
482+
"""Add example.
483+
484+
:param values: `list` of `string` parameter values.
485+
"""
486+
self.examples.append(values)
446487

447488

448489
def get_tags(line):

pytest_bdd/reporting.py

Lines changed: 38 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@
55
"""
66

77
import time
8+
from itertools import chain
89

9-
from .utils import get_parametrize_markers_args
10+
from _pytest.mark import ParameterSet
11+
12+
from .utils import get_parametrize_markers
1013

1114

1215
class StepReport:
@@ -70,21 +73,7 @@ def __init__(self, scenario, node):
7073
"""
7174
self.scenario = scenario
7275
self.step_reports = []
73-
self.param_index = None
74-
parametrize_args = get_parametrize_markers_args(node)
75-
if parametrize_args and scenario.examples:
76-
param_names = (
77-
parametrize_args[0] if isinstance(parametrize_args[0], (tuple, list)) else [parametrize_args[0]]
78-
)
79-
param_values = parametrize_args[1]
80-
node_param_values = [node.funcargs[param_name] for param_name in param_names]
81-
if node_param_values in param_values:
82-
self.param_index = param_values.index(node_param_values)
83-
elif tuple(node_param_values) in param_values:
84-
self.param_index = param_values.index(tuple(node_param_values))
85-
self.example_kwargs = {
86-
example_param: str(node.funcargs[example_param]) for example_param in scenario.get_example_params()
87-
}
76+
self.node = node
8877

8978
@property
9079
def current_step_report(self):
@@ -112,7 +101,6 @@ def serialize(self):
112101
scenario = self.scenario
113102
feature = scenario.feature
114103

115-
params = sum(scenario.get_params(builtin=True), []) if scenario.examples else None
116104
return {
117105
"steps": [step_report.serialize() for step_report in self.step_reports],
118106
"name": scenario.name,
@@ -126,17 +114,42 @@ def serialize(self):
126114
"description": feature.description,
127115
"tags": sorted(feature.tags),
128116
},
117+
**self.serialize_examples(),
118+
}
119+
120+
def get_param_index(self, param_names):
121+
parametrize_args = get_parametrize_markers(self.node)
122+
123+
for parametrize_arg in parametrize_args:
124+
arg_param_names = (
125+
parametrize_arg.names if isinstance(parametrize_arg[0], (tuple, list)) else [parametrize_arg.names]
126+
)
127+
param_values = [list(v.values) if isinstance(v, ParameterSet) else v for v in parametrize_arg.values]
128+
if param_names == arg_param_names:
129+
node_param_values = [self.node.funcargs[param_name] for param_name in param_names]
130+
if node_param_values in param_values:
131+
return param_values.index(node_param_values)
132+
elif tuple(node_param_values) in param_values:
133+
return param_values.index(tuple(node_param_values))
134+
135+
def serialize_examples(self):
136+
return {
129137
"examples": [
130138
{
131-
"name": scenario.examples.name,
132-
"line_number": scenario.examples.line_number,
133-
"rows": params,
134-
"row_index": self.param_index,
139+
"name": example_table.name,
140+
"line_number": example_table.line_number,
141+
"rows": list(example_table.get_params(self.scenario.example_converters, builtin=True)),
142+
"row_index": self.get_param_index(example_table.example_params),
143+
"tags": list(example_table.tags),
135144
}
136-
]
137-
if scenario.examples
138-
else [],
139-
"example_kwargs": self.example_kwargs,
145+
for example_table in chain(
146+
self.scenario.examples.example_tables, self.scenario.feature.examples.example_tables
147+
)
148+
],
149+
"example_kwargs": {
150+
example_param: str(self.node.funcargs[example_param])
151+
for example_param in self.scenario.get_example_params()
152+
},
140153
}
141154

142155
def fail(self):

0 commit comments

Comments
 (0)