Skip to content

Commit c370160

Browse files
authored
Merge pull request #712 from jsa34/datatables
Datatables
2 parents 655f08b + 5388c9b commit c370160

File tree

8 files changed

+531
-177
lines changed

8 files changed

+531
-177
lines changed

CHANGES.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ Unreleased
66
- Update documentation to clarify that `--gherkin-terminal-reporter` needs to be used with `-v` or `-vv`.
77
- Drop compatibility with pytest < 7.0.0.
88
- Continuation of steps using asterisks instead of And/But supported.
9+
- Added `datatable` argument for steps that contain a datatable.
910

1011
8.0.0b1
1112
----------

README.rst

Lines changed: 97 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -433,20 +433,21 @@ Multiline steps
433433

434434
As Gherkin, pytest-bdd supports multiline steps
435435
(a.k.a. `Doc Strings <https://cucumber.io/docs/gherkin/reference/#doc-strings>`_).
436-
But in much cleaner and powerful way:
437436

438437
.. code-block:: gherkin
439438
440439
Feature: Multiline steps
441440
Scenario: Multiline step using sub indentation
442441
Given I have a step with:
442+
"""
443443
Some
444444
Extra
445445
Lines
446+
"""
446447
Then the text should be parsed with correct indentation
447448
448-
A step is considered as a multiline one, if the **next** line(s) after it's first line is indented relatively
449-
to the first line. The step name is then simply extended by adding further lines with newlines.
449+
A step is considered as a multiline one, if the **next** line(s) after its first line is encapsulated by
450+
triple quotes. The step name is then simply extended by adding further lines inside the triple quotes.
450451
In the example above, the Given step name will be:
451452

452453
.. code-block:: python
@@ -560,6 +561,98 @@ Example:
560561
assert cucumbers["start"] - cucumbers["eat"] == left
561562
562563
564+
Step Definitions and Accessing the Datatable
565+
--------------------------------------------
566+
567+
The ``datatable`` argument allows you to utilise data tables defined in your Gherkin scenarios
568+
directly within your test functions. This is particularly useful for scenarios that require tabular data as input,
569+
enabling you to manage and manipulate this data conveniently.
570+
571+
When you use the ``datatable`` argument in a step definition, it will return the table as a list of lists,
572+
where each inner list represents a row from the table.
573+
574+
For example, the Gherkin table:
575+
576+
.. code-block:: gherkin
577+
578+
| name | email |
579+
| John | john@example.com |
580+
581+
Will be returned by the ``datatable`` argument as:
582+
583+
.. code-block:: python
584+
585+
[
586+
["name", "email"],
587+
["John", "john@example.com"]
588+
]
589+
590+
.. NOTE:: When using the datatable argument, it is essential to ensure that the step to which it is applied
591+
actually has an associated data table. If the step does not have an associated data table,
592+
attempting to use the datatable argument will raise an error.
593+
Make sure that your Gherkin steps correctly reference the data table when defined.
594+
595+
Full example:
596+
597+
.. code-block:: gherkin
598+
599+
Feature: Manage user accounts
600+
601+
Scenario: Creating a new user with roles and permissions
602+
Given the following user details:
603+
| name | email | age |
604+
| John | john@example.com | 30 |
605+
| Alice | alice@example.com | 25 |
606+
607+
When each user is assigned the following roles:
608+
| Admin | Full access to the system |
609+
| Contributor | Can add content |
610+
611+
And the page is saved
612+
613+
Then the user should have the following permissions:
614+
| permission | allowed |
615+
| view dashboard | true |
616+
| edit content | true |
617+
| delete content | false |
618+
619+
.. code-block:: python
620+
621+
from pytest_bdd import given, when, then
622+
623+
624+
@given("the following user details:", target_fixture="users")
625+
def _(datatable):
626+
users = []
627+
for row in datatable[1:]:
628+
users.append(row)
629+
630+
print(users)
631+
return users
632+
633+
634+
@when("each user is assigned the following roles:")
635+
def _(datatable, users):
636+
roles = datatable
637+
for user in users:
638+
for role_row in datatable:
639+
assign_role(user, role_row)
640+
641+
642+
@when("the page is saved")
643+
def _():
644+
save_page()
645+
646+
647+
@then("the user should have the following permissions:")
648+
def _(datatable, users):
649+
expected_permissions = []
650+
for row in datatable[1:]:
651+
expected_permissions.append(row)
652+
653+
assert users_have_correct_permissions(users, expected_permissions)
654+
655+
563656
Organizing your scenarios
564657
-------------------------
565658

@@ -1065,7 +1158,7 @@ which might be helpful building useful reporting, visualization, etc. on top of
10651158
(even if one of steps has failed)
10661159

10671160
* `pytest_bdd_before_step(request, feature, scenario, step, step_func)` - Called before step function
1068-
is executed and it's arguments evaluated
1161+
is executed and its arguments evaluated
10691162

