Rewrite scenario/feature examples logic (#445)

* rewrite examples subsitution using templating

* Remove “example_converters”

* Remove "expanded" option. It's now the default

* Add utility functions to be able to inspect tests run by the pytester.

* use better timer

* Fix typos

* Fix and simplify tests

* Update to latest python 3.10 version

* Add isort configuration and pre-commit hook

* Fix imports

* Fix types

* Update changelog

* Update README (mainly fix typos, remove outdated options)

* Fix examples in README

* Remove python2 junk

Co-authored-by: Oleg Pidsadnyi <oleg.pidsadnyi@gmail.com>
This commit is contained in:
Alessio Bogon 2021-09-23 21:07:15 +02:00 committed by GitHub
parent c6b7134e59
commit 379cb4b47c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 541 additions and 608 deletions

View File

@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.6", "3.7", "3.8", "3.9", 3.10.0-beta.4]
python-version: ["3.6", "3.7", "3.8", "3.9", 3.10.0-rc.2]
steps:
- uses: actions/checkout@v2

View File

@ -2,9 +2,14 @@
# See https://pre-commit.com/hooks.html for more hooks
repos:
- repo: https://github.com/psf/black
rev: 21.6b0
rev: 21.9b0
hooks:
- id: black
- repo: https://github.com/pycqa/isort
rev: 5.9.3
hooks:
- id: isort
name: isort (python)
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.0.1
hooks:
@ -13,7 +18,7 @@ repos:
- id: check-yaml
- id: check-added-large-files
- repo: https://github.com/asottile/pyupgrade
rev: v2.19.4
rev: v2.26.0
hooks:
- id: pyupgrade
args: [--py36-plus]

View File

@ -3,6 +3,12 @@ Changelog
Unreleased
-----------
This release introduces breaking changes, please refer to the :ref:`Migration from 4.x.x`.
- Rewrite the logic to parse Examples for Scenario Outlines. Now the substitution of the examples is done during the parsing of Gherkin feature files. You won't need to define the steps twice like ``@given("there are <start> cucumbers")`` and ``@given(parsers.parse("there are {start} cucumbers"))``. The latter will be enough.
- Removed ``example_converters`` from ``scenario(...)`` signature. You should now use just the ``converters`` parameter for ``given``, ``when``, ``then``.
- Removed ``--cucumberjson-expanded`` and ``--cucumber-json-expanded`` options. Now the JSON report is always expanded.
- Removed ``--gherkin-terminal-reporter-expanded`` option. Now the terminal report is always expanded.
4.1.0
-----------

View File

@ -109,15 +109,8 @@ test_publish_article.py:
Scenario decorator
------------------
The scenario decorator can accept the following optional keyword arguments:
* ``encoding`` - decode content of feature file in specific encoding. UTF-8 is default.
* ``example_converters`` - mapping to pass functions to convert example values provided in feature files.
Functions decorated with the `scenario` decorator behave like a normal test function,
and they will be executed after all scenario steps.
You can consider it as a normal pytest test function, e.g. order fixtures there,
call other functions and make assertions:
.. code-block:: python
@ -129,6 +122,9 @@ call other functions and make assertions:
assert article.title in browser.html
.. NOTE:: It is however encouraged to try as much as possible to have your logic only inside the Given, When, Then steps.
Step aliases
------------
@ -239,7 +235,7 @@ Example:
.. code-block:: gherkin
Feature: Step arguments
Scenario: Arguments for given, when, thens
Scenario: Arguments for given, when, then
Given there are 5 cucumbers
When I eat 3 cucumbers
@ -256,7 +252,7 @@ The code will look like:
from pytest_bdd import scenario, given, when, then, parsers
@scenario("arguments.feature", "Arguments for given, when, thens")
@scenario("arguments.feature", "Arguments for given, when, then")
def test_arguments():
pass
@ -292,7 +288,7 @@ You can implement your own step parser. It's interface is quite simple. The code
def __init__(self, name, **kwargs):
"""Compile regex."""
super(re, self).__init__(name)
super().__init__(name)
self.regex = re.compile(re.sub("%(.+)%", "(?P<\1>.+)", self.name), **kwargs)
def parse_arguments(self, name):
@ -316,9 +312,9 @@ Step arguments are fixtures as well!
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Step arguments are injected into pytest `request` context as normal fixtures with the names equal to the names of the
arguments. This opens a number of possibilies:
arguments. This opens a number of possibilities:
* you can access step's argument as a fixture in other step function just by mentioning it as an argument (just like any othe pytest fixture)
* you can access step's argument as a fixture in other step function just by mentioning it as an argument (just like any other pytest fixture)
* if the name of the step argument clashes with existing fixture, it will be overridden by step's argument value; this way you can set/override the value for some fixture deeply inside of the fixture tree in a ad-hoc way by just choosing the proper name for the step argument.
@ -433,7 +429,7 @@ step arguments and capture lines after first line (or some subset of them) into
import re
from pytest_bdd import given, then, scenario
from pytest_bdd import given, then, scenario, parsers
@scenario(
@ -454,7 +450,7 @@ step arguments and capture lines after first line (or some subset of them) into
assert i_have_text == text == 'Some\nExtra\nLines'
Note that `then` step definition (`text_should_be_correct`) in this example uses `text` fixture which is provided
by a a `given` step (`i_have_text`) argument with the same name (`text`). This possibility is described in
by a `given` step (`i_have_text`) argument with the same name (`text`). This possibility is described in
the `Step arguments are fixtures as well!`_ section.
@ -508,7 +504,7 @@ Scenario outlines
-----------------
Scenarios can be parametrized to cover few cases. In Gherkin the variable
templates are written using corner braces as <somevalue>.
templates are written using corner braces as ``<somevalue>``.
`Gherkin scenario outlines <http://behat.org/en/v3.0/user_guide/writing_scenarios.html#scenario-outlines>`_ are supported by pytest-bdd
exactly as it's described in be behave_ docs.
@ -517,7 +513,7 @@ Example:
.. code-block:: gherkin
Feature: Scenario outlines
Scenario Outline: Outlined given, when, thens
Scenario Outline: Outlined given, when, then
Given there are <start> cucumbers
When I eat <eat> cucumbers
Then I should have <left> cucumbers
@ -532,7 +528,7 @@ pytest-bdd feature file format also supports example tables in different way:
.. code-block:: gherkin
Feature: Scenario outlines
Scenario Outline: Outlined given, when, thens
Scenario Outline: Outlined given, when, then
Given there are <start> cucumbers
When I eat <eat> cucumbers
Then I should have <left> cucumbers
@ -549,31 +545,30 @@ The code will look like:
.. code-block:: python
from pytest_bdd import given, when, then, scenario
from pytest_bdd import given, when, then, scenario, parsers
@scenario(
"outline.feature",
"Outlined given, when, thens",
example_converters=dict(start=int, eat=float, left=str)
"Outlined given, when, then",
)
def test_outlined():
pass
@given("there are <start> cucumbers", target_fixture="start_cucumbers")
@given(parsers.parse("there are {start:d} cucumbers", target_fixture="start_cucumbers"))
def start_cucumbers(start):
assert isinstance(start, int)
return dict(start=start)
@when("I eat <eat> cucumbers")
@when(parsers.parse("I eat {eat:g} cucumbers"))
def eat_cucumbers(start_cucumbers, eat):
assert isinstance(eat, float)
start_cucumbers["eat"] = eat
@then("I should have <left> cucumbers")
@then(parsers.parse("I should have {left} cucumbers"))
def should_have_left_cucumbers(start_cucumbers, start, eat, left):
assert isinstance(left, str)
assert start - eat == int(left)
@ -654,7 +649,7 @@ The code will look like:
.. code-block:: python
import pytest
from pytest_bdd import scenario, given, when, then
from pytest_bdd import scenario, given, when, then, parsers
# Here we use pytest to parametrize the test with the parameters table
@ -664,7 +659,7 @@ The code will look like:
)
@scenario(
"parametrized.feature",
"Parametrized given, when, thens",
"Parametrized given, when, then",
)
# Note that we should take the same arguments in the test function that we use
# for the test parametrization either directly or indirectly (fixtures depend on them).
@ -672,17 +667,17 @@ The code will look like:
"""We don't need to do anything here, everything will be managed by the scenario decorator."""
@given("there are <start> cucumbers", target_fixture="start_cucumbers")
@given(parsers.parse("there are {start:d} cucumbers"), target_fixture="start_cucumbers")
def start_cucumbers(start):
return dict(start=start)
@when("I eat <eat> cucumbers")
@when(parsers.parse("I eat {eat:d} cucumbers"))
def eat_cucumbers(start_cucumbers, start, eat):
start_cucumbers["eat"] = eat
@then("I should have <left> cucumbers")
@then(parsers.parse("I should have {left:d} cucumbers"))
def should_have_left_cucumbers(start_cucumbers, start, eat, left):
assert start - eat == left
assert start_cucumbers["start"] == start
@ -694,7 +689,7 @@ With a parametrized.feature file:
.. code-block:: gherkin
Feature: parametrized
Scenario: Parametrized given, when, thens
Scenario: Parametrized given, when, then
Given there are <start> cucumbers
When I eat <eat> cucumbers
Then I should have <left> cucumbers
@ -773,12 +768,12 @@ scenario test, so we can use standard test selection:
pytest -m "backend and login and successful"
The feature and scenario markers are not different from standard pytest markers, and the `@` symbol is stripped out
The feature and scenario markers are not different from standard pytest markers, and the ``@`` symbol is stripped out
automatically to allow test selector expressions. If you want to have bdd-related tags to be distinguishable from the
other test markers, use prefix like `bdd`.
Note that if you use pytest `--strict` option, all bdd tags mentioned in the feature files should be also in the
`markers` setting of the `pytest.ini` config. Also for tags please use names which are python-compartible variable
names, eg starts with a non-number, underscore alphanumberic, etc. That way you can safely use tags for tests filtering.
`markers` setting of the `pytest.ini` config. Also for tags please use names which are python-compatible variable
names, eg starts with a non-number, underscore alphanumeric, etc. That way you can safely use tags for tests filtering.
You can customize how tags are converted to pytest marks by implementing the
``pytest_bdd_apply_tag`` hook and returning ``True`` from it:
@ -791,7 +786,7 @@ You can customize how tags are converted to pytest marks by implementing the
marker(function)
return True
else:
# Fall back to pytest-bdd's default behavior
# Fall back to the default behavior of pytest-bdd
return None
Test setup
@ -978,23 +973,7 @@ test_common.py:
pass
There are no definitions of the steps in the test file. They were
collected from the parent conftests.
Using unicode in the feature files
----------------------------------
As mentioned above, by default, utf-8 encoding is used for parsing feature files.
For steps definition, you should use unicode strings, which is the default in python 3.
If you are on python 2, make sure you use unicode strings by prefixing them with the `u` sign.
.. code-block:: python
@given(parsers.re(u"у мене є рядок який містить '{0}'".format(u'(?P<content>.+)')))
def there_is_a_string_with_content(content, string):
"""Create string with unicode content."""
string["content"] = content
collected from the parent conftest.py.
Default steps
@ -1050,7 +1029,7 @@ The `features_base_dir` parameter can also be passed to the `@scenario` decorato
Avoid retyping the feature file name
------------------------------------
If you want to avoid retyping the feature file name when defining your scenarios in a test file, use functools.partial.
If you want to avoid retyping the feature file name when defining your scenarios in a test file, use ``functools.partial``.
This will make your life much easier when defining multiple scenarios in a test file. For example:
test_publish_article.py:
@ -1118,8 +1097,8 @@ Reporting
It's important to have nice reporting out of your bdd tests. Cucumber introduced some kind of standard for
`json format <https://www.relishapp.com/cucumber/cucumber/docs/json-output-formatter>`_
which can be used for `this <https://wiki.jenkins-ci.org/display/JENKINS/Cucumber+Test+Result+Plugin>`_ jenkins
plugin
which can be used for, for example, by `this <https://plugins.jenkins.io/cucumber-testresult-plugin/>`_ Jenkins
plugin.
To have an output in json format:
@ -1128,11 +1107,6 @@ To have an output in json format:
pytest --cucumberjson=<path to json report>
This will output an expanded (meaning scenario outlines will be expanded to several scenarios) cucumber format.
To also fill in parameters in the step name, you have to explicitly tell pytest-bdd to use the expanded format:
::
pytest --cucumberjson=<path to json report> --cucumberjson-expanded
To enable gherkin-formatted output on terminal, use
@ -1141,14 +1115,6 @@ To enable gherkin-formatted output on terminal, use
pytest --gherkin-terminal-reporter
Terminal reporter supports expanded format as well
::
pytest --gherkin-terminal-reporter-expanded
Test code generation helpers
----------------------------
@ -1208,6 +1174,51 @@ As as side effect, the tool will validate the files for format errors, also some
ordering of the types of the steps.
.. _Migration from 4.x.x:
Migration of your tests from versions 4.x.x
-------------------------------------------
Templated steps (e.g. ``@given("there are <start> cucumbers")``) should now the use step argument parsers in order to match the scenario outlines and get the values from the example tables. The values from the example tables are no longer passed as fixtures, although if you define your step to use a parser, the parameters will be still provided as fixtures.
.. code-block:: python
# Old step definition:
@given("there are <start> cucumbers")
def given_cucumbers(start):
pass
# New step definition:
@given(parsers.parse("there are {start} cucumbers"))
def given_cucumbers(start):
pass
Scenario `example_converters` are removed in favor of the converters provided on the step level:
.. code-block:: python
# Old code:
@given("there are <start> cucumbers")
def given_cucumbers(start):
return {"start": start}
@scenario("outline.feature", "Outlined", example_converters={"start": float})
def test_outline():
pass
# New code:
@given(parsers.parse("there are {start} cucumbers"), converters={"start": float})
def given_cucumbers(start):
return {"start": start}
@scenario("outline.feature", "Outlined")
def test_outline():
pass
.. _Migration from 3.x.x:
Migration of your tests from versions 3.x.x
@ -1240,7 +1251,6 @@ as well as ``bdd_strict_gherkin`` from the ini files.
Step validation handlers for the hook ``pytest_bdd_step_validation_error`` should be removed.
License
-------

View File

@ -5,3 +5,8 @@ build-backend = "setuptools.build_meta"
[tool.black]
line-length = 120
target-version = ['py36', 'py37', 'py38']
[tool.isort]
profile = "black"
line_length = 120
multi_line_output = 3

View File

@ -1,7 +1,7 @@
"""pytest-bdd public API."""
from pytest_bdd.steps import given, when, then
from pytest_bdd.scenario import scenario, scenarios
from pytest_bdd.steps import given, then, when
__version__ = "4.1.0"

View File

@ -19,21 +19,12 @@ def add_options(parser):
help="create cucumber json style report file at given path.",
)
group._addoption(
"--cucumberjson-expanded",
"--cucumber-json-expanded",
action="store_true",
dest="expand",
default=False,
help="expand scenario outlines into scenarios and fill in the step names",
)
def configure(config):
cucumber_json_path = config.option.cucumber_json_path
# prevent opening json log on worker nodes (xdist)
if cucumber_json_path and not hasattr(config, "workerinput"):
config._bddcucumberjson = LogBDDCucumberJSON(cucumber_json_path, expand=config.option.expand)
config._bddcucumberjson = LogBDDCucumberJSON(cucumber_json_path)
config.pluginmanager.register(config._bddcucumberjson)
@ -48,11 +39,10 @@ class LogBDDCucumberJSON:
"""Logging plugin for cucumber like json output."""
def __init__(self, logfile, expand=False):
def __init__(self, logfile):
logfile = os.path.expanduser(os.path.expandvars(logfile))
self.logfile = os.path.normpath(os.path.abspath(logfile))
self.features = {}
self.expand = expand
def append(self, obj):
self.features[-1].append(obj)
@ -88,23 +78,6 @@ class LogBDDCucumberJSON:
"""
return [{"name": tag, "line": item["line_number"] - 1} for tag in item["tags"]]
def _format_name(self, name, keys, values):
for param, value in zip(keys, values):
name = name.replace(f"<{param}>", str(value))
return name
def _format_step_name(self, report, step):
examples = report.scenario["examples"]
if len(examples) == 0:
return step["name"]
# we take the keys from the first "examples", but in each table, the keys should
# be the same anyway since all the variables need to be filled in.
keys, values = examples[0]["rows"]
row_index = examples[0]["row_index"]
return self._format_name(step["name"], keys, values[row_index])
def pytest_runtest_logreport(self, report):
try:
scenario = report.scenario
@ -122,13 +95,7 @@ class LogBDDCucumberJSON:
scenario["failed"] = True
error_message = True
if self.expand:
# XXX The format is already 'expanded' (scenario oultines -> scenarios),
# but the step names were not filled in with parameters. To be backwards
# compatible, do not fill in the step names unless explicitly asked for.
step_name = self._format_step_name(report, step)
else:
step_name = step["name"]
step_name = step["name"]
return {
"keyword": step["keyword"],

View File

@ -20,20 +20,21 @@ Syntax example:
And the article should be published # Note: will query the database
:note: The "#" symbol is used for comments.
:note: There're no multiline steps, the description of the step must fit in
:note: There are no multiline steps, the description of the step must fit in
one line.
"""
import os.path
import typing
import glob2
from .parser import parse_feature
from .parser import Feature, parse_feature
# Global features dictionary
features = {}
features: typing.Dict[str, Feature] = {}
def get_feature(base_path, filename, encoding="utf-8"):
def get_feature(base_path: str, filename: str, encoding: str = "utf-8") -> Feature:
"""Get a feature by the filename.
:param str base_path: Base feature directory.
@ -55,7 +56,7 @@ def get_feature(base_path, filename, encoding="utf-8"):
return feature
def get_features(paths, **kwargs):
def get_features(paths: typing.List[str], **kwargs) -> typing.List[Feature]:
"""Get features for given paths.
:param list paths: `list` of paths (file or dirs)

