diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index e345de50a..7692fd91c 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -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 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a93974b25..6db118d5a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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] diff --git a/CHANGES.rst b/CHANGES.rst index 1ac34e92a..1c90f501b 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -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 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 ----------- diff --git a/README.rst b/README.rst index 5329a64fa..7bf302d5e 100644 --- a/README.rst +++ b/README.rst @@ -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 . +templates are written using corner braces as ````. `Gherkin 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 cucumbers When I eat cucumbers Then I should have 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 cucumbers When I eat cucumbers Then I should have 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 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 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 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 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 cucumbers") + @when(parsers.parse("I eat {eat:d} cucumbers")) def eat_cucumbers(start_cucumbers, start, eat): start_cucumbers["eat"] = eat - @then("I should have 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 cucumbers When I eat cucumbers Then I should have 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.+)'))) - 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 `_ -which can be used for `this `_ jenkins -plugin +which can be used for, for example, by `this `_ Jenkins +plugin. To have an output in json format: @@ -1128,11 +1107,6 @@ To have an output in json format: pytest --cucumberjson= 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= --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 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 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 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 ------- diff --git a/pyproject.toml b/pyproject.toml index 7c47b9d0f..cc1aff8d8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 diff --git a/pytest_bdd/__init__.py b/pytest_bdd/__init__.py index 1bf999917..cb4bc1f58 100644 --- a/pytest_bdd/__init__.py +++ b/pytest_bdd/__init__.py @@ -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" diff --git a/pytest_bdd/cucumber_json.py b/pytest_bdd/cucumber_json.py index 1a9458e72..07b5e6ea0 100644 --- a/pytest_bdd/cucumber_json.py +++ b/pytest_bdd/cucumber_json.py @@ -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 @@ def _serialize_tags(self, item): """ 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 @@ def stepmap(step): 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"], diff --git a/pytest_bdd/feature.py b/pytest_bdd/feature.py index 7ab386246..0cdc36cff 100644 --- a/pytest_bdd/feature.py +++ b/pytest_bdd/feature.py @@ -20,20 +20,21 @@ 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) diff --git a/pytest_bdd/gherkin_terminal_reporter.py b/pytest_bdd/gherkin_terminal_reporter.py index 97f7ae797..29f2d4deb 100644 --- a/pytest_bdd/gherkin_terminal_reporter.py +++ b/pytest_bdd/gherkin_terminal_reporter.py @@ -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 @@ def pytest_runtest_logreport(self, report): 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 diff --git a/pytest_bdd/parser.py b/pytest_bdd/parser.py index 25d54904c..8cbfbb145 100644 --- a/pytest_bdd/parser.py +++ b/pytest_bdd/parser.py @@ -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"(?") 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 @@ def add_step(self, step): @property def steps(self): - """Get scenario steps including background 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) + background = self.feature.background + return (background.steps if background else []) + self._steps + + 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 @@ def validate(self): ) +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 @@ def params(self): """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 @@ def add_example_row(self, param, values): 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 @@ def get_params(self, converters, builtin=False): 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"\<(.+?)\>") diff --git a/pytest_bdd/plugin.py b/pytest_bdd/plugin.py index 20ac37eb6..c6d42e8f9 100644 --- a/pytest_bdd/plugin.py +++ b/pytest_bdd/plugin.py @@ -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) diff --git a/pytest_bdd/reporting.py b/pytest_bdd/reporting.py index e9925708c..b6f7d9c8b 100644 --- a/pytest_bdd/reporting.py +++ b/pytest_bdd/reporting.py @@ -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 @@ def __init__(self, step): :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 @@ def serialize(self): 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 @@ def __init__(self, scenario, node): """ 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 @@ def add_step_report(self, step_report): 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 @@ def serialize(self): 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 @@ def serialize(self): "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): diff --git a/pytest_bdd/scenario.py b/pytest_bdd/scenario.py index 5a8729d53..c9446e38e 100644 --- a/pytest_bdd/scenario.py +++ b/pytest_bdd/scenario.py @@ -13,6 +13,7 @@ import collections import os import re +import typing import pytest from _pytest.fixtures import FixtureLookupError @@ -22,6 +23,11 @@ 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 decorator(*args): ) [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)) - - 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): + fixture_values = [request.getfixturevalue(arg) for arg in args] + return fn(*fixture_values) + + 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 ) diff --git a/pytest_bdd/scripts.py b/pytest_bdd/scripts.py index 5a136480a..155853286 100644 --- a/pytest_bdd/scripts.py +++ b/pytest_bdd/scripts.py @@ -8,7 +8,7 @@ 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): diff --git a/pytest_bdd/steps.py b/pytest_bdd/steps.py index df6e9543a..509ef83c3 100644 --- a/pytest_bdd/steps.py +++ b/pytest_bdd/steps.py @@ -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 diff --git a/pytest_bdd/utils.py b/pytest_bdd/utils.py index fa3866a6d..272a5fecd 100644 --- a/pytest_bdd/utils.py +++ b/pytest_bdd/utils.py @@ -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] diff --git a/tests/feature/test_background.py b/tests/feature/test_background.py index c2a88c6e9..a2477840f 100644 --- a/tests/feature/test_background.py +++ b/tests/feature/test_background.py @@ -2,7 +2,6 @@ import textwrap - FEATURE = """\ Feature: Background support diff --git a/tests/feature/test_cucumber_json.py b/tests/feature/test_cucumber_json.py index d7db5f0e3..e34208183 100644 --- a/tests/feature/test_cucumber_json.py +++ b/tests/feature/test_cucumber_json.py @@ -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 some_other_passing_step(): def a_failing_step(): raise Exception('Error') - @given('type and value ') + @given(parsers.parse('type {type} and value {value}')) def type_type_and_value_value(): return 'pass' @@ -104,6 +104,8 @@ def test_passing_outline(): ) ) result, jsonobject = runandparse(testdir) + result.assert_outcomes(passed=4, failed=1) + assert result.ret expected = [ { @@ -169,7 +171,7 @@ def test_passing_outline(): "match": {"location": ""}, "result": {"status": "passed", "duration": OfType(int)}, "keyword": "Given", - "name": "type and value ", + "name": "type str and value hello", } ], "line": 15, @@ -187,7 +189,7 @@ def test_passing_outline(): "match": {"location": ""}, "result": {"status": "passed", "duration": OfType(int)}, "keyword": "Given", - "name": "type and value ", + "name": "type int and value 42", } ], "line": 15, @@ -205,7 +207,7 @@ def test_passing_outline(): "match": {"location": ""}, "result": {"status": "passed", "duration": OfType(int)}, "keyword": "Given", - "name": "type and value ", + "name": "type float and value 1.0", } ], "line": 15, @@ -224,102 +226,3 @@ def test_passing_outline(): ] 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 and 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 and 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 and stringvalue and 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 and stringvalue and 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" diff --git a/tests/feature/test_gherkin_terminal_reporter.py b/tests/feature/test_gherkin_terminal_reporter.py index dde1ad67e..52946bfe9 100644 --- a/tests/feature/test_gherkin_terminal_reporter.py +++ b/tests/feature/test_gherkin_terminal_reporter.py @@ -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 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 cucumbers') + @when(parsers.parse('I eat {eat} cucumbers')) def eat_cucumbers(start_cucumbers, eat): pass - @then('I should have 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_scenario_2(): ) ) - 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)) diff --git a/tests/feature/test_no_sctrict_gherkin.py b/tests/feature/test_no_sctrict_gherkin.py index f4918940b..2fc41abe4 100644 --- a/tests/feature/test_no_sctrict_gherkin.py +++ b/tests/feature/test_no_sctrict_gherkin.py @@ -3,78 +3,75 @@ def test_background_no_strict_gherkin(testdir): """Test background no strict gherkin.""" - prepare_test_dir(testdir) + testdir.makepyfile( + test_gherkin=""" + import pytest - testdir.makefile( - ".feature", - no_strict_gherkin_background=""" - Feature: No strict Gherkin Background support + from pytest_bdd import when, scenario - Background: - When foo has a value "bar" - And foo is not boolean - And foo has not a value "baz" + @scenario( + "no_strict_gherkin_background.feature", + "Test background", + ) + def test_background(): + pass - Scenario: Test background - """, - ) + @pytest.fixture + def foo(): + return {} - result = testdir.runpytest("-k", "test_background_ok") - result.assert_outcomes(passed=1) + @when('foo has a value "bar"') + def bar(foo): + foo["bar"] = "bar" + return foo["bar"] -def test_scenario_no_strict_gherkin(testdir): - """Test scenario no strict gherkin.""" - prepare_test_dir(testdir) + @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 + no_strict_gherkin_background=""" + Feature: No strict Gherkin Background support - Scenario: Test scenario + 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_scenario_ok") + result = testdir.runpytest() result.assert_outcomes(passed=1) -def prepare_test_dir(testdir): +def test_scenario_no_strict_gherkin(testdir): """Test scenario no strict gherkin.""" testdir.makepyfile( test_gherkin=""" import pytest - from pytest_bdd import ( - when, - scenario, - ) - - def test_scenario_ok(request): - @scenario( - "no_strict_gherkin_scenario.feature", - "Test scenario", - ) - def test(): - pass + from pytest_bdd import when, scenario - test(request) - - def test_background_ok(request): - @scenario( - "no_strict_gherkin_background.feature", - "Test background", - ) - def test(): - pass + @scenario( + "no_strict_gherkin_scenario.feature", + "Test scenario", + ) + def test_scenario(): + pass - test(request) @pytest.fixture def foo(): @@ -96,3 +93,19 @@ 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) diff --git a/tests/feature/test_outline.py b/tests/feature/test_outline.py index 92a1d6d58..3628db8d7 100644 --- a/tests/feature/test_outline.py +++ b/tests/feature/test_outline.py @@ -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 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 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 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 other_fixture(request): @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"]], - ) - - @given("there are ", target_fixture="start_fruits") + def test_outline(): + pass + + @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 ") + @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 ") + @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 should_have_left_fruits(start_fruits, start, eat, left, fruits): """ ) ) - 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 and - Then should be the base64 encoding of + # Just print the string so that we can assert later what it was by reading the output + Given I print the 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_pipe_character(request): pass - @given("We have strings and ") - def we_have_strings_string1_and_string2(string1, string2): - pass - - - @then(" should be the base64 encoding of ") - 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 \\|", + ] diff --git a/tests/feature/test_outline_empty_values.py b/tests/feature/test_outline_empty_values.py index 0285b8086..de944211a 100644 --- a/tests/feature/test_outline_empty_values.py +++ b/tests/feature/test_outline_empty_values.py @@ -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 cucumbers") +@given(parsers.re("there are (?P.*?) cucumbers")) def start_cucumbers(start): - pass + dump_obj(start) -@when("I eat cucumbers") +@when(parsers.re("I eat (?P.*?) cucumbers")) def eat_cucumbers(eat): - pass + dump_obj(eat) -@then("I should have cucumbers") +@then(parsers.re("I should have (?P.*?) 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) == ["#", "", ""] diff --git a/tests/feature/test_parametrized.py b/tests/feature/test_parametrized.py index d650f4f6b..7c766b42e 100644 --- a/tests/feature/test_parametrized.py +++ b/tests/feature/test_parametrized.py @@ -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 cucumbers - When I eat cucumbers - Then I should have 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 foo_bar(request): @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 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 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 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 should_have_left_cucumbers(start_cucumbers, start, eat, left): """ ) ) - 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 diff --git a/tests/feature/test_report.py b/tests/feature/test_report.py index 720611032..f11f8d0f5 100644 --- a/tests/feature/test_report.py +++ b/tests/feature/test_report.py @@ -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 some_other_passing_step(): def a_failing_step(): raise Exception('Error') - @given('there are 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 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 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 should_have_left_cucumbers(start_cucumbers, start, eat, left): }, ], "tags": ["scenario-passing-tag"], - "examples": [], - "example_kwargs": {}, } assert report == expected @@ -169,12 +168,10 @@ def should_have_left_cucumbers(start_cucumbers, start, eat, left): }, ], "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 should_have_left_cucumbers(start_cucumbers, start, eat, left): "failed": False, "keyword": "Given", "line_number": 15, - "name": "there are cucumbers", + "name": "there are 12 cucumbers", "type": "given", }, { @@ -200,7 +197,7 @@ def should_have_left_cucumbers(start_cucumbers, start, eat, left): "failed": False, "keyword": "When", "line_number": 16, - "name": "I eat cucumbers", + "name": "I eat 5 cucumbers", "type": "when", }, { @@ -208,24 +205,15 @@ def should_have_left_cucumbers(start_cucumbers, start, eat, left): "failed": False, "keyword": "Then", "line_number": 17, - "name": "I should have 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 should_have_left_cucumbers(start_cucumbers, start, eat, left): "failed": False, "keyword": "Given", "line_number": 15, - "name": "there are cucumbers", + "name": "there are 5 cucumbers", "type": "given", }, { @@ -251,7 +239,7 @@ def should_have_left_cucumbers(start_cucumbers, start, eat, left): "failed": False, "keyword": "When", "line_number": 16, - "name": "I eat cucumbers", + "name": "I eat 4 cucumbers", "type": "when", }, { @@ -259,31 +247,22 @@ def should_have_left_cucumbers(start_cucumbers, start, eat, left): "failed": False, "keyword": "Then", "line_number": 17, - "name": "I should have 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 parse(cls, value): class Alien(object): pass - @given('there is a coordinate ') - 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(alien): ) ) 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) diff --git a/tests/steps/test_steps.py b/tests/steps/test_steps.py index a7eded740..f08be0cb6 100644 --- a/tests/steps/test_steps.py +++ b/tests/steps/test_steps.py @@ -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.