code cleanup. vertical example table implemented

This commit is contained in:
Anatoly Bubenkov 2014-03-15 01:05:21 +01:00
parent c0ae33a241
commit 482482b059
12 changed files with 270 additions and 145 deletions

View File

@ -5,7 +5,9 @@ Changelog
-----
- Pure pytest parametrization for scenario outlines (bubenkoff)
- Splitting scenario decorated and non-decorated variants (bubenkoff)
- Argumented steps now support converters (transformations) (bubenkoff)
- scenario supports only decorator form (bubenkoff)
- Code generation refactoring and cleanup (bubenkoff)
1.0.0

View File

@ -23,7 +23,7 @@ containing the side effects of the Gherkin imperative declarations.
Install pytest-bdd
==================
------------------
::
@ -31,7 +31,7 @@ Install pytest-bdd
Example
=======
-------
publish\_article.feature:
@ -54,7 +54,9 @@ test\_publish\_article.py:
from pytest_bdd import scenario, given, when, then
test_publish = scenario('publish_article.feature', 'Publishing the article')
@scenario('publish_article.feature', 'Publishing the article')
def test_publish():
pass
@given('I have an article')
@ -85,7 +87,7 @@ test\_publish\_article.py:
Step aliases
============
------------
Sometimes it is needed to declare the same fixtures or steps with the
different names for better readability. In order to use the same step
@ -118,7 +120,7 @@ default author.
Step arguments
==============
--------------
Often it's possible to reuse steps giving them a parameter(s).
This allows to have single implementation and multiple use, so less code.
@ -145,7 +147,11 @@ The code will look like:
import re
from pytest_bdd import scenario, given, when, then
test_arguments = scenario('arguments.feature', 'Arguments for given, when, thens')
@scenario('arguments.feature', 'Arguments for given, when, thens')
def test_arguments():
pass
@given(re.compile('there are (?P<start>\d+) cucumbers'), converters=dict(start=int))
def start_cucumbers(start):
@ -167,15 +173,15 @@ different than strings.
Scenario parameters
===================
Scenario function/decorator can accept such optional keyword arguments:
-------------------
Scenario decorator can accept such 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.
Scenario outlines
=================
-----------------
Scenarios can be parametrized to cover few cases. In Gherkin the variable
templates are written using corner braces as <somevalue>.
@ -195,6 +201,24 @@ Example:
| start | eat | left |
| 12 | 5 | 7 |
pytest-bdd feature file format also supports example tables in different way:
.. code-block:: feature
Scenario Outline: Outlined given, when, thens
Given there are <start> cucumbers
When I eat <eat> cucumbers
Then I should have <left> cucumbers
Examples: Vertical
| start | 12 | 2 |
| eat | 5 | 1 |
| left | 7 | 1 |
This form allows to have tables with lots of columns keeping the maximum text width predictable without significant
readability change.
The code will look like:
@ -203,11 +227,13 @@ The code will look like:
from pytest_bdd import given, when, then, scenario
test_outlined = scenario(
@scenario(
'outline.feature',
'Outlined given, when, thens',
example_converters=dict(start=int, eat=float, left=str)
)
def test_outlined():
pass
@given('there are <start> cucumbers')
@ -242,6 +268,7 @@ The code will look like:
import pytest
from pytest_bdd import mark, given, when, then
# Here we use pytest to parametrize the test with the parameters table
@pytest.mark.parametrize(
['start', 'eat', 'left'],
@ -276,7 +303,7 @@ The significant downside of this approach is inability to see the test table fro
Test setup
==========
----------
Test setup is implemented within the Given section. Even though these steps
are executed imperatively to apply possible side-effects, pytest-bdd is trying
@ -366,7 +393,7 @@ Will raise an exception if the step is using the regular expression pattern.
Reusing fixtures
================
----------------
Sometimes scenarios define new names for the fixture that can be
inherited. Fixtures can be reused with other names using given():
@ -377,7 +404,7 @@ inherited. Fixtures can be reused with other names using given():
Reusing steps
=============
-------------
It is possible to define some common steps in the parent conftest.py and
simply expect them in the child test file.
@ -410,14 +437,16 @@ test\_common.py:
.. code-block:: python
test_conftest = scenario('common_steps.feature', 'All steps are declared in the conftest')
@scenario('common_steps.feature', 'All steps are declared in the conftest')
def test_conftest():
pass
There are no definitions of the steps in the test file. They were
collected from the parent conftests.
Feature file paths
==================
------------------
But default, pytest-bdd will use current modules path as base path for
finding feature files, but this behaviour can be changed by having
@ -436,11 +465,14 @@ test\_publish\_article.py:
def pytestbdd_feature_base_dir():
return '/home/user/projects/foo.bar/features'
test_publish = scenario('publish_article.feature', 'Publishing the article')
@scenario('publish_article.feature', 'Publishing the article')
def test_publish():
pass
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.
This will make your life much easier when defining multiple scenarios in a test file.
@ -459,15 +491,22 @@ test\_publish\_article.py:
scenario = partial(pytest_bdd.scenario, '/path/to/publish_article.feature')
test_publish = scenario('Publishing the article')
test_publish_unprivileged = scenario('Publishing the article as unprivileged user')
@scenario('Publishing the article')
def test_publish():
pass
@scenario('Publishing the article as unprivileged user')
def test_publish_unprivileged():
pass
You can learn more about `functools.partial <http://docs.python.org/2/library/functools.html#functools.partial>`_ in the Python docs.
Hooks
=====
-----
pytest-bdd exposes several pytest `hooks <http://pytest.org/latest/plugins.html#well-specified-hooks>`_
which might be helpful building useful reporting, visualization, etc on top of it:
@ -488,7 +527,15 @@ which might be helpful building useful reporting, visualization, etc on top of i
Browser testing
===============
---------------
Tools recommended to use for browser testing:
* pytest-splinter - pytest splinter integration for the real browser testing
Migration of your tests from versions 0.x.x-1.x.x
-------------------------------------------------
Tools recommended to use for browser testing:
@ -496,7 +543,7 @@ Tools recommended to use for browser testing:
License
=======
-------
This software is licensed under the `MIT license <http://en.wikipedia.org/wiki/MIT_License>`_.

25
pytest_bdd/exceptions.py Normal file
View File

@ -0,0 +1,25 @@
"""pytest-bdd Exceptions."""
class ScenarioIsDecoratorOnly(Exception):
"""Scenario can be only used as decorator."""
class ScenarioValidationError(Exception):
"""Base class for scenario validation."""
class ScenarioNotFound(ScenarioValidationError): # pragma: no cover
"""Scenario Not Found"""
class ScenarioExamplesNotValidError(ScenarioValidationError): # pragma: no cover
"""Scenario steps argumets do not match declared scenario examples."""
class StepTypeError(ScenarioValidationError): # pragma: no cover
"""Step definition is not of the type expected in the scenario."""
class GivenAlreadyUsed(ScenarioValidationError): # pragma: no cover
"""Fixture that implements the Given has been already used."""

View File

@ -26,10 +26,8 @@ one line.
import re # pragma: no cover
import sys # pragma: no cover
from pytest_bdd.types import (
FEATURE, SCENARIO, SCENARIO_OUTLINE, EXAMPLES, EXAMPLES_HEADERS, EXAMPLE_LINE, GIVEN, WHEN,
THEN # pragma: no cover
)
from pytest_bdd import types # pragma: no cover
from pytest_bdd import exceptions # pragma: no cover
class FeatureError(Exception): # pragma: no cover
@ -48,16 +46,17 @@ class FeatureError(Exception): # pragma: no cover
features = {} # pragma: no cover
STEP_PREFIXES = { # pragma: no cover
'Feature: ': FEATURE,
'Scenario Outline: ': SCENARIO_OUTLINE,
'Examples:': EXAMPLES,
'Scenario: ': SCENARIO,
'Given ': GIVEN,
'When ': WHEN,
'Then ': THEN,
'And ': None, # Unknown step type
}
STEP_PREFIXES = [ # pragma: no cover
('Feature: ', types.FEATURE),
('Scenario Outline: ', types.SCENARIO_OUTLINE),
('Examples: Vertical', types.EXAMPLES_VERTICAL),
('Examples:', types.EXAMPLES),
('Scenario: ', types.SCENARIO),
('Given ', types.GIVEN),
('When ', types.WHEN),
('Then ', types.THEN),
('And ', None), # Unknown step type
]
COMMENT_SYMBOLS = '#' # pragma: no cover
@ -70,9 +69,9 @@ def get_step_type(line):
:param line: Line of the Feature file
:return: SCENARIO, GIVEN, WHEN, THEN, or `None` if can't be detected.
"""
for prefix in STEP_PREFIXES:
for prefix, _type in STEP_PREFIXES:
if line.startswith(prefix):
return STEP_PREFIXES[prefix]
return _type
def get_step_params(name):
@ -104,7 +103,7 @@ def remove_prefix(line):
:return: Line without the prefix.
"""
for prefix in STEP_PREFIXES:
for prefix, _ in STEP_PREFIXES:
if line.startswith(prefix):
return line[len(prefix):].strip()
return line
@ -156,20 +155,21 @@ class Feature(object):
continue
mode = get_step_type(line) or mode
if mode == GIVEN and prev_mode not in (GIVEN, SCENARIO, SCENARIO_OUTLINE):
if mode == types.GIVEN and prev_mode not in (types.GIVEN, types.SCENARIO, types.SCENARIO_OUTLINE):
raise FeatureError('Given steps must be the first in withing the Scenario',
line_number, line)
if mode == WHEN and prev_mode not in (SCENARIO, SCENARIO_OUTLINE, GIVEN, WHEN):
if mode == types.WHEN and prev_mode not in (
types.SCENARIO, types.SCENARIO_OUTLINE, types.GIVEN, types.WHEN):
raise FeatureError('When steps must be the first or follow Given steps',
line_number, line)
if mode == THEN and prev_mode not in (GIVEN, WHEN, THEN):
if mode == types.THEN and prev_mode not in (types.GIVEN, types.WHEN, types.THEN):
raise FeatureError('Then steps must follow Given or When steps',
line_number, line)
if mode == FEATURE:
if prev_mode != FEATURE:
if mode == types.FEATURE:
if prev_mode != types.FEATURE:
self.name = remove_prefix(line)
else:
description.append(line)
@ -178,16 +178,21 @@ class Feature(object):
# Remove Feature, Given, When, Then, And
line = remove_prefix(line)
if mode in [SCENARIO, SCENARIO_OUTLINE]:
self.scenarios[line] = scenario = Scenario(line)
elif mode == EXAMPLES:
mode = EXAMPLES_HEADERS
elif mode == EXAMPLES_HEADERS:
if mode in [types.SCENARIO, types.SCENARIO_OUTLINE]:
self.scenarios[line] = scenario = Scenario(self, line)
elif mode == types.EXAMPLES:
mode = types.EXAMPLES_HEADERS
elif mode == types.EXAMPLES_VERTICAL:
mode = types.EXAMPLE_LINE_VERTICAL
elif mode == types.EXAMPLES_HEADERS:
scenario.set_param_names([l.strip() for l in line.split('|') if l.strip()])
mode = EXAMPLE_LINE
elif mode == EXAMPLE_LINE:
mode = types.EXAMPLE_LINE
elif mode == types.EXAMPLE_LINE:
scenario.add_example([l.strip() for l in line.split('|') if l.strip()])
elif mode and mode != FEATURE:
elif mode == types.EXAMPLE_LINE_VERTICAL:
line = [l.strip() for l in line.split('|') if l.strip()]
scenario.add_example_row(line[0], line[1:])
elif mode and mode != types.FEATURE:
scenario.add_step(step_name=line, step_type=mode)
self.description = u'\n'.join(description)
@ -215,12 +220,15 @@ class Feature(object):
class Scenario(object):
"""Scenario."""
def __init__(self, name):
def __init__(self, feature, name, example_converters=None):
self.feature = feature
self.name = name
self.params = set()
self.steps = []
self.example_params = []
self.examples = []
self.vertical_examples = []
self.example_converters = example_converters
def add_step(self, step_name, step_type):
"""Add step to the scenario.
@ -249,6 +257,58 @@ class Scenario(object):
"""
self.examples.append(values)
def add_example_row(self, param, values):
"""Add example row.
:param param: `str` parameter name
:param values: `list` of `string` parameter values
"""
if param in self.example_params:
raise exceptions.ScenarioExamplesNotValidError(
"""Scenario "{0}" in the feature "{1}" has not valid examples. """
"""Example rows should contain unique parameters. {2} appeared more than once.""".format(
self.name, self.feature.filename, param,
)
)
self.example_params.append(param)
self.vertical_examples.append(values)
def get_params(self):
"""Get scenario pytest parametrization table."""
param_count = len(self.example_params)
if self.vertical_examples and not self.examples:
for value_index in range(len(self.vertical_examples[0])):
example = []
for param_index in range(param_count):
example.append(self.vertical_examples[param_index][value_index])
self.examples.append(example)
if self.examples:
params = []
for example in self.examples:
for index, param in enumerate(self.example_params):
if self.example_converters and param in self.example_converters:
example[index] = self.example_converters[param](example[index])
params.append(example)
return [self.example_params, params]
else:
return []
def validate(self):
"""Validate the scenario.
:raises: `ScenarioValidationError`
"""
if self.params and self.example_params and self.params != set(self.example_params):
raise exceptions.ScenarioExamplesNotValidError(
"""Scenario "{0}" in the feature "{1}" has not valid examples. """
"""Set of step parameters {2} should match set of example values {3}.""".format(
self.name, self.feature.filename, sorted(self.params), sorted(self.example_params),
)
)
class Step(object):
"""Step."""

View File

@ -26,34 +26,11 @@ from _pytest import python
from pytest_bdd.feature import Feature, force_encode # pragma: no cover
from pytest_bdd.steps import execute, recreate_function, get_caller_module, get_caller_function
from pytest_bdd.types import GIVEN
from pytest_bdd import exceptions
from pytest_bdd import plugin
class ScenarioIsDecoratorOnly(Exception):
"""Scenario can be only used as decorator."""
class ScenarioValidationError(Exception):
"""Base class for scenario validation."""
class ScenarioNotFound(ScenarioValidationError): # pragma: no cover
"""Scenario Not Found"""
class ScenarioExamplesNotValidError(ScenarioValidationError): # pragma: no cover
"""Scenario steps argumets do not match declared scenario examples."""
class StepTypeError(ScenarioValidationError): # pragma: no cover
"""Step definition is not of the type expected in the scenario."""
class GivenAlreadyUsed(ScenarioValidationError): # pragma: no cover
"""Fixture that implements the Given has been already used."""
def _inject_fixture(request, arg, value):
"""Inject fixture into pytest fixture request.
@ -122,17 +99,6 @@ def _find_step_function(request, name, encoding):
raise
def _validate_scenario(feature, scenario):
"""Validate the scenario."""
if scenario.params and scenario.example_params and scenario.params != set(scenario.example_params):
raise ScenarioExamplesNotValidError(
"""Scenario "{0}" in the feature "{1}" has not valid examples. """
"""Set of step parameters {2} should match set of example values {3}.""".format(
scenario.name, feature.filename, sorted(scenario.params), sorted(scenario.example_params),
)
)
def _execute_step_function(request, feature, step, step_func, example=None):
"""Execute step function."""
kwargs = {}
@ -176,20 +142,20 @@ def _execute_scenario(feature, scenario, request, encoding, example=None):
try:
# Check the step types are called in the correct order
if step_func.step_type != step.type:
raise StepTypeError(
raise exceptions.StepTypeError(
'Wrong step type "{0}" while "{1}" is expected.'.format(step_func.step_type, step.type)
)
# Check if the fixture that implements given step has not been yet used by another given step
if step.type == GIVEN:
if step_func.fixture in givens:
raise GivenAlreadyUsed(
raise exceptions.GivenAlreadyUsed(
'Fixture "{0}" that implements this "{1}" given step has been already used.'.format(
step_func.fixture, step.name,
)
)
givens.add(step_func.fixture)
except ScenarioValidationError as exception:
except exceptions.ScenarioValidationError as exception:
request.config.hook.pytest_bdd_step_validation_error(
request=request, feature=feature, scenario=scenario, step=step, step_func=step_func,
exception=exception)
@ -228,47 +194,15 @@ def get_fixture(caller_module, fixture, path=None, module=None):
return get_fixture(caller_module, fixture, path=os.path.dirname(path), module=module)
def scenario(
feature_name, scenario_name, encoding='utf-8', example_converters=None,
caller_module=None, caller_function=None):
"""Scenario."""
caller_module = caller_module or get_caller_module()
caller_function = caller_function or get_caller_function()
# Get the feature
base_path = get_fixture(caller_module, 'pytestbdd_feature_base_dir')
feature_path = op.abspath(op.join(base_path, feature_name))
feature = Feature.get_feature(feature_path, encoding=encoding)
# Get the scenario
try:
scenario = feature.scenarios[scenario_name]
except KeyError:
raise ScenarioNotFound(
'Scenario "{0}" in feature "{1}" is not found.'.format(scenario_name, feature_name)
)
# Validate the scenario
_validate_scenario(feature, scenario)
if scenario.examples:
params = []
for example in scenario.examples:
for index, param in enumerate(scenario.example_params):
if example_converters and param in example_converters:
example[index] = example_converters[param](example[index])
params.append(example)
params = [scenario.example_params, params]
else:
params = []
g = globals().copy()
g.update(locals())
def _get_scenario_decorator(
feature, feature_name, scenario, scenario_name, caller_module, caller_function, encoding):
"""Get scenario decorator."""
g = locals()
g['_execute_scenario'] = _execute_scenario
def decorator(_pytestbdd_function):
if isinstance(_pytestbdd_function, python.FixtureRequest):
raise ScenarioIsDecoratorOnly(
raise exceptions.ScenarioIsDecoratorOnly(
'scenario function can only be used as a decorator. Refer to the documentation.')
g.update(locals())
@ -293,6 +227,8 @@ def scenario(
_scenario = recreate_function(
g[_pytestbdd_function.__name__], module=caller_module, firstlineno=caller_function.f_lineno)
params = scenario.get_params()
if params:
_scenario = pytest.mark.parametrize(*params)(_scenario)
@ -300,6 +236,34 @@ def scenario(
feature_name=feature_name, scenario_name=scenario_name)
return _scenario
decorator = recreate_function(decorator, module=caller_module, firstlineno=caller_function.f_lineno)
return recreate_function(decorator, module=caller_module, firstlineno=caller_function.f_lineno)
return decorator
def scenario(
feature_name, scenario_name, encoding='utf-8', example_converters=None,
caller_module=None, caller_function=None):
"""Scenario."""
caller_module = caller_module or get_caller_module()
caller_function = caller_function or get_caller_function()
# Get the feature
base_path = get_fixture(caller_module, 'pytestbdd_feature_base_dir')
feature_path = op.abspath(op.join(base_path, feature_name))
feature = Feature.get_feature(feature_path, encoding=encoding)
# Get the scenario
try:
scenario = feature.scenarios[scenario_name]
except KeyError:
raise exceptions.ScenarioNotFound(
'Scenario "{0}" in feature "{1}" is not found.'.format(scenario_name, feature_name)
)
scenario.example_converters = example_converters
# Validate the scenario
scenario.validate()
return _get_scenario_decorator(
feature, feature_name, scenario, scenario_name, caller_module, caller_function, encoding)

View File

@ -3,8 +3,10 @@
FEATURE = 'feature' # pragma: no cover
SCENARIO_OUTLINE = 'scenario outline' # pragma: no cover
EXAMPLES = 'examples' # pragma: no cover
EXAMPLES_VERTICAL = 'examples vertical' # pragma: no cover
EXAMPLES_HEADERS = 'example headers' # pragma: no cover
EXAMPLE_LINE = 'example line' # pragma: no cover
EXAMPLE_LINE_VERTICAL = 'example line vertical' # pragma: no cover
SCENARIO = 'scenario' # pragma: no cover
GIVEN = 'given' # pragma: no cover
WHEN = 'when' # pragma: no cover

View File

@ -4,7 +4,7 @@ import functools
import re
import pytest
from pytest_bdd import scenario, given, when, then
from pytest_bdd.scenario import GivenAlreadyUsed
from pytest_bdd import exceptions
@scenario(
@ -73,5 +73,5 @@ def test_multiple_given(request):
)
def test():
pass
with pytest.raises(GivenAlreadyUsed):
with pytest.raises(exceptions.GivenAlreadyUsed):
test(request)

View File

@ -28,3 +28,14 @@ Scenario Outline: Outlined with some examples failing
| start | eat | left |
| 0 | 5 | 5 |
| 12 | 5 | 7 |
Scenario Outline: Outlined with vertical example table
Given there are <start> cucumbers
When I eat <eat> cucumbers
Then I should have <left> cucumbers
Examples: Vertical
| start | 12 | 2 |
| eat | 5 | 1 |
| left | 7 | 1 |

View File

@ -4,7 +4,7 @@ import re
import pytest
from pytest_bdd import given, when, then, scenario
from pytest_bdd.scenario import ScenarioExamplesNotValidError
from pytest_bdd import exceptions
@scenario(
@ -13,7 +13,8 @@ from pytest_bdd.scenario import ScenarioExamplesNotValidError
example_converters=dict(start=int, eat=float, left=str)
)
def test_outlined():
assert 1
assert test_outlined.parametrize.args == (
[u'start', u'eat', u'left'], [[12, 5.0, '7'], [5, 4.0, '1']])
@given('there are <start> cucumbers')
@ -39,7 +40,7 @@ def should_have_left_cucumbers(start_cucumbers, start, eat, left):
def test_wrongly_outlined(request):
"""Test parametrized scenario when the test function lacks parameters."""
with pytest.raises(ScenarioExamplesNotValidError) as exc:
with pytest.raises(exceptions.ScenarioExamplesNotValidError) as exc:
@scenario(
'outline.feature',
'Outlined with wrong examples',
@ -67,3 +68,14 @@ def other_fixture(request):
)
def test_outlined_with_other_fixtures(other_fixture):
"""Test outlined scenario also using other parametrized fixture."""
@scenario(
'outline.feature',
'Outlined with vertical example table',
example_converters=dict(start=int, eat=float, left=str)
)
def test_vertical_example():
"""Test outlined scenario with vertical examples table."""
assert test_vertical_example.parametrize.args == (
[u'start', u'eat', u'left'], [[12, 5.0, '7'], [2, 1.0, '1']])

View File

@ -1,13 +1,14 @@
"""Test scenario decorator."""
import pytest
from pytest_bdd import scenario
from pytest_bdd.scenario import ScenarioNotFound
from pytest_bdd import exceptions
def test_scenario_not_found(request):
"""Test the situation when scenario is not found."""
with pytest.raises(ScenarioNotFound):
with pytest.raises(exceptions.ScenarioNotFound):
scenario(
'not_found.feature',
'NOT FOUND'

View File

@ -1,7 +1,7 @@
import pytest
from pytest_bdd import scenario, given, when, then
from pytest_bdd.scenario import GivenAlreadyUsed
from pytest_bdd import exceptions
@scenario('steps.feature', 'Executed step by step')
@ -84,7 +84,7 @@ def test_multiple_given(request):
def test():
pass
with pytest.raises(GivenAlreadyUsed):
with pytest.raises(exceptions.GivenAlreadyUsed):
test(request)

View File

@ -1,9 +1,11 @@
"""Test wrong feature syntax."""
import re
import pytest
from pytest_bdd import scenario, given, when, then
from pytest_bdd.feature import FeatureError
from pytest_bdd.scenario import StepTypeError
from pytest_bdd import exceptions
@given('something')
@ -57,10 +59,9 @@ def test_wrong_type_order(request, scenario_name):
def test_wrong_type_order(request):
pass
with pytest.raises(StepTypeError) as excinfo:
with pytest.raises(exceptions.StepTypeError) as excinfo:
test_wrong_type_order(request)
excinfo # TODO: assert the exception args from parameters
assert re.match(r'Wrong step type \"(\w+)\" while \"(\w+)\" is expected\.', excinfo.value.args[0])
def test_verbose_output(request):