View File

@ -1,9 +1,5 @@
import re
from _pytest.terminal import TerminalReporter
from .parser import STEP_PARAM_RE
def add_options(parser):
group = parser.getgroup("terminal reporting", "reporting", after="general")
@ -12,14 +8,7 @@ def add_options(parser):
action="store_true",
dest="gherkin_terminal_reporter",
default=False,
help=("enable gherkin output"),
)
group._addoption(
"--gherkin-terminal-reporter-expanded",
action="store_true",
dest="expand",
default=False,
help="expand scenario outlines into scenarios and fill in the step names",
help="enable gherkin output",
)
@ -93,24 +82,9 @@ class GherkinTerminalReporter(TerminalReporter):
self._tw.write(report.scenario["name"], **scenario_markup)
self._tw.write("\n")
for step in report.scenario["steps"]:
if self.config.option.expand:
step_name = self._format_step_name(step["name"], **report.scenario["example_kwargs"])
else:
step_name = step["name"]
self._tw.write(" {} {}\n".format(step["keyword"], step_name), **scenario_markup)
self._tw.write(f" {step['keyword']} {step['name']}\n", **scenario_markup)
self._tw.write(" " + word, **word_markup)
self._tw.write("\n\n")
else:
return TerminalReporter.pytest_runtest_logreport(self, rep)
self.stats.setdefault(cat, []).append(rep)
def _format_step_name(self, step_name, **example_kwargs):
while True:
param_match = re.search(STEP_PARAM_RE, step_name)
if not param_match:
break
param_token = param_match.group(0)
param_name = param_match.group(1)
param_value = example_kwargs[param_name]
step_name = step_name.replace(param_token, param_value)
return step_name