10701163
* `pytest_bdd_before_step_call(request, feature, scenario, step, step_func, step_func_args)` - Called before step
10711164
function is executed with evaluated arguments

src/pytest_bdd/gherkin_parser.py

Lines changed: 39 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import re
55
import textwrap
66
import typing
7+
from collections.abc import Sequence
78
from dataclasses import dataclass, field
89
from typing import Any
910

@@ -101,21 +102,36 @@ def from_dict(cls, data: dict[str, Any]) -> Self:
101102

102103

103104
@dataclass
104-
class DataTable:
105+
class ExamplesTable:
105106
location: Location
106107
name: str | None = None
107-
tableHeader: Row | None = None
108-
tableBody: list[Row] | None = field(default_factory=list)
108+
table_header: Row | None = None
109+
table_body: list[Row] | None = field(default_factory=list)
109110

110111
@classmethod
111112
def from_dict(cls, data: dict[str, Any]) -> Self:
112113
return cls(
113114
location=Location.from_dict(data["location"]),
114115
name=data.get("name"),
115-
tableHeader=Row.from_dict(data["tableHeader"]) if data.get("tableHeader") else None,
116-
tableBody=[Row.from_dict(row) for row in data.get("tableBody", [])],
116+
table_header=Row.from_dict(data["tableHeader"]) if data.get("tableHeader") else None,
117+
table_body=[Row.from_dict(row) for row in data.get("tableBody", [])],
118+
)
119+
120+
121+
@dataclass
122+
class DataTable:
123+
location: Location
124+
rows: list[Row]
125+
126+
@classmethod
127+
def from_dict(cls, data: dict[str, Any]) -> Self:
128+
return cls(
129+
location=Location.from_dict(data["location"]), rows=[Row.from_dict(row) for row in data.get("rows", [])]
117130
)
118131

132+
def raw(self) -> Sequence[Sequence[object]]:
133+
return [[cell.value for cell in row.cells] for row in self.rows]
134+
119135

120136
@dataclass
121137
class DocString:
@@ -135,23 +151,23 @@ def from_dict(cls, data: dict[str, Any]) -> Self:
135151
@dataclass
136152
class Step:
137153
id: str
138-
keyword: str
139-
keywordType: str
140154
location: Location
155+
keyword: str
156+
keyword_type: str
141157
text: str
142-
dataTable: DataTable | None = None
143-
docString: DocString | None = None
158+
datatable: DataTable | None = None
159+
docstring: DocString | None = None
144160

145161
@classmethod
146162
def from_dict(cls, data: dict[str, Any]) -> Self:
147163
return cls(
148164
id=data["id"],
149-
keyword=data["keyword"].strip(),
150-
keywordType=data["keywordType"],
151165
location=Location.from_dict(data["location"]),
166+
keyword=data["keyword"].strip(),
167+
keyword_type=data["keywordType"],
152168
text=data["text"],
153-
dataTable=DataTable.from_dict(data["dataTable"]) if data.get("dataTable") else None,
154-
docString=DocString.from_dict(data["docString"]) if data.get("docString") else None,
169+
datatable=DataTable.from_dict(data["dataTable"]) if data.get("dataTable") else None,
170+
docstring=DocString.from_dict(data["docString"]) if data.get("docString") else None,
155171
)
156172

157173

@@ -169,33 +185,33 @@ def from_dict(cls, data: dict[str, Any]) -> Self:
169185
@dataclass
170186
class Scenario:
171187
id: str
172-
keyword: str
173188
location: Location
189+
keyword: str
174190
name: str
175191
description: str
176192
steps: list[Step]
177193
tags: list[Tag]
178-
examples: list[DataTable] = field(default_factory=list)
194+
examples: list[ExamplesTable] = field(default_factory=list)
179195

180196
@classmethod
181197
def from_dict(cls, data: dict[str, Any]) -> Self:
182198
return cls(
183199
id=data["id"],
184-
keyword=data["keyword"],
185200
location=Location.from_dict(data["location"]),
201+
keyword=data["keyword"],
186202
name=data["name"],
187203
description=data["description"],
188204
steps=[Step.from_dict(step) for step in data["steps"]],
189205
tags=[Tag.from_dict(tag) for tag in data["tags"]],
190-
examples=[DataTable.from_dict(example) for example in data["examples"]],
206+
examples=[ExamplesTable.from_dict(example) for example in data["examples"]],
191207
)
192208

193209

