Merge pull request #36 from paylogic/scenario-outlines

scenario outlines implemented
This commit is contained in:
Oleg Pidsadnyi 2014-03-10 23:12:08 +01:00
commit f20c02a571
9 changed files with 264 additions and 97 deletions

View File

@ -1,6 +1,11 @@
Changelog
=========
1.0.0
-----
- Implemented scenario outlines (bubenkoff)
0.6.10
-----

View File

@ -34,7 +34,7 @@ Example
publish\_article.feature:
.. code-block:: feature
Feature: Blog
A site where you can publish your articles.
@ -166,23 +166,59 @@ Scenario parameters
===================
Scenario can accept `encoding` param to decode content of feature file in specific encoding. UTF-8 is default.
Step parameters
===============
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
Scenario: Parametrized given, when, thens
Scenario Outline: Outlined given, when, thens
Given there are <start> cucumbers
When I eat <eat> cucumbers
Then I should have <left> cucumbers
Unlike other tools, pytest-bdd implements the scenario outline not in the
feature files, but in the python code using pytest parametrization.
Examples:
| start | eat | left |
| 12 | 5 | 7 |
The code will look like:
.. code-block:: python
from pytest_bdd import given, when, then, scenario
test_outlined = scenario(
'outline.feature',
'Outlined given, when, thens',
)
@given('there are <start> cucumbers')
def start_cucumbers(start):
return dict(start=int(start))
@when('I eat <eat> cucumbers')
def eat_cucumbers(start_cucumbers, start, eat):
start_cucumbers['eat'] = int(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)
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.
@ -224,6 +260,9 @@ The code will look like:
assert start_cucumbers['start'] == start
assert start_cucumbers['eat'] == eat
The significant downside of this approach is inability to see the test table from the feature file.
Test setup
==========
@ -324,6 +363,7 @@ inherited. Fixtures can be reused with other names using given():
given('I have beautiful article', fixture='article')
Reusing steps
=============
@ -363,6 +403,7 @@ test\_common.py:
There are no definitions of the steps in the test file. They were
collected from the parent conftests.
Feature file paths
==================

View File