View File

@ -1,12 +1,13 @@
import io
import os.path
import re
import textwrap
import typing
from collections import OrderedDict
from . import types, exceptions
from . import exceptions, types
SPLIT_LINE_RE = re.compile(r"(?<!\\)\|")
STEP_PARAM_RE = re.compile(r"<(.+?)>")
COMMENT_RE = re.compile(r"(^|(?<=\s))#")
STEP_PREFIXES = [
("Feature: ", types.FEATURE),
@ -73,7 +74,7 @@ def get_step_type(line):
return _type
def parse_feature(basedir, filename, encoding="utf-8"):
def parse_feature(basedir: str, filename: str, encoding: str = "utf-8") -> "Feature":
"""Parse the feature file.
:param str basedir: Feature files base directory.
@ -93,10 +94,10 @@ def parse_feature(basedir, filename, encoding="utf-8"):
background=None,
description="",
)
scenario = None
scenario: typing.Optional[ScenarioTemplate] = None
mode = None
prev_mode = None
description = []
description: typing.List[str] = []
step = None
multiline_step = False
prev_line = None
@ -149,7 +150,9 @@ def parse_feature(basedir, filename, encoding="utf-8"):
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 = Scenario(feature, parsed_line, line_number, tags=tags)
feature.scenarios[parsed_line] = scenario = 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:
@ -199,42 +202,35 @@ class Feature:
"""Feature."""
def __init__(self, scenarios, filename, rel_filename, name, tags, examples, background, line_number, description):
self.scenarios = scenarios
self.scenarios: typing.Dict[str, ScenarioTemplate] = scenarios
self.rel_filename = rel_filename
self.filename = filename
self.name = name
self.tags = tags
self.examples = examples
self.name = name
self.line_number = line_number
self.tags = tags
self.scenarios = scenarios
self.description = description
self.background = background
class Scenario:
class ScenarioTemplate:
"""A scenario template.
"""Scenario."""
Created when parsing the feature file, it will then be combined with the examples to create a Scenario."""
def __init__(self, feature, name, line_number, example_converters=None, tags=None):
"""Scenario constructor.
def __init__(self, feature: Feature, name: str, line_number: int, tags=None):
"""
:param pytest_bdd.parser.Feature feature: Feature.
:param str name: Scenario name.
:param int line_number: Scenario line number.
:param dict example_converters: Example table parameter converters.
:param set tags: Set of tags.
"""
self.feature = feature
self.name = name
self._steps = []
self._steps: typing.List[Step] = []
self.examples = Examples()
self.line_number = line_number
self.example_converters = example_converters
self.tags = tags or set()
self.failed = False
self.test_function = None
def add_step(self, step):
"""Add step to the scenario.
@ -246,41 +242,29 @@ class Scenario:
@property
def steps(self):
"""Get scenario steps including background steps.
background = self.feature.background
return (background.steps if background else []) + self._steps
:return: List of steps.
"""
result = []
if self.feature.background:
result.extend(self.feature.background.steps)
result.extend(self._steps)
return result
@property
def params(self):
"""Get parameter names.
:return: Parameter names.
:rtype: frozenset
"""
return frozenset(sum((list(step.params) for step in self.steps), []))
def get_example_params(self):
"""Get example parameter names."""
return set(self.examples.example_params + self.feature.examples.example_params)
def get_params(self, builtin=False):
"""Get converted example params."""
for examples in [self.feature.examples, self.examples]:
yield examples.get_params(self.example_converters, builtin=builtin)
def render(self, context: typing.Mapping[str, typing.Any]) -> "Scenario":
steps = [
Step(
name=templated_step.render(context),
type=templated_step.type,
indent=templated_step.indent,
line_number=templated_step.line_number,
keyword=templated_step.keyword,
)
for templated_step in self.steps
]
return Scenario(feature=self.feature, name=self.name, line_number=self.line_number, steps=steps, tags=self.tags)
def validate(self):
"""Validate the scenario.
:raises ScenarioValidationError: when scenario is not valid
"""
params = self.params
example_params = self.get_example_params()
params = frozenset(sum((list(step.params) for step in self.steps), []))
example_params = set(self.examples.example_params + 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. """
@ -290,6 +274,26 @@ class Scenario:
)
class Scenario:
"""Scenario."""
def __init__(self, feature: Feature, name: str, line_number: int, steps: "typing.List[Step]", tags=None):
"""Scenario constructor.
:param pytest_bdd.parser.Feature feature: Feature.
:param str name: Scenario name.
:param int line_number: Scenario line number.
:param set tags: Set of tags.
"""
self.feature = feature
self.name = name
self.steps = steps
self.line_number = line_number
self.tags = tags or set()
self.failed = False
class Step:
"""Step."""
@ -352,6 +356,13 @@ class Step:
"""Get step params."""
return tuple(frozenset(STEP_PARAM_RE.findall(self.name)))
def render(self, context: typing.Mapping[str, typing.Any]):
def replacer(m: typing.Match):
varname = m.group(1)
return str(context[varname])
return STEP_PARAM_RE.sub(replacer, self.name)
class Background:
@ -412,11 +423,7 @@ class Examples:
self.example_params.append(param)
self.vertical_examples.append(values)
def get_params(self, converters, builtin=False):
"""Get scenario pytest parametrization table.
:param converters: `dict` of converter functions to convert parameter values
"""
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])):
@ -425,20 +432,15 @@ class Examples:
example.append(self.vertical_examples[param_index][value_index])
self.examples.append(example)
if self.examples:
params = []
for example in self.examples:
example = list(example)
for index, param in enumerate(self.example_params):
raw_value = example[index]
if converters and param in converters:
value = converters[param](raw_value)
if not builtin or value.__class__.__module__ in {"__builtin__", "builtins"}:
example[index] = value
params.append(example)
return [self.example_params, params]
else:
return []
if not self.examples:
return
header, rows = self.example_params, self.examples
for row in rows:
assert len(header) == len(row)
yield dict(zip(header, row))
def __bool__(self):
"""Bool comparison."""
@ -455,6 +457,3 @@ def get_tags(line):
if not line or not line.strip().startswith("@"):
return set()
return {tag.lstrip("@") for tag in line.strip().split(" @") if len(tag) > 1}
STEP_PARAM_RE = re.compile(r"\<(.+?)\>")