194210
@dataclass
195211
class Rule:
196212
id: str
197-
keyword: str
198213
location: Location
214+
keyword: str
199215
name: str
200216
description: str
201217
tags: list[Tag]
@@ -205,8 +221,8 @@ class Rule:
205221
def from_dict(cls, data: dict[str, Any]) -> Self:
206222
return cls(
207223
id=data["id"],
208-
keyword=data["keyword"],
209224
location=Location.from_dict(data["location"]),
225+
keyword=data["keyword"],
210226
name=data["name"],
211227
description=data["description"],
212228
tags=[Tag.from_dict(tag) for tag in data["tags"]],
@@ -217,8 +233,8 @@ def from_dict(cls, data: dict[str, Any]) -> Self:
217233
@dataclass
218234
class Background:
219235
id: str
220-
keyword: str
221236
location: Location
237+
keyword: str
222238
name: str
223239
description: str
224240
steps: list[Step]
@@ -227,8 +243,8 @@ class Background:
227243
def from_dict(cls, data: dict[str, Any]) -> Self:
228244
return cls(
229245
id=data["id"],
230-
keyword=data["keyword"],
231246
location=Location.from_dict(data["location"]),
247+
keyword=data["keyword"],
232248
name=data["name"],
233249
description=data["description"],
234250
steps=[Step.from_dict(step) for step in data["steps"]],
@@ -252,8 +268,8 @@ def from_dict(cls, data: dict[str, Any]) -> Self:
252268

253269
@dataclass
254270
class Feature:
255-
keyword: str
256271
location: Location
272+
keyword: str
257273
tags: list[Tag]
258274
name: str
259275
description: str
@@ -262,8 +278,8 @@ class Feature:
262278
@classmethod
263279
def from_dict(cls, data: dict[str, Any]) -> Self:
264280
return cls(
265-
keyword=data["keyword"],
266281
location=Location.from_dict(data["location"]),
282+
keyword=data["keyword"],
267283
tags=[Tag.from_dict(tag) for tag in data["tags"]],
268284
name=data["name"],
269285
description=data["description"],

src/pytest_bdd/parser.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from .exceptions import StepError
1111
from .gherkin_parser import Background as GherkinBackground
12+
from .gherkin_parser import DataTable, ExamplesTable
1213
from .gherkin_parser import Feature as GherkinFeature
1314
from .gherkin_parser import GherkinDocument
1415
from .gherkin_parser import Scenario as GherkinScenario
@@ -170,6 +171,7 @@ def render(self, context: Mapping[str, Any]) -> Scenario:
170171
indent=step.indent,
171172
line_number=step.line_number,
172173
keyword=step.keyword,
174+
datatable=step.datatable,
173175
)
174176
for step in self._steps
175177
]
@@ -225,11 +227,14 @@ class Step:
225227
line_number: int
226228
indent: int
227229
keyword: str
230+
datatable: DataTable | None = None
228231
failed: bool = field(init=False, default=False)
229232
scenario: ScenarioTemplate | None = field(init=False, default=None)
230233
background: Background | None = field(init=False, default=None)
231234

232-
def __init__(self, name: str, type: str, indent: int, line_number: int, keyword: str) -> None:
235+
def __init__(
236+
self, name: str, type: str, indent: int, line_number: int, keyword: str, datatable: DataTable | None = None
237+
) -> None:
233238
"""Initialize a step.
234239
235240
Args:
@@ -244,6 +249,7 @@ def __init__(self, name: str, type: str, indent: int, line_number: int, keyword:
244249
self.indent = indent
245250
self.line_number = line_number
246251
self.keyword = keyword
252+
self.datatable = datatable
247253

248254
def __str__(self) -> str:
249255
"""Return a string representation of the step.
@@ -342,8 +348,8 @@ def parse_steps(self, steps_data: list[GherkinStep]) -> list[Step]:
342348

343349
def get_step_content(_gherkin_step: GherkinStep) -> str:
344350
step_name = strip_comments(_gherkin_step.text)
345-
if _gherkin_step.docString:
346-
step_name = f"{step_name}\n{_gherkin_step.docString.content}"
351+
if _gherkin_step.docstring:
352+
step_name = f"{step_name}\n{_gherkin_step.docstring.content}"
347353
return step_name
348354

349355
if not steps_data:
@@ -372,6 +378,7 @@ def get_step_content(_gherkin_step: GherkinStep) -> str:
372378
indent=step.location.column - 1,
373379
line_number=step.location.line,
374380
keyword=step.keyword.title(),
381+
datatable=step.datatable,
375382
)
376383
)
377384
return steps
@@ -403,11 +410,11 @@ def parse_scenario(self, scenario_data: GherkinScenario, feature: Feature) -> Sc
403410
line_number=example_data.location.line,
404411
name=example_data.name,
405412
)
406-
if example_data.tableHeader is not None:
407-
param_names = [cell.value for cell in example_data.tableHeader.cells]
413+
if example_data.table_header is not None:
414+
param_names = [cell.value for cell in example_data.table_header.cells]
408415
examples.set_param_names(param_names)
409-
if example_data.tableBody is not None:
410-
for row in example_data.tableBody:
416+
if example_data.table_body is not None:
417+
for row in example_data.table_body:
411418
values = [cell.value or "" for cell in row.cells]
412419
examples.add_example(values)
413420
scenario.examples = examples

0 commit comments

Comments
 (0)