@ -26,7 +26,10 @@ one line.
import re # pragma: no cover
import sys # pragma: no cover
from pytest_bdd.types import FEATURE, SCENARIO, GIVEN, WHEN, THEN # pragma: no cover
from pytest_bdd.types import (
FEATURE, SCENARIO, SCENARIO_OUTLINE, EXAMPLES, EXAMPLES_HEADERS, EXAMPLE_LINE, GIVEN, WHEN,
THEN # pragma: no cover
)
class FeatureError(Exception): # pragma: no cover
@ -47,6 +50,8 @@ features = {} # pragma: no cover
STEP_PREFIXES = { # pragma: no cover
'Feature: ': FEATURE,
'Scenario Outline: ': SCENARIO_OUTLINE,
'Examples:': EXAMPLES,
'Scenario: ': SCENARIO,
'Given ': GIVEN,
'When ': WHEN,
@ -137,6 +142,7 @@ class Feature(object):
"""
self.scenarios = {}
self.filename = filename
scenario = None
mode = None
prev_mode = None
@ -144,18 +150,17 @@ class Feature(object):
with _open_file(filename, encoding) as f:
content = force_unicode(f.read(), encoding)
for line_number, line in enumerate(content.split('\n')):
for line_number, line in enumerate(content.splitlines()):
line = strip(line)
if not line:
continue
mode = get_step_type(line) or mode
if mode == GIVEN and prev_mode not in (GIVEN, SCENARIO):
if mode == GIVEN and prev_mode not in (GIVEN, SCENARIO, 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, GIVEN, WHEN):
if mode == WHEN and prev_mode not in (SCENARIO, SCENARIO_OUTLINE, GIVEN, WHEN):
raise FeatureError('When steps must be the first or follow Given steps',
line_number, line)
@ -173,9 +178,15 @@ class Feature(object):
# Remove Feature, Given, When, Then, And
line = remove_prefix(line)
if mode == SCENARIO:
if mode in [SCENARIO, SCENARIO_OUTLINE]:
self.scenarios[line] = scenario = Scenario(line)
elif mode == EXAMPLES:
mode = EXAMPLES_HEADERS
elif mode == EXAMPLES_HEADERS:
scenario.set_param_names([l.strip() for l in line.split('|') if l.strip()])
mode = EXAMPLE_LINE
elif mode == EXAMPLE_LINE:
scenario.add_example([l.strip() for l in line.split('|') if l.strip()])
elif mode and mode != FEATURE:
scenario.add_step(step_name=line, step_type=mode)
@ -208,6 +219,8 @@ class Scenario(object):
self.name = name
self.params = set()
self.steps = []
self.example_params = []
self.examples = []
def add_step(self, step_name, step_type):
"""Add step to the scenario.
@ -219,6 +232,23 @@ class Scenario(object):
self.params.update(get_step_params(step_name))
self.steps.append(Step(name=step_name, type=step_type))
def set_param_names(self, keys):
"""Set parameter names.
:param names: `list` of `string` parameter names
"""
self.params.update(keys)
self.example_params = keys
def add_example(self, values):
"""Add example.
:param values: `list` of `string` parameter values
"""
self.examples.append(values)
class Step(object):
"""Step."""

View File

@ -41,6 +41,45 @@ 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.
:param request: pytest fixture request
:param arg: argument name
:param value: argument value
"""
fd = python.FixtureDef(
request._fixturemanager,
None,
arg,
lambda: value, None, None,
False,
)
fd.cached_result = (value, 0)
old_fd = getattr(request, '_fixturedefs', {}).get(arg)
old_value = request._funcargs.get(arg)
add_fixturename = arg not in request.fixturenames
def fin():
request._fixturemanager._arg2fixturedefs[arg].remove(fd)
getattr(request, '_fixturedefs', {})[arg] = old_fd
request._funcargs[arg] = old_value
if add_fixturename:
request.fixturenames.remove(arg)
request.addfinalizer(fin)
# inject fixture definition
request._fixturemanager._arg2fixturedefs.setdefault(arg, []).insert(0, fd)
# inject fixture value in request cache
getattr(request, '_fixturedefs', {})[arg] = fd
request._funcargs[arg] = value
if add_fixturename:
request.fixturenames.append(arg)
def _find_step_function(request, name, encoding):
"""Match the step defined by the regular expression pattern.
@ -63,34 +102,88 @@ def _find_step_function(request, name, encoding):
if match:
for arg, value in match.groupdict().items():
fd = python.FixtureDef(
request._fixturemanager,
fixturedef.baseid,
arg,
lambda: value, fixturedef.scope, fixturedef.params,
fixturedef.unittest,
)
fd.cached_result = (value, 0)
old_fd = getattr(request, '_fixturedefs', {}).get(arg)
old_value = request._funcargs.get(arg)
def fin():
request._fixturemanager._arg2fixturedefs[arg].remove(fd)
getattr(request, '_fixturedefs', {})[arg] = old_fd
request._funcargs[arg] = old_value
request.addfinalizer(fin)
# inject fixture definition
request._fixturemanager._arg2fixturedefs.setdefault(arg, []).insert(0, fd)
# inject fixture value in request cache
getattr(request, '_fixturedefs', {})[arg] = fd
request._funcargs[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():
_inject_fixture(request, key, value)
_execute_scenario(feature, scenario, request, encoding)
def _execute_scenario(feature, scenario, request, encoding):
"""Execute the scenario."""
_validate_scenario(feature, scenario, request)
givens = set()
# Execute scenario steps
for step in scenario.steps:
try:
step_func = _find_step_function(request, step.name, encoding=encoding)
except python.FixtureLookupError as exception:
request.config.hook.pytest_bdd_step_func_lookup_error(
request=request, feature=feature, scenario=scenario, step=step, exception=exception)
raise
try:
# Check the step types are called in the correct order
if step_func.step_type != step.type:
raise 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(
'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:
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
def scenario(feature_name, scenario_name, encoding='utf-8'):
"""Scenario. May be called both as decorator and as just normal function."""
@ -113,65 +206,10 @@ def scenario(feature_name, scenario_name, encoding='utf-8'):
'Scenario "{0}" in feature "{1}" is not found.'.format(scenario_name, feature_name)
)
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_name, sorted(scenario.params), sorted(resolved_params),
)
)
givens = set()
# Execute scenario steps
for step in scenario.steps:
try:
step_func = _find_step_function(request, step.name, encoding=encoding)
except python.FixtureLookupError as exception:
request.config.hook.pytest_bdd_step_func_lookup_error(
request=request, feature=feature, scenario=scenario, step=step, exception=exception)
raise
try:
# Check the step types are called in the correct order
if step_func.step_type != step.type:
raise 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(
'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:
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
if scenario.examples:
_execute_scenario_outline(feature, scenario, request, encoding)
else:
_execute_scenario(feature, scenario, request, encoding)
_scenario.pytestbdd_params = set()

View File

@ -1,6 +1,10 @@
"""Common type definitions."""
FEATURE = 'feature' # pragma: no cover
SCENARIO_OUTLINE = 'scenario outline' # pragma: no cover
EXAMPLES = 'examples' # pragma: no cover
EXAMPLES_HEADERS = 'example headers' # pragma: no cover
EXAMPLE_LINE = 'example line' # 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 = '0.6.10'
version = '1.0.0'
class Tox(TestCommand):

View File

@ -0,0 +1,8 @@
Scenario Outline: Outlined given, when, thens
Given there are <start> cucumbers
When I eat <eat> cucumbers
Then I should have <left> cucumbers
Examples:
| start | eat | left |
| 12 | 5 | 7 |

View File

@ -0,0 +1,25 @@
"""Scenario Outline tests."""
from pytest_bdd import given, when, then, scenario
test_outlined = scenario(
'outline.feature',
'Outlined given, when, thens',
)
@given('there are <start> cucumbers')
def start_cucumbers(start):
return dict(start=int(start))
@when('I eat <eat> cucumbers')
def eat_cucumbers(start_cucumbers, start, eat):
start_cucumbers['eat'] = int(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)

View File

@ -16,6 +16,22 @@ def test_parametrized(request, start, eat, left):
"""Test parametrized scenario."""
@pytest.fixture(params=[1, 2])
def foo_bar(request):
return 'foo_bar' * request.param
@pytest.mark.parametrize(
['start', 'eat', 'left'],
[(12, 5, 7)])
@scenario(
'parametrized.feature',
'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(