View File

@ -2,11 +2,7 @@
import pytest
from . import cucumber_json
from . import generation
from . import gherkin_terminal_reporter
from . import given, when, then
from . import reporting
from . import cucumber_json, generation, gherkin_terminal_reporter, given, reporting, then, when
from .utils import CONFIG_STACK
@ -25,6 +21,20 @@ def trace():
pytest.set_trace()
@pytest.fixture
def _pytest_bdd_example():
"""The current scenario outline parametrization.
This is used internally by pytest_bdd.
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
from the example definitions in the feature file.
"""
return {}
def pytest_addoption(parser):
"""Add pytest-bdd options."""
add_bdd_ini(parser)

View File

@ -1,16 +1,14 @@
"""Reporting functionality.
Collection of the scenario excecution statuses, timing and other information
Collection of the scenario execution statuses, timing and other information
that enriches the pytest test reporting.
"""
import time
from .utils import get_parametrize_markers_args
class StepReport:
"""Step excecution report."""
"""Step execution report."""
failed = False
stopped = None
@ -21,12 +19,12 @@ class StepReport:
:param pytest_bdd.parser.Step step: Step.
"""
self.step = step
self.started = time.time()
self.started = time.perf_counter()
def serialize(self):
"""Serialize the step excecution report.
"""Serialize the step execution report.
:return: Serialized step excecution report.
:return: Serialized step execution report.
:rtype: dict
"""
return {
@ -41,16 +39,16 @@ class StepReport:
def finalize(self, failed):
"""Stop collecting information and finalize the report.
:param bool failed: Wheither the step excecution is failed.
:param bool failed: Whether the step execution is failed.
"""
self.stopped = time.time()
self.stopped = time.perf_counter()
self.failed = failed
@property
def duration(self):
"""Step excecution duration.
"""Step execution duration.
:return: Step excecution duration.
:return: Step execution duration.
:rtype: float
"""
if self.stopped is None:
@ -70,21 +68,6 @@ class ScenarioReport:
"""
self.scenario = scenario
self.step_reports = []
self.param_index = None
parametrize_args = get_parametrize_markers_args(node)
if parametrize_args and scenario.examples:
param_names = (
parametrize_args[0] if isinstance(parametrize_args[0], (tuple, list)) else [parametrize_args[0]]
)
param_values = parametrize_args[1]
node_param_values = [node.funcargs[param_name] for param_name in param_names]
if node_param_values in param_values:
self.param_index = param_values.index(node_param_values)
elif tuple(node_param_values) in param_values:
self.param_index = param_values.index(tuple(node_param_values))
self.example_kwargs = {
example_param: str(node.funcargs[example_param]) for example_param in scenario.get_example_params()
}
@property
def current_step_report(self):
@ -104,7 +87,7 @@ class ScenarioReport:
self.step_reports.append(step_report)
def serialize(self):
"""Serialize scenario excecution report in order to transfer reportin from nodes in the distributed mode.
"""Serialize scenario execution report in order to transfer reporting from nodes in the distributed mode.
:return: Serialized report.
:rtype: dict
@ -112,7 +95,6 @@ class ScenarioReport:
scenario = self.scenario
feature = scenario.feature
params = sum(scenario.get_params(builtin=True), []) if scenario.examples else None
return {
"steps": [step_report.serialize() for step_report in self.step_reports],
"name": scenario.name,
@ -126,17 +108,6 @@ class ScenarioReport:
"description": feature.description,
"tags": sorted(feature.tags),
},
"examples": [
{
"name": scenario.examples.name,
"line_number": scenario.examples.line_number,
"rows": params,
"row_index": self.param_index,
}
]
if scenario.examples
else [],
"example_kwargs": self.example_kwargs,
}
def fail(self):

View File

