Merge pull request #37 from paylogic/examples-converters

lets merge it. i think we can still change some stuff, add multiline feature, review the whole code and release 2.0
This commit is contained in:
Oleg Pidsadnyi 2014-03-17 15:19:52 +01:00
commit 535a035c3d
26 changed files with 688 additions and 286 deletions

1
.gitignore vendored
View File

@ -46,3 +46,4 @@ nosetests.xml
/include
/src
/share
/local

View File

@ -1,6 +1,15 @@
Changelog
=========
2.0.0
-----
- Pure pytest parametrization for scenario outlines (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

@ -21,15 +21,17 @@ mentioned in the feature steps with dependency injection, which allows a true BD
just-enough specification of the requirements without maintaining any context object
containing the side effects of the Gherkin imperative declarations.
Install pytest-bdd
==================
------------------
::
pip install pytest-bdd
Example
=======
-------
publish\_article.feature:
@ -52,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')
@ -81,8 +85,9 @@ test\_publish\_article.py:
article.refresh() # Refresh the object in the SQLAlchemy session
assert article.is_published
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
@ -113,8 +118,9 @@ default author.
Given I'm the admin
And there is an article
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.
@ -141,40 +147,47 @@ 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')
@given(re.compile('there are (?P<start>\d+) cucumbers'))
@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):
# note that you always get step arguments as strings, convert them on demand
start = int(start)
return dict(start=start, eat=0)
@when(re.compile('I eat (?P<eat>\d+) cucumbers'))
@when(re.compile('I eat (?P<eat>\d+) cucumbers'), converters=dict(eat=int))
def eat_cucumbers(start_cucumbers, eat):
eat = int(eat)
start_cucumbers['eat'] += eat
@then(re.compile('I should have (?P<left>\d+) cucumbers'))
@then(re.compile('I should have (?P<left>\d+) cucumbers'), converters=dict(left=int))
def should_have_left_cucumbers(start_cucumbers, start, left):
start, left = int(start), int(left)
assert start_cucumbers['start'] == start
assert start - start_cucumbers['eat'] == left
Example code also shows possibility to pass argument converters which may be useful if you need argument types
different than strings.
Scenario parameters
===================
Scenario can accept `encoding` param to decode content of feature file in specific encoding. UTF-8 is default.
-------------------
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>.
`Scenario outlines <http://docs.behat.org/guides/1.gherkin.html#scenario-outlines>`_ are supported by pytest-bdd
exactly as it's described in be behave docs.
Example:
.. code-block:: feature
@ -188,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:
@ -196,45 +227,53 @@ 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')
def start_cucumbers(start):
return dict(start=int(start))
assert isinstance(start, int)
return dict(start=start)
@when('I eat <eat> cucumbers')
def eat_cucumbers(start_cucumbers, start, eat):
start_cucumbers['eat'] = int(eat)
def eat_cucumbers(start_cucumbers, eat):
assert isinstance(eat, float)
start_cucumbers['eat'] = eat
@then('I should have <left> cucumbers')
def should_have_left_cucumbers(start_cucumbers, start, eat, left):
assert int(start) - int(eat) == int(left)
assert start_cucumbers['start'] == int(start)
assert start_cucumbers['eat'] == int(eat)
assert isinstance(left, str)
assert start - eat == int(left)
assert start_cucumbers['start'] == start
assert start_cucumbers['eat'] == eat
It's also possible to parametrize the scenario on the python side. This is done using pytest parametrization.
The reason for this is that it is very often that some simple pythonic type
is needed in the parameters like a datetime or a dictionary, which makes it
more difficult to express in the text files and preserve the correct format.
Example code also shows possibility to pass example converters which may be useful if you need parameter types
different than strings.
It's also possible to parametrize the scenario on the python side.
The reason for this is that it is sometimes not needed to mention example table for every scenario.
The code will look like:
.. code-block:: python
import pytest
from pytest_bdd import scenario, given, when, then
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'],
[(12, 5, 7)])
@scenario(
@mark.scenario(
'parametrized.feature',
'Parametrized given, when, thens',
)
@ -264,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
@ -354,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():
@ -365,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.
@ -398,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
@ -424,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.
@ -447,14 +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:
@ -474,18 +526,54 @@ which might be helpful building useful reporting, visualization, etc on top of i
* pytest_bdd_step_func_lookup_error(request, feature, scenario, step, exception) - Called when step lookup failed
Subplugins
==========
Browser testing
---------------
The pytest BDD has plugin support, and the main purpose of plugins
(subplugins) is to provide useful and specialized fixtures.
Tools recommended to use for browser testing:
List of known subplugins:
* pytest-splinter - pytest splinter integration for the real browser testing
Migration of your tests from versions 0.x.x-1.x.x
-------------------------------------------------
In version 2.0.0, the backward-incompartible change was introduced: scenario function can now only be used as a
decorator. Reasons for that:
* test code readability is much higher using normal python function syntax;
* pytest-bdd internals are much cleaner and shorter when using single approach instead of supporting two;
* after moving to parsing-on-import-time approach for feature files, it's not possible to detect whether it's a
decorator more or not, so to support it along with functional approach there needed to be special parameter
for that, which is also a backward-incompartible change.
To help users migrate to newer version, there's migration console script provided with **migrate** extra:
::
# install extra for migration
pip install pytest-bdd[migrate]
# run migration script
pytestbdd_migrate_tests <your test folder>
Under the hood the script does the replacement from this:
.. code-block:: python
test_function = scenario('publish_article.feature', 'Publishing the article')
to this:
.. code-block:: python
@scenario('publish_article.feature', 'Publishing the article')
def test_function():
pass
* pytest-bdd-splinter - collection of fixtures for the real browser BDD testing
License
=======
-------
This software is licensed under the `MIT license <http://en.wikipedia.org/wiki/MIT_License>`_.

View File

@ -1,5 +1,4 @@
from pytest_bdd.steps import given, when, then # pragma: no cover
from pytest_bdd.scenario import scenario # pragma: no cover
__all__ = [given.__name__, when.__name__, then.__name__, scenario.__name__] # pragma: no cover

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.
@ -229,8 +237,9 @@ class Scenario(object):
:param step_type: Step type.
"""
self.params.update(get_step_params(step_name))
self.steps.append(Step(name=step_name, type=step_type))
params = get_step_params(step_name)
self.params.update(params)
self.steps.append(Step(name=step_name, type=step_type, params=params))
def set_param_names(self, keys):
"""Set parameter names.
@ -238,8 +247,7 @@ class Scenario(object):
:param names: `list` of `string` parameter names
"""
self.params.update(keys)
self.example_params = keys
self.example_params = [str(key) for key in keys]
def add_example(self, values):
"""Add example.
@ -249,10 +257,63 @@ 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."""
def __init__(self, name, type):
def __init__(self, name, type, params):
self.name = name
self.type = type
self.params = params

View File

@ -11,34 +11,24 @@ test_publish_article = scenario(
)
"""
import collections
import os
import imp
import inspect # pragma: no cover
from os import path as op # pragma: no cover
import pytest
from _pytest import python
from pytest_bdd.feature import Feature, force_encode # pragma: no cover
from pytest_bdd.steps import recreate_function, get_caller_module, get_caller_function
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
class ScenarioValidationError(Exception):
"""Base class for scenario validation."""
class ScenarioNotFound(ScenarioValidationError): # pragma: no cover
"""Scenario Not Found"""
class NotEnoughScenarioParams(ScenarioValidationError): # pragma: no cover
"""Scenario function doesn't take enough parameters in the arguments."""
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."""
from pytest_bdd import plugin
def _inject_fixture(request, arg, value):
@ -99,40 +89,46 @@ def _find_step_function(request, name, encoding):
pattern = getattr(fixturedef.func, 'pattern', None)
match = pattern.match(name) if pattern else None
if match:
converters = getattr(fixturedef.func, 'converters', {})
for arg, value in match.groupdict().items():
if arg in converters:
value = converters[arg](value)
_inject_fixture(request, arg, value)
return request.getfuncargvalue(pattern.pattern)
raise
def _validate_scenario(feature, scenario, request):
"""Validate the scenario."""
resolved_params = scenario.params.intersection(request.fixturenames)
if scenario.params != resolved_params:
raise NotEnoughScenarioParams(
"""Scenario "{0}" in the feature "{1}" was not able to resolve all declared parameters."""
"""Should resolve params: {2}, but resolved only: {3}.""".format(
scenario.name, feature.filename, sorted(scenario.params), sorted(resolved_params),
)
)
def _execute_scenario_outline(feature, scenario, request, encoding):
"""Execute the scenario outline."""
for example in scenario.examples:
for key, value in dict(zip(scenario.example_params, example)).items():
def _execute_step_function(request, feature, step, step_func, example=None):
"""Execute step function."""
kwargs = {}
if example:
for key in step.params:
value = example[key]
if step_func.converters and key in step_func.converters:
value = step_func.converters[key](value)
_inject_fixture(request, key, value)
_execute_scenario(feature, scenario, request, encoding)
try:
# Get the step argument values
kwargs = dict((arg, request.getfuncargvalue(arg)) for arg in inspect.getargspec(step_func).args)
request.config.hook.pytest_bdd_before_step(
request=request, feature=feature, scenario=scenario, step=step, step_func=step_func,
step_func_args=kwargs)
# Execute the step
step_func(**kwargs)
request.config.hook.pytest_bdd_after_step(
request=request, feature=feature, scenario=scenario, step=step, step_func=step_func,
step_func_args=kwargs)
except Exception as exception:
request.config.hook.pytest_bdd_step_error(
request=request, feature=feature, scenario=scenario, step=step, step_func=step_func,
step_func_args=kwargs, exception=exception)
raise
def _execute_scenario(feature, scenario, request, encoding):
def _execute_scenario(feature, scenario, request, encoding, example=None):
"""Execute the scenario."""
_validate_scenario(feature, scenario, request)
givens = set()
# Execute scenario steps
for step in scenario.steps:
@ -146,87 +142,128 @@ def _execute_scenario(feature, scenario, request, encoding):
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)
raise
kwargs = {}
try:
# Get the step argument values
kwargs = dict((arg, request.getfuncargvalue(arg)) for arg in inspect.getargspec(step_func).args)
request.config.hook.pytest_bdd_before_step(
request=request, feature=feature, scenario=scenario, step=step, step_func=step_func,
step_func_args=kwargs)
# Execute the step
step_func(**kwargs)
request.config.hook.pytest_bdd_after_step(
request=request, feature=feature, scenario=scenario, step=step, step_func=step_func,
step_func_args=kwargs)
except Exception as exception:
request.config.hook.pytest_bdd_step_error(
request=request, feature=feature, scenario=scenario, step=step, step_func=step_func,
step_func_args=kwargs, exception=exception)
raise
_execute_step_function(request, feature, step, step_func, example=example)
def scenario(feature_name, scenario_name, encoding='utf-8'):
"""Scenario. May be called both as decorator and as just normal function."""
FakeRequest = collections.namedtuple('FakeRequest', ['module'])
caller_module = get_caller_module()
caller_function = get_caller_function()
def decorator(request):
def get_fixture(caller_module, fixture, path=None, module=None):
"""Get first conftest module from given one."""
def call_fixture(function):
args = []
if 'request' in inspect.getargspec(function).args:
args = [FakeRequest(module=caller_module)]
return function(*args)
def _scenario(request):
# Get the feature
base_path = request.getfuncargvalue('pytestbdd_feature_base_dir')
feature_path = op.abspath(op.join(base_path, feature_name))
feature = Feature.get_feature(feature_path, encoding=encoding)
if not module:
module = caller_module
# 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)
)
if hasattr(module, fixture):
return call_fixture(getattr(module, fixture))
if scenario.examples:
_execute_scenario_outline(feature, scenario, request, encoding)
else:
_execute_scenario(feature, scenario, request, encoding)
if path is None:
path = os.path.dirname(module.__file__)
if os.path.exists(os.path.join(path, '__init__.py')):
file_path = os.path.join(path, 'conftest.py')
if os.path.exists(file_path):
conftest = imp.load_source('conftest', file_path)
if hasattr(conftest, fixture):
return get_fixture(caller_module, fixture, module=conftest)
else:
return get_fixture(caller_module, fixture, module=plugin)
return get_fixture(caller_module, fixture, path=os.path.dirname(path), module=module)
_scenario.pytestbdd_params = set()
if isinstance(request, python.FixtureRequest):
# Called as a normal function.
_scenario = recreate_function(_scenario, module=caller_module)
return _scenario(request)
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
# Used as a decorator. Modify the returned function to add parameters from a decorated function.
func_args = inspect.getargspec(request).args
if 'request' in func_args:
func_args.remove('request')
_scenario = recreate_function(_scenario, name=request.__name__, add_args=func_args, module=caller_module)
_scenario.pytestbdd_params = set(func_args)
def decorator(_pytestbdd_function):
if isinstance(_pytestbdd_function, python.FixtureRequest):
raise exceptions.ScenarioIsDecoratorOnly(
'scenario function can only be used as a decorator. Refer to the documentation.')
g.update(locals())
args = inspect.getargspec(_pytestbdd_function).args
function_args = list(args)
for arg in scenario.example_params:
if arg not in function_args:
function_args.append(arg)
if 'request' not in function_args:
function_args.append('request')
code = """def {name}({function_args}):
_execute_scenario(feature, scenario, request, encoding)
_pytestbdd_function({args})""".format(
name=_pytestbdd_function.__name__,
function_args=', '.join(function_args),
args=', '.join(args))
execute(code, g)
_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)
_scenario.__doc__ = '{feature_name}: {scenario_name}'.format(
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)

34
pytest_bdd/scripts.py Normal file
View File

@ -0,0 +1,34 @@
"""pytest-bdd scripts."""
import glob2
import os.path
import re
import sys
MIGRATE_REGEX = re.compile(r'\s?(\w+)\s\=\sscenario\((.+)\)', flags=re.MULTILINE)
def migrate_tests():
"""Migrate outdated tests to the most recent form."""
if len(sys.argv) != 2:
print 'Usage: pytestbdd_migrate_tests <path>'
sys.exit(1)
path = sys.argv[1]
for file_path in glob2.iglob(os.path.join(os.path.abspath(path), '**', '*.py')):
migrate_tests_in_file(file_path)
def migrate_tests_in_file(file_path):
"""Migrate all bdd-based tests in the given test file."""
try:
with open(file_path, 'r+') as fd:
content = fd.read()
new_content = MIGRATE_REGEX.sub(r'\n\n@scenario(\2)\ndef \1():\n pass\n', content)
if new_content != content:
fd.seek(0)
fd.write(new_content)
print('migrated: {0}'.format(file_path))
else:
print('skipped: {0}'.format(file_path))
except IOError:
pass

View File

@ -51,11 +51,13 @@ class StepError(Exception): # pragma: no cover
RE_TYPE = type(re.compile('')) # pragma: no cover
def given(name, fixture=None):
def given(name, fixture=None, converters=None):
"""Given step decorator.
:param name: Given step name.
:param fixture: Optional name of the fixture to reuse.
:param converters: Optional `dict` of the argument or parameter converters in form
{<param_name>: <converter function>}.
:raises: StepError in case of wrong configuration.
:note: Can't be used as a decorator when the fixture is specified.
@ -66,6 +68,7 @@ def given(name, fixture=None):
module = get_caller_module()
step_func = lambda request: request.getfuncargvalue(fixture)
step_func.step_type = GIVEN
step_func.converters = converters
step_func.__name__ = name
step_func.fixture = fixture
func = pytest.fixture(lambda: step_func)
@ -73,29 +76,33 @@ def given(name, fixture=None):
contribute_to_module(module, remove_prefix(name), func)
return _not_a_fixture_decorator
return _step_decorator(GIVEN, name)
return _step_decorator(GIVEN, name, converters=converters)
def when(name):
def when(name, converters=None):
"""When step decorator.
:param name: Step name.
:param converters: Optional `dict` of the argument or parameter converters in form
{<param_name>: <converter function>}.
:raises: StepError in case of wrong configuration.
"""
return _step_decorator(WHEN, name)
return _step_decorator(WHEN, name, converters=converters)
def then(name):
def then(name, converters=None):
"""Then step decorator.
:param name: Step name.
:param converters: Optional `dict` of the argument or parameter converters in form
{<param_name>: <converter function>}.
:raises: StepError in case of wrong configuration.
"""
return _step_decorator(THEN, name)
return _step_decorator(THEN, name, converters=converters)
def _not_a_fixture_decorator(func):
@ -109,7 +116,7 @@ def _not_a_fixture_decorator(func):
raise StepError('Cannot be used as a decorator when the fixture is specified')
def _step_decorator(step_type, step_name):
def _step_decorator(step_type, step_name, converters=None):
"""Step decorator for the type and the name.
:param step_type: Step type (GIVEN, WHEN or THEN).
@ -141,6 +148,7 @@ def _step_decorator(step_type, step_name):
step_func.__name__ = step_name
step_func.step_type = step_type
step_func.converters = converters
@pytest.fixture
def lazy_step_func():
@ -151,6 +159,8 @@ def _step_decorator(step_type, step_name):
if pattern:
lazy_step_func.pattern = pattern
if converters:
lazy_step_func.converters = converters
contribute_to_module(
get_caller_module(),
@ -162,7 +172,7 @@ def _step_decorator(step_type, step_name):
return decorator
def recreate_function(func, module=None, name=None, add_args=(), firstlineno=None):
def recreate_function(func, module=None, name=None, add_args=[], firstlineno=None):
"""Recreate a function, replacing some info.
:param func: Function object.
@ -188,6 +198,10 @@ def recreate_function(func, module=None, name=None, add_args=(), firstlineno=Non
if PY3:
argnames.insert(1, 'co_kwonlyargcount')
for arg in inspect.getargspec(func).args:
if arg in add_args:
add_args.remove(arg)
args = []
code = get_code(func)
for arg in argnames:
@ -220,7 +234,6 @@ def contribute_to_module(module, name, func):
"""
func = recreate_function(func, module=module)
setattr(module, name, func)
@ -233,3 +246,7 @@ def get_caller_module(depth=2):
def get_caller_function(depth=2):
"""Return caller function."""
return sys._getframe(depth)
def execute(code, g):
exec(code, g)

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

@ -6,7 +6,7 @@ from setuptools import setup
from setuptools.command.test import test as TestCommand
version = '1.0.0'
version = '2.0.0'
class Tox(TestCommand):
@ -59,8 +59,14 @@ setup(
entry_points={
'pytest11': [
'pytest-bdd = pytest_bdd.plugin',
],
'console_scripts': [
'pytestbdd_migrate_tests = pytest_bdd.scripts:migrate_tests [migrate]'
]
},
tests_require=['detox'],
extras_require={
'migrate': ['glob2']
},
packages=['pytest_bdd'],
)

View File

@ -1,11 +1,11 @@
Scenario: Every step takes a parameter with the same name
Given I have 1 Euro
When I pay 2 Euro
And I pay 1 Euro
Then I should have 0 Euro
And I should have 999999 Euro # In my dream...
Given I have 1 Euro
When I pay 2 Euro
And I pay 1 Euro
Then I should have 0 Euro
And I should have 999999 Euro # In my dream...
Scenario: Using the same given fixture raises an error
Given I have 1 Euro
And I have 2 Euro
Given I have 1 Euro
And I have 2 Euro

View File

@ -1,10 +1,12 @@
from pytest_bdd import scenario, given, then
test_steps = scenario(
@scenario(
'args.feature',
'Executed with steps matching step definitons with arguments',
)
def test_steps():
pass
@given('I have a foo fixture with value "foo"')

View File

@ -14,12 +14,12 @@ def test_arg_fixture_mix(testdir):
def foo():
return "fine"
test_args = scenario(
@scenario(
'arg_and_fixture_mix.feature',
'Use the step argument with the same name as fixture of another test',
)
def test_args():
pass
@given(re.compile(r'foo is "(?P<foo>\w+)"'))
def foo1(foo):
@ -30,12 +30,12 @@ def test_arg_fixture_mix(testdir):
def foo_should_be(foo, foo_value):
assert foo == foo_value
test_bar = scenario(
@scenario(
'arg_and_fixture_mix.feature',
'Everything is fine',
)
def test_bar():
pass
@given(re.compile(r'it is all fine'))
def fine():
@ -52,12 +52,12 @@ def test_arg_fixture_mix(testdir):
import pytest
from pytest_bdd import scenario, given, then
test_args = scenario(
@scenario(
'arg_and_fixture_mix.feature',
'Everything is fine',
)
def test_args():
pass
@pytest.fixture
def foo():

View File

@ -4,34 +4,49 @@ 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
test_steps = scenario(
@scenario(
'args_steps.feature',
'Every step takes a parameter with the same name',
)
def test_steps():
pass
sc = functools.partial(scenario, 'when_arguments.feature')
test_argument_in_when_step_1 = sc('Argument in when, step 1')
test_argument_in_when_step_2 = sc('Argument in when, step 2')
@sc('Argument in when, step 1')
def test_argument_in_when_step_1():
pass
@sc('Argument in when, step 2')
def test_argument_in_when_step_2():
pass
@pytest.fixture
def values():
return ['1', '2', '1', '0', '999999']
return [1, 2, 1, 0, 999999]
@given(re.compile(r'I have (?P<euro>\d+) Euro'))
@given(re.compile(r'I have (?P<euro>\d+) Euro'), converters=dict(euro=int))
def i_have(euro, values):
assert euro == values.pop(0)
@when(re.compile(r'I pay (?P<euro>\d+) Euro'))
@when(re.compile(r'I pay (?P<euro>\d+) Euro'), converters=dict(euro=int))
def i_pay(euro, values, request):
assert euro == values.pop(0)
@then(re.compile(r'I should have (?P<euro>\d+) Euro'), converters=dict(euro=int))
def i_should_have(euro, values):
assert euro == values.pop(0)
@given('I have an argument')
def argument():
"""I have an argument."""
@ -44,11 +59,6 @@ def get_argument(argument, arg):
argument['arg'] = arg
@then(re.compile(r'I should have (?P<euro>\d+) Euro'))
def i_should_have(euro, values):
assert euro == values.pop(0)
@then(re.compile('My argument should be (?P<arg>\d+)'))
def assert_that_my_argument_is_arg(argument, arg):
"""Assert that arg from when equals arg."""
@ -57,9 +67,11 @@ def assert_that_my_argument_is_arg(argument, arg):
def test_multiple_given(request):
"""Using the same given fixture raises an error."""
test = scenario(
@scenario(
'args_steps.feature',
'Using the same given fixture raises an error',
)
with pytest.raises(GivenAlreadyUsed):
def test():
pass
with pytest.raises(exceptions.GivenAlreadyUsed):
test(request)

View File

@ -6,3 +6,36 @@ Scenario Outline: Outlined given, when, thens
Examples:
| start | eat | left |
| 12 | 5 | 7 |
| 5 | 4 | 1 |
Scenario Outline: Outlined with wrong examples
Given there are <start> cucumbers
When I eat <eat> cucumbers
Then I should have <left> cucumbers
Examples:
| start | eat | left | unknown_param |
| 12 | 5 | 7 | value |
Scenario Outline: Outlined with some examples failing
Given there are <start> cucumbers
When I eat <eat> cucumbers
Then I should have <left> cucumbers
Examples:
| 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

@ -3,7 +3,9 @@
from pytest_bdd import scenario, given, when, then
test_steps = scenario('alias.feature', 'Multiple given alias is not evaluated multiple times')
@scenario('alias.feature', 'Multiple given alias is not evaluated multiple times')
def test_steps():
pass
@given('I have an empty list')

View File

@ -20,8 +20,6 @@ def pytestbdd_feature_base_dir():
def test_feature_path(request, scenario_name):
"""Test feature base dir."""
sc = scenario('steps.feature', scenario_name)
with pytest.raises(IOError) as exc:
sc(request)
scenario('steps.feature', scenario_name)
assert os.path.join('/does/not/exist/', 'steps.feature') in str(exc.value)

View File

@ -1,25 +1,81 @@
"""Scenario Outline tests."""
import re
import pytest
from pytest_bdd import given, when, then, scenario
from pytest_bdd import exceptions
test_outlined = scenario(
@scenario(
'outline.feature',
'Outlined given, when, thens',
example_converters=dict(start=int, eat=float, left=str)
)
def test_outlined():
assert test_outlined.parametrize.args == (
[u'start', u'eat', u'left'], [[12, 5.0, '7'], [5, 4.0, '1']])
@given('there are <start> cucumbers')
def start_cucumbers(start):
return dict(start=int(start))
assert isinstance(start, int)
return dict(start=start)
@when('I eat <eat> cucumbers')
def eat_cucumbers(start_cucumbers, start, eat):
start_cucumbers['eat'] = int(eat)
def eat_cucumbers(start_cucumbers, eat):
assert isinstance(eat, float)
start_cucumbers['eat'] = eat
@then('I should have <left> cucumbers')
def should_have_left_cucumbers(start_cucumbers, start, eat, left):
assert int(start) - int(eat) == int(left)
assert start_cucumbers['start'] == int(start)
assert start_cucumbers['eat'] == int(eat)
assert isinstance(left, str)
assert start - eat == int(left)
assert start_cucumbers['start'] == start
assert start_cucumbers['eat'] == eat
def test_wrongly_outlined(request):
"""Test parametrized scenario when the test function lacks parameters."""
with pytest.raises(exceptions.ScenarioExamplesNotValidError) as exc:
@scenario(
'outline.feature',
'Outlined with wrong examples',
)
def wrongly_outlined():
pass
assert re.match(
"""Scenario \"Outlined with wrong examples\" in the feature \"(.+)\" has not valid examples\. """
"""Set of step parameters (.+) should match set of example values """
"""(.+)\.""",
exc.value.args[0]
)
@pytest.fixture(params=[1, 2, 3])
def other_fixture(request):
return request.param
@scenario(
'outline.feature',
'Outlined given, when, thens',
example_converters=dict(start=int, eat=float, left=str)
)
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,7 +1,5 @@
import pytest
from pytest_bdd.scenario import NotEnoughScenarioParams
from pytest_bdd import given, when, then, scenario
@ -18,7 +16,7 @@ def test_parametrized(request, start, eat, left):
@pytest.fixture(params=[1, 2])
def foo_bar(request):
return 'foo_bar' * request.param
return 'bar' * request.param
@pytest.mark.parametrize(
@ -29,26 +27,7 @@ def foo_bar(request):
'Parametrized given, when, thens',
)
def test_parametrized_with_other_fixtures(request, start, eat, left, foo_bar):
"""Test parametrized scenario, but also with other fixtures."""
def test_parametrized_wrongly(request):
"""Test parametrized scenario when the test function lacks parameters."""
@scenario(
'parametrized.feature',
'Parametrized given, when, thens',
)
def wrongly_parametrized(request):
pass
with pytest.raises(NotEnoughScenarioParams) as exc:
wrongly_parametrized(request)
assert exc.value.args == (
"""Scenario "Parametrized given, when, thens" in the feature "parametrized.feature" was not able to """
"""resolve all declared parameters. """
"""Should resolve params: [\'eat\', \'left\', \'start\'], but resolved only: []."""
)
"""Test parametrized scenario, but also with other parametrized fixtures."""
@given('there are <start> cucumbers')

View File

@ -2,10 +2,13 @@ from pytest_bdd.steps import when
from pytest_bdd import given, then, scenario
test_reuse = scenario(
@scenario(
'reuse.feature',
'Given and when using the same fixture should not evaluate it twice',
)
def test_reuse():
pass
@given('I have an empty list')

View File

@ -1,15 +1,15 @@
"""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."""
test_not_found = scenario(
'not_found.feature',
'NOT FOUND'
)
with pytest.raises(ScenarioNotFound):
test_not_found(request)
with pytest.raises(exceptions.ScenarioNotFound):
scenario(
'not_found.feature',
'NOT FOUND'
)

View File

@ -1,10 +1,12 @@
import pytest
from pytest_bdd import scenario, given, when, then
from pytest_bdd.scenario import GivenAlreadyUsed
from pytest_bdd import exceptions
test_steps = scenario('steps.feature', 'Executed step by step')
@scenario('steps.feature', 'Executed step by step')
def test_steps():
pass
@given('I have a foo fixture with value "foo"')
@ -42,7 +44,9 @@ def check_results(results):
assert results == [1, 2, 3]
test_when_first = scenario('steps.feature', 'When step can be the first')
@scenario('steps.feature', 'When step can be the first')
def test_when_first():
pass
@when('I do nothing')
@ -55,7 +59,9 @@ def no_errors():
assert True
test_then_after_given = scenario('steps.feature', 'Then step can follow Given step')
@scenario('steps.feature', 'Then step can follow Given step')
def test_then_after_given():
pass
@given('xyz')
@ -63,16 +69,22 @@ def xyz():
"""Used in the test_same_step_name."""
return
test_conftest = scenario('steps.feature', 'All steps are declared in the conftest')
@scenario('steps.feature', 'All steps are declared in the conftest')
def test_conftest():
pass
def test_multiple_given(request):
"""Using the same given fixture raises an error."""
test = scenario(
@scenario(
'steps.feature',
'Using the same given fixture raises an error',
)
with pytest.raises(GivenAlreadyUsed):
def test():
pass
with pytest.raises(exceptions.GivenAlreadyUsed):
test(request)
@ -195,19 +207,25 @@ def test_step_trace(testdir):
def when_it_fails():
raise Exception('when fails')
test_when_fails_inline = scenario('test.feature', 'When step has failure')
@scenario('test.feature', 'When step has failure')
def test_when_fails_inline():
pass
@scenario('test.feature', 'When step has failure')
def test_when_fails_decorated():
pass
test_when_not_found = scenario('test.feature', 'When step is not found')
@scenario('test.feature', 'When step is not found')
def test_when_not_found():
pass
@when('foo')
def foo():
return 'foo'
test_when_step_validation_error = scenario('test.feature', 'When step validation error happens')
@scenario('test.feature', 'When step validation error happens')
def test_when_step_validation_error():
pass
""")
result = testdir.runpytest('-k test_when_fails_inline', '-vv')
assert result.ret == 1

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')
@ -33,9 +35,10 @@ def then_nevermind():
def test_wrong(request, feature, scenario_name):
"""Test wrong feature scenarios."""
sc = scenario(feature, scenario_name)
with pytest.raises(FeatureError):
sc(request)
@scenario(feature, scenario_name)
def test_scenario():
pass
# TODO: assert the exception args from parameters
@ -52,17 +55,19 @@ def test_wrong(request, feature, scenario_name):
)
def test_wrong_type_order(request, scenario_name):
"""Test wrong step type order."""
sc = scenario('wrong_type_order.feature', scenario_name)
with pytest.raises(StepTypeError) as excinfo:
sc(request)
excinfo # TODO: assert the exception args from parameters
@scenario('wrong_type_order.feature', scenario_name)
def test_wrong_type_order(request):
pass
with pytest.raises(exceptions.StepTypeError) as excinfo:
test_wrong_type_order(request)
assert re.match(r'Wrong step type \"(\w+)\" while \"(\w+)\" is expected\.', excinfo.value.args[0])
def test_verbose_output(request):
"""Test verbose output of failed feature scenario"""
sc = scenario('when_after_then.feature', 'When after then')
with pytest.raises(FeatureError) as excinfo:
sc(request)
scenario('when_after_then.feature', 'When after then')
msg, line_number, line = excinfo.value.args

View File

@ -11,9 +11,15 @@ def foo():
given('I have alias for foo', fixture='foo')
given('I have an alias to the root fixture', fixture='root')
test_given_with_fixture = scenario('given.feature', 'Test reusing local fixture')
test_root_alias = scenario('given.feature', 'Test reusing root fixture')
@scenario('given.feature', 'Test reusing local fixture')
def test_given_with_fixture():
pass
@scenario('given.feature', 'Test reusing root fixture')
def test_root_alias():
pass
@then('foo should be "foo"')

View File

@ -8,8 +8,17 @@ import functools
from pytest_bdd import scenario, given, then
scenario = functools.partial(scenario, 'unicode.feature')
test_steps_in_feature_file_have_unicode = scenario('Steps in .feature file have unicode')
test_steps_in_py_file_have_unicode = scenario('Steps in .py file have unicode')
@scenario('Steps in .feature file have unicode')
def test_steps_in_feature_file_have_unicode():
pass
@scenario('Steps in .py file have unicode')
def test_steps_in_py_file_have_unicode():
pass
pattern = '(?P<content>\'\w+\')'