@ -13,6 +13,7 @@ test_publish_article = scenario(
import collections
import os
import re
import typing
import pytest
from _pytest.fixtures import FixtureLookupError
@ -22,6 +23,11 @@ from .feature import get_feature, get_features
from .steps import get_step_fixture_name, inject_fixture
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")
ALPHA_REGEX = re.compile(r"^\d+_*")
@ -38,6 +44,8 @@ def find_argumented_step_fixture_name(name, type_, fixturemanager, request=None)
if not match:
continue
# TODO: maybe `converters` should be part of the SterParser.__init__(),
# and used by StepParser.parse_arguments() method
converters = getattr(fixturedef.func, "converters", {})
for arg, value in parser.parse_arguments(name).items():
if arg in converters:
@ -113,7 +121,7 @@ def _execute_step_function(request, scenario, step, step_func):
raise
def _execute_scenario(feature, scenario, request):
def _execute_scenario(feature: "Feature", scenario: "Scenario", request):
"""Execute the scenario.
:param feature: Feature.
@ -141,7 +149,9 @@ def _execute_scenario(feature, scenario, request):
FakeRequest = collections.namedtuple("FakeRequest", ["module"])
def _get_scenario_decorator(feature, feature_name, scenario, scenario_name):
def _get_scenario_decorator(
feature: "Feature", feature_name: str, templated_scenario: "ScenarioTemplate", scenario_name: str
):
# HACK: Ideally we would use `def decorator(fn)`, but we want to return a custom exception
# when the decorator is misused.
# Pytest inspect the signature to determine the required fixtures, and in that case it would look
@ -155,39 +165,62 @@ def _get_scenario_decorator(feature, feature_name, scenario, scenario_name):
)
[fn] = args
args = get_args(fn)
function_args = list(args)
for arg in scenario.get_example_params():
if arg not in function_args:
function_args.append(arg)
@pytest.mark.usefixtures(*function_args)
def scenario_wrapper(request):
# We need to tell pytest that the original function requires its fixtures,
# otherwise indirect fixtures would not work.
@pytest.mark.usefixtures(*args)
def scenario_wrapper(request, _pytest_bdd_example):
scenario = templated_scenario.render(_pytest_bdd_example)
_execute_scenario(feature, scenario, request)
return fn(*(request.getfixturevalue(arg) for arg in args))
fixture_values = [request.getfixturevalue(arg) for arg in args]
return fn(*fixture_values)
for param_set in scenario.get_params():
if param_set:
scenario_wrapper = pytest.mark.parametrize(*param_set)(scenario_wrapper)
for tag in scenario.tags.union(feature.tags):
example_parametrizations = collect_example_parametrizations(templated_scenario)
if example_parametrizations is not None:
# Parametrize the scenario outlines
scenario_wrapper = pytest.mark.parametrize(
"_pytest_bdd_example",
example_parametrizations,
)(scenario_wrapper)
for tag in templated_scenario.tags.union(feature.tags):
config = CONFIG_STACK[-1]
config.hook.pytest_bdd_apply_tag(tag=tag, function=scenario_wrapper)
scenario_wrapper.__doc__ = f"{feature_name}: {scenario_name}"
scenario_wrapper.__scenario__ = scenario
scenario.test_function = scenario_wrapper
scenario_wrapper.__scenario__ = templated_scenario
return scenario_wrapper
return decorator
def scenario(feature_name, scenario_name, encoding="utf-8", example_converters=None, features_base_dir=None):
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.
:param str feature_name: Feature file name. Absolute or relative to the configured feature base path.
:param str scenario_name: Scenario name.
:param str encoding: Feature file encoding.
:param dict example_converters: optional `dict` of example converter function, where key is the name of the
example parameter, and value is the converter function.
"""
scenario_name = str(scenario_name)
@ -207,13 +240,11 @@ def scenario(feature_name, scenario_name, encoding="utf-8", example_converters=N
f'Scenario "{scenario_name}" in feature "{feature_name}" in {feature.filename} is not found.'
)
scenario.example_converters = example_converters
# Validate the scenario
scenario.validate()
return _get_scenario_decorator(
feature=feature, feature_name=feature_name, scenario=scenario, scenario_name=scenario_name
feature=feature, feature_name=feature_name, templated_scenario=scenario, scenario_name=scenario_name
)

View File

@ -8,7 +8,7 @@ import glob2
from .generation import generate_code, parse_feature_files
MIGRATE_REGEX = re.compile(r"\s?(\w+)\s\=\sscenario\((.+)\)", flags=re.MULTILINE)
MIGRATE_REGEX = re.compile(r"\s?(\w+)\s=\sscenario\((.+)\)", flags=re.MULTILINE)
def migrate_tests(args):

View File

@ -36,11 +36,10 @@ def given_beautiful_article(article):
"""
import pytest
from _pytest.fixtures import FixtureDef
from .types import GIVEN, WHEN, THEN
from .parsers import get_parser
from .types import GIVEN, THEN, WHEN
from .utils import get_caller_module_locals

View File

@ -1,9 +1,14 @@
"""Various utility functions."""
from inspect import getframeinfo
from inspect import signature as _signature
import base64
import pickle
import re
import typing
from inspect import getframeinfo, signature
from sys import _getframe
if typing.TYPE_CHECKING:
from _pytest.pytester import RunResult
CONFIG_STACK = []
@ -15,14 +20,10 @@ def get_args(func):
:return: A list of argument names.
:rtype: list
"""
params = _signature(func).parameters.values()
params = signature(func).parameters.values()
return [param.name for param in params if param.kind == param.POSITIONAL_OR_KEYWORD]
def get_parametrize_markers_args(node):
return tuple(arg for mark in node.iter_markers("parametrize") for arg in mark.args)
def get_caller_module_locals(depth=2):
"""Get the caller module locals dictionary.
@ -40,3 +41,26 @@ def get_caller_module_path(depth=2):
"""
frame = _getframe(depth)
return getframeinfo(frame, context=0).filename
_DUMP_START = "_pytest_bdd_>>>"
_DUMP_END = "<<<_pytest_bdd_"
def dump_obj(*objects):
"""Dump objects to stdout so that they can be inspected by the test suite."""
for obj in objects:
dump = pickle.dumps(obj, protocol=pickle.HIGHEST_PROTOCOL)
encoded = base64.b64encode(dump).decode("ascii")
print(f"{_DUMP_START}{encoded}{_DUMP_END}")
def collect_dumped_objects(result: "RunResult"):
"""Parse all the objects dumped with `dump_object` from the result.
Note: You must run the result with output to stdout enabled.
For example, using ``testdir.runpytest("-s")``.
"""
stdout = result.stdout.str() # pytest < 6.2, otherwise we could just do str(result.stdout)
payloads = re.findall(rf"{_DUMP_START}(.*?){_DUMP_END}", stdout)
return [pickle.loads(base64.b64decode(payload)) for payload in payloads]

View File

@ -2,7 +2,6 @@
import textwrap
FEATURE = """\
Feature: Background support

View File

@ -71,7 +71,7 @@ def test_step_trace(testdir):
textwrap.dedent(
"""
import pytest
from pytest_bdd import given, when, scenario
from pytest_bdd import given, when, scenario, parsers
@given('a passing step')
def a_passing_step():
@ -85,7 +85,7 @@ def test_step_trace(testdir):
def a_failing_step():
raise Exception('Error')
@given('type <type> and value <value>')
@given(parsers.parse('type {type} and value {value}'))
def type_type_and_value_value():
return 'pass'
@ -104,6 +104,8 @@ def test_step_trace(testdir):
)
)
result, jsonobject = runandparse(testdir)
result.assert_outcomes(passed=4, failed=1)
assert result.ret
expected = [
{
@ -169,7 +171,7 @@ def test_step_trace(testdir):
"match": {"location": ""},
"result": {"status": "passed", "duration": OfType(int)},
"keyword": "Given",
"name": "type <type> and value <value>",
"name": "type str and value hello",
}
],
"line": 15,
@ -187,7 +189,7 @@ def test_step_trace(testdir):
"match": {"location": ""},
"result": {"status": "passed", "duration": OfType(int)},
"keyword": "Given",
"name": "type <type> and value <value>",
"name": "type int and value 42",
}
],
"line": 15,
@ -205,7 +207,7 @@ def test_step_trace(testdir):
"match": {"location": ""},
"result": {"status": "passed", "duration": OfType(int)},
"keyword": "Given",
"name": "type <type> and value <value>",
"name": "type float and value 1.0",
}
],
"line": 15,
@ -224,102 +226,3 @@ def test_step_trace(testdir):
]
assert jsonobject == expected
def test_step_trace_with_expand_option(testdir):
"""Test step trace."""
testdir.makefile(
".ini",
pytest=textwrap.dedent(
"""
[pytest]
markers =
feature-tag
scenario-outline-passing-tag
"""
),
)
testdir.makefile(
".feature",
test=textwrap.dedent(
"""
@feature-tag
Feature: One scenario outline, expanded to multiple scenarios
@scenario-outline-passing-tag
Scenario: Passing outline
Given type <type> and value <value>
Examples: example1
| type | value |
| str | hello |
| int | 42 |
| float | 1.0 |
"""
),
)
testdir.makepyfile(
textwrap.dedent(
"""
import pytest
from pytest_bdd import given, scenario
@given('type <type> and value <value>')
def type_type_and_value_value():
return 'pass'
@scenario('test.feature', 'Passing outline')
def test_passing_outline():
pass
"""
)
)
result, jsonobject = runandparse(testdir, "--cucumber-json-expanded")
result.assert_outcomes(passed=3)
assert jsonobject[0]["elements"][0]["steps"][0]["name"] == "type str and value hello"
assert jsonobject[0]["elements"][1]["steps"][0]["name"] == "type int and value 42"
assert jsonobject[0]["elements"][2]["steps"][0]["name"] == "type float and value 1.0"
def test_converters_dict_with_expand_option(testdir):
"""Test that `--cucumber-json-expanded` works correctly when using `example_converters`."""
testdir.makefile(
".feature",
test=textwrap.dedent(
"""
Feature: Expanded option with example converters
Scenario: Passing outline
Given there is an intvalue <intvalue> and stringvalue <stringvalue> and floatvalue <floatvalue>
Examples: example1
| intvalue | stringvalue | floatvalue |
| 1 | hello | 1.0 |
"""
),
)
testdir.makepyfile(
textwrap.dedent(
"""
import pytest
from pytest_bdd import given, scenario
@given('there is an intvalue <intvalue> and stringvalue <stringvalue> and floatvalue <floatvalue>')
def type_type_and_value_value():
pass
@scenario(
'test.feature',
'Passing outline',
example_converters={"intvalue":int, "stringvalue":str, "floatvalue":float},
)
def test_passing_outline():
pass
"""
)
)
result, jsonobject = runandparse(testdir, "--cucumber-json-expanded")
assert result.ret == 0
expanded_step_name = jsonobject[0]["elements"][0]["steps"][0]["name"]
assert expanded_step_name == "there is an intvalue 1 and stringvalue hello and floatvalue 1.0"

View File

@ -1,6 +1,6 @@
import textwrap
import pytest
import pytest
FEATURE = """\
Feature: Gherkin terminal output feature
@ -204,18 +204,18 @@ def test_step_parameters_should_be_replaced_by_their_values(testdir):
testdir.makepyfile(
test_gherkin=textwrap.dedent(
"""\
from pytest_bdd import given, when, scenario, then
from pytest_bdd import given, when, scenario, then, parsers
@given('there are <start> cucumbers', target_fixture="start_cucumbers")
@given(parsers.parse('there are {start} cucumbers'), target_fixture="start_cucumbers")
def start_cucumbers(start):
return start
@when('I eat <eat> cucumbers')
@when(parsers.parse('I eat {eat} cucumbers'))
def eat_cucumbers(start_cucumbers, eat):
pass
@then('I should have <left> cucumbers')
def should_have_left_cucumbers(start_cucumbers, start, eat, left):
@then(parsers.parse('I should have {left} cucumbers'))
def should_have_left_cucumbers(start_cucumbers, left):
pass
@scenario('test.feature', 'Scenario example 2')
@ -225,7 +225,7 @@ def test_step_parameters_should_be_replaced_by_their_values(testdir):
)
)
result = testdir.runpytest("--gherkin-terminal-reporter", "--gherkin-terminal-reporter-expanded", "-vv")
result = testdir.runpytest("--gherkin-terminal-reporter", "-vv")
result.assert_outcomes(passed=1, failed=0)
result.stdout.fnmatch_lines("*Scenario: Scenario example 2")
result.stdout.fnmatch_lines("*Given there are {start} cucumbers".format(**example))

View File

@ -3,78 +3,19 @@
def test_background_no_strict_gherkin(testdir):
"""Test background no strict gherkin."""
prepare_test_dir(testdir)
testdir.makefile(
".feature",
no_strict_gherkin_background="""
Feature: No strict Gherkin Background support
Background:
When foo has a value "bar"
And foo is not boolean
And foo has not a value "baz"
Scenario: Test background
""",
)
result = testdir.runpytest("-k", "test_background_ok")
result.assert_outcomes(passed=1)
def test_scenario_no_strict_gherkin(testdir):
"""Test scenario no strict gherkin."""
prepare_test_dir(testdir)
testdir.makefile(
".feature",
no_strict_gherkin_scenario="""
Feature: No strict Gherkin Scenario support
Scenario: Test scenario
When foo has a value "bar"
And foo is not boolean
And foo has not a value "baz"
""",
)
result = testdir.runpytest("-k", "test_scenario_ok")
result.assert_outcomes(passed=1)
def prepare_test_dir(testdir):
"""Test scenario no strict gherkin."""
testdir.makepyfile(
test_gherkin="""
import pytest
from pytest_bdd import (
when,
scenario,
from pytest_bdd import when, scenario
@scenario(
"no_strict_gherkin_background.feature",
"Test background",
)
def test_background():
pass
def test_scenario_ok(request):
@scenario(
"no_strict_gherkin_scenario.feature",
"Test scenario",
)
def test():
pass
test(request)
def test_background_ok(request):
@scenario(
"no_strict_gherkin_background.feature",
"Test background",
)
def test():
pass
test(request)
@pytest.fixture
def foo():
@ -96,3 +37,75 @@ def prepare_test_dir(testdir):
assert "baz" not in foo
"""
)
testdir.makefile(
".feature",
no_strict_gherkin_background="""
Feature: No strict Gherkin Background support
Background:
When foo has a value "bar"
And foo is not boolean
And foo has not a value "baz"
Scenario: Test background
""",
)
result = testdir.runpytest()
result.assert_outcomes(passed=1)
def test_scenario_no_strict_gherkin(testdir):
"""Test scenario no strict gherkin."""
testdir.makepyfile(
test_gherkin="""
import pytest
from pytest_bdd import when, scenario
@scenario(
"no_strict_gherkin_scenario.feature",
"Test scenario",
)
def test_scenario():
pass
@pytest.fixture
def foo():
return {}
@when('foo has a value "bar"')
def bar(foo):
foo["bar"] = "bar"
return foo["bar"]
@when('foo is not boolean')
def not_boolean(foo):
assert foo is not bool
@when('foo has not a value "baz"')
def has_not_baz(foo):
assert "baz" not in foo
"""
)
testdir.makefile(
".feature",
no_strict_gherkin_scenario="""
Feature: No strict Gherkin Scenario support
Scenario: Test scenario
When foo has a value "bar"
And foo is not boolean
And foo has not a value "baz"
""",
)
result = testdir.runpytest()
result.assert_outcomes(passed=1)

View File

@ -1,27 +1,32 @@
"""Scenario Outline tests."""
import textwrap
from pytest_bdd.utils import collect_dumped_objects
from tests.utils import assert_outcomes
STEPS = """\
from pytest_bdd import given, when, then
from pytest_bdd import parsers, given, when, then
from pytest_bdd.utils import dump_obj
@given("there are <start> cucumbers", target_fixture="start_cucumbers")
@given(parsers.parse("there are {start:d} cucumbers"), target_fixture="start_cucumbers")
def start_cucumbers(start):
assert isinstance(start, int)
return dict(start=start)
dump_obj(start)
return {"start": start}
@when("I eat <eat> cucumbers")
@when(parsers.parse("I eat {eat:g} cucumbers"))
def eat_cucumbers(start_cucumbers, eat):
assert isinstance(eat, float)
dump_obj(eat)
start_cucumbers["eat"] = eat
@then("I should have <left> cucumbers")
@then(parsers.parse("I should have {left} cucumbers"))
def should_have_left_cucumbers(start_cucumbers, start, eat, left):
assert isinstance(left, str)
dump_obj(left)
assert start - eat == int(left)
assert start_cucumbers["start"] == start
assert start_cucumbers["eat"] == eat
@ -54,28 +59,26 @@ def test_outlined(testdir):
testdir.makepyfile(
textwrap.dedent(
"""\
from pytest_bdd.utils import get_parametrize_markers_args
from pytest_bdd import scenario
@scenario(
"outline.feature",
"Outlined given, when, thens",
example_converters=dict(start=int, eat=float, left=str)
)
def test_outline(request):
assert get_parametrize_markers_args(request.node) == (
["start", "eat", "left"],
[
[12, 5.0, "7"],
[5, 4.0, "1"],
],
)
pass
"""
)
)
result = testdir.runpytest()
result = testdir.runpytest("-s")
result.assert_outcomes(passed=2)
# fmt: off
assert collect_dumped_objects(result) == [
12, 5.0, "7",
5, 4.0, "1",
]
# fmt: on
def test_wrongly_outlined(testdir):
@ -227,7 +230,6 @@ def test_outlined_with_other_fixtures(testdir):
textwrap.dedent(
"""\
import pytest
from pytest_bdd.utils import get_parametrize_markers_args
from pytest_bdd import scenario
@ -239,7 +241,6 @@ def test_outlined_with_other_fixtures(testdir):
@scenario(
"outline.feature",
"Outlined given, when, thens",
example_converters=dict(start=int, eat=float, left=str)
)
def test_outline(other_fixture):
pass
@ -277,28 +278,26 @@ def test_vertical_example(testdir):
testdir.makepyfile(
textwrap.dedent(
"""\
from pytest_bdd.utils import get_parametrize_markers_args
from pytest_bdd import scenario
@scenario(
"outline.feature",
"Outlined with vertical example table",
example_converters=dict(start=int, eat=float, left=str)
)
def test_outline(request):
assert get_parametrize_markers_args(request.node) == (
["start", "eat", "left"],
[
[12, 5.0, "7"],
[2, 1.0, "1"],
],
)
def test_outline():
pass
"""
)
)
result = testdir.runpytest()
result = testdir.runpytest("-s")
result.assert_outcomes(passed=2)
parametrizations = collect_dumped_objects(result)
# fmt: off
assert parametrizations == [
12, 5.0, "7",
2, 1.0, "1",
]
# fmt: on
def test_outlined_feature(testdir):
@ -329,36 +328,36 @@ def test_outlined_feature(testdir):
testdir.makepyfile(
textwrap.dedent(
"""\
from pytest_bdd.utils import get_parametrize_markers_args
from pytest_bdd import given, when, then, scenario
from pytest_bdd import given, when, then, scenario, parsers
from pytest_bdd.utils import dump_obj
@scenario(
"outline.feature",
"Outlined given, when, thens",
example_converters=dict(start=int, eat=float, left=str)
)
def test_outline(request):
assert get_parametrize_markers_args(request.node) == (
["start", "eat", "left"],
[[12, 5.0, "7"], [5, 4.0, "1"]],
["fruits"],
[["oranges"], ["apples"]],
)
def test_outline():
pass
@given("there are <start> <fruits>", target_fixture="start_fruits")
@given(parsers.parse("there are {start:d} {fruits}"), target_fixture="start_fruits")
def start_fruits(start, fruits):
dump_obj(start, fruits)
assert isinstance(start, int)
return {fruits: dict(start=start)}
@when("I eat <eat> <fruits>")
@when(parsers.parse("I eat {eat:g} {fruits}"))
def eat_fruits(start_fruits, eat, fruits):
dump_obj(eat, fruits)
assert isinstance(eat, float)
start_fruits[fruits]["eat"] = eat
@then("I should have <left> <fruits>")
@then(parsers.parse("I should have {left} {fruits}"))
def should_have_left_fruits(start_fruits, start, eat, left, fruits):
dump_obj(left, fruits)
assert isinstance(left, str)
assert start - eat == int(left)
assert start_fruits[fruits]["start"] == start
@ -367,8 +366,17 @@ def test_outlined_feature(testdir):
"""
)
)
result = testdir.runpytest()
result = testdir.runpytest("-s")
result.assert_outcomes(passed=4)
parametrizations = collect_dumped_objects(result)
# fmt: off
assert parametrizations == [
12, "oranges", 5.0, "oranges", "7", "oranges",
12, "apples", 5.0, "apples", "7", "apples",
5, "oranges", 4.0, "oranges", "1", "oranges",
5, "apples", 4.0, "apples", "1", "apples",
]
# fmt: on
def test_outline_with_escaped_pipes(testdir):
@ -380,18 +388,18 @@ def test_outline_with_escaped_pipes(testdir):
Feature: Outline With Special characters
Scenario Outline: Outline with escaped pipe character
Given We have strings <string1> and <string2>
Then <string2> should be the base64 encoding of <string1>
# Just print the string so that we can assert later what it was by reading the output
Given I print the <string>
Examples:
| string1 | string2 |
| bork | Ym9yaw== |
| \|bork | fGJvcms= |
| bork \| | Ym9yayB8 |
| bork\|\|bork | Ym9ya3x8Ym9yaw== |
| \| | fA== |
| bork \\ | Ym9yayAgICAgIFxc |
| bork \\\| | Ym9yayAgICBcXHw= |
| string |
| bork |
| \|bork |
| bork \| |
| bork\|\|bork |
| \| |
| bork \\ |
| bork \\\| |
"""
),
)
@ -399,10 +407,8 @@ def test_outline_with_escaped_pipes(testdir):
testdir.makepyfile(
textwrap.dedent(
"""\
import base64
from pytest_bdd import scenario, given, when, then
from pytest_bdd.utils import get_parametrize_markers_args
from pytest_bdd import scenario, given, parsers
from pytest_bdd.utils import dump_obj
@scenario("outline.feature", "Outline with escaped pipe character")
@ -410,17 +416,20 @@ def test_outline_with_escaped_pipes(testdir):
pass
@given("We have strings <string1> and <string2>")
def we_have_strings_string1_and_string2(string1, string2):
pass
@then("<string2> should be the base64 encoding of <string1>")
def string2_should_be_base64_encoding_of_string1(string2, string1):
assert string1.encode() == base64.b64decode(string2.encode())
@given(parsers.parse("I print the {string}"))
def i_print_the_string(string):
dump_obj(string)
"""
)
)
result = testdir.runpytest()
result = testdir.runpytest("-s")
result.assert_outcomes(passed=7)
assert collect_dumped_objects(result) == [
r"bork",
r"|bork",
r"bork |",
r"bork||bork",
r"|",
r"bork \\",
r"bork \\|",
]

View File

@ -1,24 +1,27 @@
"""Scenario Outline with empty example values tests."""
import textwrap
from pytest_bdd.utils import collect_dumped_objects
STEPS = """\
from pytest_bdd import given, when, then
from pytest_bdd import given, when, then, parsers
from pytest_bdd.utils import dump_obj
# Using `parsers.re` so that we can match empty values
@given("there are <start> cucumbers")
@given(parsers.re("there are (?P<start>.*?) cucumbers"))
def start_cucumbers(start):
pass
dump_obj(start)
@when("I eat <eat> cucumbers")
@when(parsers.re("I eat (?P<eat>.*?) cucumbers"))
def eat_cucumbers(eat):
pass
dump_obj(eat)
@then("I should have <left> cucumbers")
@then(parsers.re("I should have (?P<left>.*?) cucumbers"))
def should_have_left_cucumbers(left):
pass
dump_obj(left)
"""
@ -45,18 +48,19 @@ def test_scenario_with_empty_example_values(testdir):
testdir.makepyfile(
textwrap.dedent(
"""\
from pytest_bdd.utils import get_parametrize_markers_args
from pytest_bdd.utils import dump_obj
from pytest_bdd import scenario
import json
@scenario("outline.feature", "Outlined with empty example values")
def test_outline(request):
assert get_parametrize_markers_args(request.node) == ([u"start", u"eat", u"left"], [["#", "", ""]])
def test_outline():
pass
"""
)
)
result = testdir.runpytest()
result = testdir.runpytest("-s")
result.assert_outcomes(passed=1)
assert collect_dumped_objects(result) == ["#", "", ""]
def test_scenario_with_empty_example_values_vertical(testdir):
@ -82,15 +86,15 @@ def test_scenario_with_empty_example_values_vertical(testdir):
testdir.makepyfile(
textwrap.dedent(
"""\
from pytest_bdd.utils import get_parametrize_markers_args
from pytest_bdd.utils import dump_obj
from pytest_bdd import scenario
@scenario("outline.feature", "Outlined with empty example values vertical")
def test_outline(request):
assert get_parametrize_markers_args(request.node) == ([u"start", u"eat", u"left"], [["#", "", ""]])
def test_outline():
pass
"""
)
)
result = testdir.runpytest()
result = testdir.runpytest("-s")
result.assert_outcomes(passed=1)
assert collect_dumped_objects(result) == ["#", "", ""]

View File

@ -1,5 +1,7 @@
import textwrap
from pytest_bdd.utils import collect_dumped_objects
def test_parametrized(testdir):
"""Test parametrized scenario."""
@ -9,9 +11,9 @@ def test_parametrized(testdir):
"""\
Feature: Parametrized scenario
Scenario: Parametrized given, when, thens
Given there are <start> cucumbers
When I eat <eat> cucumbers
Then I should have <left> cucumbers
Given there are {start} cucumbers
When I eat {eat} cucumbers
Then I should have {left} cucumbers
"""
),
)
@ -20,14 +22,10 @@ def test_parametrized(testdir):
textwrap.dedent(
"""\
import pytest
from pytest_bdd import given, when, then, scenario
from pytest_bdd import given, when, then, scenario, parsers
from pytest_bdd.utils import dump_obj
@pytest.mark.parametrize(["start", "eat", "left"], [(12, 5, 7)])
@scenario("parametrized.feature", "Parametrized given, when, thens")
def test_parametrized(request, start, eat, left):
pass
@pytest.fixture(params=[1, 2])
def foo_bar(request):
return "bar" * request.param
@ -35,21 +33,31 @@ def test_parametrized(testdir):
@pytest.mark.parametrize(["start", "eat", "left"], [(12, 5, 7)])
@scenario("parametrized.feature", "Parametrized given, when, thens")
def test_parametrized(request, start, eat, left):
pass
@pytest.mark.parametrize(["start", "eat", "left"], [(2, 1, 1)])
@scenario("parametrized.feature", "Parametrized given, when, thens")
def test_parametrized_with_other_fixtures(request, start, eat, left, foo_bar):
pass
@given("there are <start> cucumbers", target_fixture="start_cucumbers")
@given(parsers.parse("there are {start} cucumbers"), target_fixture="start_cucumbers")
def start_cucumbers(start):
dump_obj(start)
return dict(start=start)
@when("I eat <eat> cucumbers")
@when(parsers.parse("I eat {eat} cucumbers"))
def eat_cucumbers(start_cucumbers, start, eat):
dump_obj(eat)
start_cucumbers["eat"] = eat
@then("I should have <left> cucumbers")
@then(parsers.parse("I should have {left} cucumbers"))
def should_have_left_cucumbers(start_cucumbers, start, eat, left):
dump_obj(left)
assert start - eat == left
assert start_cucumbers["start"] == start
assert start_cucumbers["eat"] == eat
@ -57,5 +65,15 @@ def test_parametrized(testdir):
"""
)
)
result = testdir.runpytest()
result = testdir.runpytest("-s")
result.assert_outcomes(passed=3)
parametrizations = collect_dumped_objects(result)
# fmt: off
assert parametrizations == [
12, 5, 7,
# The second test uses is duplicated because of the `foo_bar` indirect fixture
2, 1, 1,
2, 1, 1,
]
# fmt: on

View File

@ -62,7 +62,7 @@ def test_step_trace(testdir):
textwrap.dedent(
"""
import pytest
from pytest_bdd import given, when, then, scenarios
from pytest_bdd import given, when, then, scenarios, parsers
@given('a passing step')
def a_passing_step():
@ -76,26 +76,27 @@ def test_step_trace(testdir):
def a_failing_step():
raise Exception('Error')
@given('there are <start> cucumbers', target_fixture="start_cucumbers")
@given(parsers.parse('there are {start:d} cucumbers'), target_fixture="start_cucumbers")
def start_cucumbers(start):
assert isinstance(start, int)
return dict(start=start)
return {"start": start}
@when('I eat <eat> cucumbers')
@when(parsers.parse('I eat {eat:g} cucumbers'))
def eat_cucumbers(start_cucumbers, eat):
assert isinstance(eat, float)
start_cucumbers['eat'] = eat
@then('I should have <left> cucumbers')
@then(parsers.parse('I should have {left} cucumbers'))
def should_have_left_cucumbers(start_cucumbers, start, eat, left):
assert isinstance(left, str)
assert start - eat == int(left)
assert start_cucumbers['start'] == start
assert start_cucumbers['eat'] == eat
scenarios('test.feature', example_converters=dict(start=int, eat=float, left=str))
scenarios('test.feature')
"""
)
)
@ -132,8 +133,6 @@ def test_step_trace(testdir):
},
],
"tags": ["scenario-passing-tag"],
"examples": [],
"example_kwargs": {},
}
assert report == expected
@ -169,12 +168,10 @@ def test_step_trace(testdir):
},
],
"tags": ["scenario-failing-tag"],
"examples": [],
"example_kwargs": {},
}
assert report == expected
report = result.matchreport("test_outlined[12-5.0-7]", when="call").scenario
report = result.matchreport("test_outlined[12-5-7]", when="call").scenario
expected = {
"feature": {
"description": "",
@ -192,7 +189,7 @@ def test_step_trace(testdir):
"failed": False,
"keyword": "Given",
"line_number": 15,
"name": "there are <start> cucumbers",
"name": "there are 12 cucumbers",
"type": "given",
},
{
@ -200,7 +197,7 @@ def test_step_trace(testdir):
"failed": False,
"keyword": "When",
"line_number": 16,
"name": "I eat <eat> cucumbers",
"name": "I eat 5 cucumbers",
"type": "when",
},
{
@ -208,24 +205,15 @@ def test_step_trace(testdir):
"failed": False,
"keyword": "Then",
"line_number": 17,
"name": "I should have <left> cucumbers",
"name": "I should have 7 cucumbers",
"type": "then",
},
],
"tags": [],
"examples": [
{
"line_number": 19,
"name": None,
"row_index": 0,
"rows": [["start", "eat", "left"], [[12, 5.0, "7"], [5, 4.0, "1"]]],
}
],
"example_kwargs": {"eat": "5.0", "left": "7", "start": "12"},
}
assert report == expected
report = result.matchreport("test_outlined[5-4.0-1]", when="call").scenario
report = result.matchreport("test_outlined[5-4-1]", when="call").scenario
expected = {
"feature": {
"description": "",
@ -243,7 +231,7 @@ def test_step_trace(testdir):
"failed": False,
"keyword": "Given",
"line_number": 15,
"name": "there are <start> cucumbers",
"name": "there are 5 cucumbers",
"type": "given",
},
{
@ -251,7 +239,7 @@ def test_step_trace(testdir):
"failed": False,
"keyword": "When",
"line_number": 16,
"name": "I eat <eat> cucumbers",
"name": "I eat 4 cucumbers",
"type": "when",
},
{
@ -259,31 +247,22 @@ def test_step_trace(testdir):
"failed": False,
"keyword": "Then",
"line_number": 17,
"name": "I should have <left> cucumbers",
"name": "I should have 1 cucumbers",
"type": "then",
},
],
"tags": [],
"examples": [
{
"line_number": 19,
"name": None,
"row_index": 1,
"rows": [["start", "eat", "left"], [[12, 5.0, "7"], [5, 4.0, "1"]]],
}
],
"example_kwargs": {"eat": "4.0", "left": "1", "start": "5"},
}
assert report == expected
def test_complex_types(testdir):
def test_complex_types(testdir, pytestconfig):
"""Test serialization of the complex types."""
try:
import execnet.gateway_base
except ImportError:
if not pytestconfig.pluginmanager.has_plugin("xdist"):
pytest.skip("Execnet not installed")
import execnet.gateway_base
testdir.makefile(
".feature",
test=textwrap.dedent(
@ -303,9 +282,9 @@ def test_complex_types(testdir):
textwrap.dedent(
"""
import pytest
from pytest_bdd import given, when, then, scenario
from pytest_bdd import given, when, then, scenario, parsers
class Point(object):
class Point:
def __init__(self, x, y):
self.x = x
@ -318,14 +297,18 @@ def test_complex_types(testdir):
class Alien(object):
pass
@given('there is a coordinate <point>')
def point(point):
@given(
parsers.parse('there is a coordinate {point}'),
target_fixture="point",
converters={"point": Point.parse},
)
def given_there_is_a_point(point):
assert isinstance(point, Point)
return point
@pytest.mark.parametrize('alien', [Alien()])
@scenario('test.feature', 'Complex', example_converters=dict(point=Point.parse))
@scenario('test.feature', 'Complex')
def test_complex(alien):
pass
@ -333,6 +316,7 @@ def test_complex_types(testdir):
)
)
result = testdir.inline_run("-vvl")
report = result.matchreport("test_complex[point0-alien0]", when="call")
report = result.matchreport("test_complex[10,20-alien0]", when="call")
assert report.passed
assert execnet.gateway_base.dumps(report.item)
assert execnet.gateway_base.dumps(report.scenario)

View File

@ -1,8 +1,9 @@
"""Test when and then steps are callables."""
import pytest
import textwrap
import pytest
def test_when_then(testdir):
"""Test when and then steps are callable functions.