merged with olegpidsadnyi/pytest-bdd

This commit is contained in:
Andrey Makhnach 2013-09-27 10:19:12 +03:00
commit f8f9a06e15
42 changed files with 1121 additions and 422 deletions

1
.gitignore vendored
View File

@ -45,3 +45,4 @@ nosetests.xml
/lib
/include
/src
/share

View File

@ -1,12 +1,14 @@
language: python
python:
- "2.6"
- "2.7"
- "3.3"
# command to install dependencies
install: "python setup.py develop"
install:
- pip install python-coveralls
# # command to run tests
script: python setup.py test
after_success:
- pip install -r requirements-testing.txt -e .
- py.test --cov=pytest_bdd --cov-report=term-missing tests
- coveralls
notifications:
email:
- bubenkoff@gmail.com
- oleg.podsadny@gmail.com

View File

@ -1,21 +0,0 @@
Changes between 0.4.6 and 0.4.7
-------------------------------
- Fixed Python 3.3 support
Changes between 0.4.5 and 0.4.6
-------------------------------
- Fixed a bug when py.test --fixtures showed incorrect filenames for the steps.
Changes between 0.4.3 and 0.4.5
-------------------------------
- Fixed a bug with the reuse of the fixture by given steps being evaluated multiple times.
Changes between 0.4.1 and 0.4.3
-------------------------------
- Update the license file and PYPI related documentation.

47
CHANGES.rst Normal file
View File

@ -0,0 +1,47 @@
Changelog
=========
0.6.0
-----
- Added step arguments support. (curzona, olegpidsadnyi, bubenkoff)
- Added checking of the step type order. (markon, olegpidsadnyi)
0.5.2
-----
- Added extra info into output when FeatureError exception raises. (amakhnach)
0.5.0
-----
- Added parametrization to scenarios
- Coveralls.io integration
- Test coverage improvement/fixes
- Correct wrapping of step functions to preserve function docstring
0.4.7
-----
- Fixed Python 3.3 support
0.4.6
-----
- Fixed a bug when py.test --fixtures showed incorrect filenames for the steps.
0.4.5
-----
- Fixed a bug with the reuse of the fixture by given steps being evaluated multiple times.
0.4.3
-----
- Update the license file and PYPI related documentation.

View File

@ -1,4 +1,5 @@
include CHANGELOG
include README.md
include CHANGES.rst
include README.rst
include requirements-testing.txt
include setup.py
include LICENSE

170
README.md
View File

@ -1,170 +0,0 @@
BDD library for the py.test runner
==================================
[![Build Status](https://api.travis-ci.org/olegpidsadnyi/pytest-bdd.png)](https://travis-ci.org/olegpidsadnyi/pytest-bdd)
Install pytest-bdd
=================
pip install pytest-bdd
Example
=======
publish_article.feature:
Scenario: Publishing the article
Given I'm an author user
And I have an article
When I go to the article page
And I press the publish button
Then I should not see the error message
And the article should be published # Note: will query the database
test_publish_article.py:
from pytest_bdd import scenario, given, when, then
test_publish = scenario('publish_article.feature', 'Publishing the article')
@given('I have an article')
def article(author):
return create_test_article(author=author)
@when('I go to the article page')
def go_to_article(article, browser):
browser.visit(urljoin(browser.url, '/manage/articles/{0}/'.format(article.id)))
@when('I press the publish button')
def publish_article(browser):
browser.find_by_css('button[name=publish]').first.click()
@then('I should not see the error message')
def no_error_message(browser):
with pytest.raises(ElementDoesNotExist):
browser.find_by_css('.message.error').first
@then('And the article should be published')
def article_is_published(article):
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 function with multiple step names simply
decorate it multiple times:
@given('I have an article')
@given('there\'s an article')
def article(author):
return create_test_article(author=author)
Note that the given step aliases are independent and will be executed when mentioned.
For example if you assoicate your resource to some owner or not. Admin user can't be an
author of the article, but article should have some default author.
Scenario: I'm the author
Given I'm an author
And I have an article
Scenario: I'm the admin
Given I'm the admin
And there is an article
Reuse fixtures
================
Sometimes scenarios define new names for the fixture that can be inherited.
Fixtures can be reused with other names using given():
given('I have beautiful article', fixture='article')
Reuse steps
===========
It is possible to define some common steps in the parent conftest.py and simply
expect them in the child test file.
common_steps.feature:
Scenario: All steps are declared in the conftest
Given I have a bar
Then bar should have value "bar"
conftest.py:
from pytest_bdd import given, then
@given('I have a bar')
def bar():
return 'bar'
@then('bar should have value "bar"')
def bar_is_bar(bar):
assert bar == 'bar'
test_common.py:
test_conftest = scenario('common_steps.feature', 'All steps are declared in the conftest')
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 module's path as base path for finding feature files, but this behaviour can
be changed by having fixture named 'pytestbdd_feature_base_dir' which should return the new base path.
test_publish_article.py:
import pytest
from pytest_bdd import scenario
@pytest.fixture
def pytestbdd_feature_base_dir():
return '/home/user/projects/foo.bar/features'
test_publish = scenario('publish_article.feature', 'Publishing the article')
Subplugins
==========
The pytest BDD has plugin support, and the main purpose of plugins (subplugins) is to provide useful and specialized
fixtures.
List of known subplugins:
* pytest-bdd-splinter -- collection of fixtures for real browser BDD testing
License
=======
This software is licensed under the [MIT license](http://en.wikipedia.org/wiki/MIT_License>).
© 2013 Oleg Pidsadnyi

423
README.rst Normal file
View File

@ -0,0 +1,423 @@
BDD library for the py.test runner
==================================
.. image:: https://api.travis-ci.org/olegpidsadnyi/pytest-bdd.png
:target: https://travis-ci.org/olegpidsadnyi/pytest-bdd
.. image:: https://pypip.in/v/pytest-bdd/badge.png
:target: https://crate.io/packages/pytest-bdd/
.. image:: https://coveralls.io/repos/olegpidsadnyi/pytest-bdd/badge.png?branch=master
:target: https://coveralls.io/r/olegpidsadnyi/pytest-bdd
pytest-bdd implements a subset of Gherkin language for the automation of the project
requirements testing and easier behavioral driven development.
Unlike many other BDD tools it doesn't require a separate runner and benefits from
the power and flexibility of the pytest. It allows to unify your unit and functional
tests, easier continuous integration server configuration and maximal reuse of the
tests setup.
Pytest fixtures written for the unit tests can be reused for the setup and actions
mentioned in the feature steps with dependency injection, which allows a true BDD
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:
.. code-block:: feature
Scenario: Publishing the article
Given I'm an author user
And I have an article
When I go to the article page
And I press the publish button
Then I should not see the error message
And the article should be published # Note: will query the database
test\_publish\_article.py:
.. code-block:: python
from pytest_bdd import scenario, given, when, then
test_publish = scenario('publish_article.feature', 'Publishing the article')
@given('I have an article')
def article(author):
return create_test_article(author=author)
@when('I go to the article page')
def go_to_article(article, browser):
browser.visit(urljoin(browser.url, '/manage/articles/{0}/'.format(article.id)))
@when('I press the publish button')
def publish_article(browser):
browser.find_by_css('button[name=publish]').first.click()
@then('I should not see the error message')
def no_error_message(browser):
with pytest.raises(ElementDoesNotExist):
browser.find_by_css('.message.error').first
@then('And the article should be published')
def article_is_published(article):
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
function with multiple step names simply decorate it multiple times:
.. code-block:: python
@given('I have an article')
@given('there\'s an article')
def article(author):
return create_test_article(author=author)
Note that the given step aliases are independent and will be executed
when mentioned.
For example if you associate your resource to some owner or not. Admin
user cant be an author of the article, but articles should have a
default author.
.. code-block:: feature
Scenario: I'm the author
Given I'm an author
And I have an article
Scenario: I'm the admin
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.
Also opens the possibility to use same step twice in single scenario and with different arguments!
Important thing that argumented step names are not just strings but regular expressions.
Example:
.. code-block:: feature
Scenario: Arguments for given, when, thens
Given there are 5 cucumbers
When I eat 3 cucumbers
And I eat 2 cucumbers
Then I should have 0 cucumbers
The code will look like:
.. code-block:: python
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'))
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'))
def eat_cucumbers(start_cucumbers, eat):
eat = int(eat)
start_cucumbers['eat'] += eat
@then(re.compile('I should have (?P<left>\d+) cucumbers'))
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
Step parameters
===============
Scenarios can be parametrized to cover few cases. In Gherkin the variable
templates are written using corner braces as <somevalue>.
Example:
.. code-block:: feature
Scenario: Parametrized 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.
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.
The code will look like:
.. code-block:: python
import pytest
from pytest_bdd import scenario, 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(
'parametrized.feature',
'Parametrized given, when, thens',
)
# 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).
def test_parametrized(start, eat, left):
"""We don't need to do anything here, everything will be managed by the scenario decorator."""
@given('there are <start> cucumbers')
def start_cucumbers(start):
return dict(start=start)
@when('I eat <eat> cucumbers')
def eat_cucumbers(start_cucumbers, start, eat):
start_cucumbers['eat'] = eat
@then('I should have <left> cucumbers')
def should_have_left_cucumbers(start_cucumbers, start, eat, left):
assert start - eat == left
assert start_cucumbers['start'] == start
assert start_cucumbers['eat'] == eat
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
to benefit of the PyTest fixtures which is based on the dependency injection
and makes the setup more declarative style.
.. code-block:: python
@given('I have a beautiful article')
def article():
return Article(is_beautiful=True)
This also declares a PyTest fixture "article" and any other step can depend on it.
.. code-block:: feature
Given I have a beautiful article
When I publish this article
When step is referring the article to publish it.
.. code-block:: python
@when('I publish this article')
def publish_article(article):
article.publish()
Many other BDD toolkits operate a global context and put the side effects there.
This makes it very difficult to implement the steps, because the dependencies
appear only as the side-effects in the run-time and not declared in the code.
The publish article step has to trust that the article is already in the context,
has to know the name of the attribute it is stored there, the type etc.
In pytest-bdd you just declare an argument of the step function that it depends on
and the PyTest will make sure to provide it.
Still side effects can be applied in the imperative style by design of the BDD.
.. code-block:: feature
Given I have a beautiful article
And my article is published
Functional tests can reuse your fixture libraries created for the unit-tests and upgrade
them by applying the side effects.
.. code-block:: python
given('I have a beautiful article', fixture='article')
@given('my article is published')
def published_article(article):
article.publish()
return article
This way side-effects were applied to our article and PyTest makes sure that all
steps that require the "article" fixture will receive the same object. The value
of the "published_article" and the "article" fixtures is the same object.
Fixtures are evaluated only once within the PyTest scope and their values are cached.
In case of Given steps and the step arguments mentioning the same given step makes
no sense. It won't be executed second time.
.. code-block:: feature
Given I have a beautiful article
And some other thing
And I have a beautiful article # Won't be executed, exception is raised
pytest-bdd will raise an exception even in the case of the steps that use regular expression
patterns to get arguments.
.. code-block:: feature
Given I have 1 cucumbers
And I have 2 cucumbers # Exception is raised
Will raise an exception if the step is using the regular expression pattern.
.. code-block:: python
@given(re.compile('I have (?P<n>\d+) cucumbers'))
def cucumbers(n):
return create_cucumbers(n)
Reusing fixtures
================
Sometimes scenarios define new names for the fixture that can be
inherited. Fixtures can be reused with other names using given():
.. code-block:: python
given('I have beautiful article', fixture='article')
Reusing steps
=============
It is possible to define some common steps in the parent conftest.py and
simply expect them in the child test file.
common\_steps.feature:
.. code-block:: feature
Scenario: All steps are declared in the conftest
Given I have a bar
Then bar should have value "bar"
conftest.py:
.. code-block:: python
from pytest_bdd import given, then
@given('I have a bar')
def bar():
return 'bar'
@then('bar should have value "bar"')
def bar_is_bar(bar):
assert bar == 'bar'
test\_common.py:
.. code-block:: python
test_conftest = scenario('common_steps.feature', 'All steps are declared in the conftest')
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
fixture named pytestbdd\_feature\_base\_dir which should return the
new base path.
test\_publish\_article.py:
.. code-block:: python
import pytest
from pytest_bdd import scenario
@pytest.fixture
def pytestbdd_feature_base_dir():
return '/home/user/projects/foo.bar/features'
test_publish = scenario('publish_article.feature', 'Publishing the article')
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.
For example:
test\_publish\_article.py:
.. code-block:: python
from functools import partial
import pytest_bdd
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')
You can learn more about `functools.partial <http://docs.python.org/2/library/functools.html#functools.partial>`_ in the Python docs.
Subplugins
==========
The pytest BDD has plugin support, and the main purpose of plugins
(subplugins) is to provide useful and specialized fixtures.
List of known subplugins:
* 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>`_.
© 2013 Oleg Pidsadnyi

View File

@ -11,7 +11,7 @@
# All configuration values have a default; values that are commented out
# serve to show the default.
import sys, os
import pytest_bdd
# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
@ -41,16 +41,16 @@ master_doc = 'index'
# General information about the project.
project = u'Pytest-BDD'
copyright = u'2013, Oleg Pidsadnyi, Anatoly Bubenkov'
copyright = u'2013, Oleg Pidsadnyi'
# The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the
# built documents.
#
# The short X.Y version.
version = '0.1'
version = pytest_bdd.__version__
# The full version, including alpha/beta/rc tags.
release = '0.1'
release = pytest_bdd.__version__
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
@ -184,7 +184,7 @@ latex_elements = {
# (source start file, target name, title, author, documentclass [howto/manual]).
latex_documents = [
('index', 'Pytest-BDD.tex', u'Pytest-BDD Documentation',
u'Oleg Pidsadnyi, Anatoly Bubenkov', 'manual'),
u'Oleg Pidsadnyi', 'manual'),
]
# The name of an image file (relative to this directory) to place at the top of
@ -214,7 +214,7 @@ latex_documents = [
# (source start file, name, description, authors, manual section).
man_pages = [
('index', 'pytest-bdd', u'Pytest-BDD Documentation',
[u'Oleg Pidsadnyi, Anatoly Bubenkov'], 1)
[u'Oleg Pidsadnyi'], 1)
]
# If true, show URL addresses after external links.
@ -228,7 +228,7 @@ man_pages = [
# dir menu entry, description, category)
texinfo_documents = [
('index', 'Pytest-BDD', u'Pytest-BDD Documentation',
u'Oleg Pidsadnyi, Anatoly Bubenkov', 'Pytest-BDD', 'One line description of project.',
u'Oleg Pidsadnyi', 'Pytest-BDD', 'One line description of project.',
'Miscellaneous'),
]

View File

@ -1,24 +1,6 @@
.. Pytest-BDD documentation master file, created by
sphinx-quickstart on Sun Apr 7 21:07:56 2013.
You can adapt this file completely to your liking, but it should at least
contain the root `toctree` directive.
Welcome to Pytest-BDD's documentation!
======================================
Contents:
.. toctree::
:maxdepth: 2
.. automodule:: pytest_bdd
Indices and tables
==================
* :ref:`genindex`
* :ref:`modindex`
* :ref:`search`
.. contents::
.. include:: ../README.rst

View File

@ -1,4 +1,5 @@
from pytest_bdd.steps import given, when, then
from pytest_bdd.scenario import scenario
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__]
__all__ = [given.__name__, when.__name__, then.__name__, scenario.__name__] # pragma: no cover

View File

@ -1,9 +1,12 @@
"""Feature.
The way of describing the behavior is based on Gherkin language, but a very
limited version. It doesn't support any parameter tables or any variables.
limited version. It doesn't support any parameter tables.
If the parametrization is needed to generate more test cases it can be done
on the fixture level of the pytest.
The <variable> syntax can be used here to make a connection between steps and
it will also validate the parameters mentioned in the steps with ones
provided in the pytest parametrization table.
Syntax example:
@ -18,21 +21,30 @@ Syntax example:
:note: The "#" symbol is used for comments.
:note: There're no multiline steps, the description of the step must fit in
one line.
"""
import re # pragma: no cover
from pytest_bdd.types import SCENARIO, GIVEN, WHEN, THEN
from pytest_bdd.types import SCENARIO, GIVEN, WHEN, THEN # pragma: no cover
class FeatureError(Exception):
class FeatureError(Exception): # pragma: no cover
"""Feature parse error."""
pass
message = u'{0}.\nLine number: {1}.\nLine: {2}.'
def __str__(self):
return unicode(self).encode('utf-8')
def __unicode__(self):
return self.message.format(*self.args)
# Global features dictionary
features = {}
features = {} # pragma: no cover
STEP_PREFIXES = {
STEP_PREFIXES = { # pragma: no cover
'Scenario: ': SCENARIO,
'Given ': GIVEN,
'When ': WHEN,
@ -40,7 +52,9 @@ STEP_PREFIXES = {
'And ': None, # Unknown step type
}
COMMENT_SYMBOLS = '#'
COMMENT_SYMBOLS = '#' # pragma: no cover
STEP_PARAM_RE = re.compile('\<(.+?)\>') # pragma: no cover
def get_step_type(line):
@ -54,6 +68,14 @@ def get_step_type(line):
return STEP_PREFIXES[prefix]
def get_step_params(name):
"""Return step parameters."""
params = STEP_PARAM_RE.search(name)
if params:
return params.groups()
return ()
def strip(line):
"""Remove leading and trailing whitespaces and comments.
@ -71,7 +93,9 @@ def remove_prefix(line):
"""Remove the step prefix (Scenario, Given, When, Then or And).
:param line: Line of the Feature file.
:return: Line without the prefix.
"""
for prefix in STEP_PREFIXES:
if line.startswith(prefix):
@ -86,6 +110,7 @@ class Feature(object):
"""Parse the feature file.
:param filename: Relative path to the feature file.
"""
self.scenarios = {}
@ -95,7 +120,7 @@ class Feature(object):
with open(filename, 'r') as f:
content = f.read()
for number_of_line, line in enumerate(content.split('\n')):
for line_number, line in enumerate(content.split('\n')):
line = strip(line)
if not line:
continue
@ -104,15 +129,15 @@ class Feature(object):
if mode == GIVEN and prev_mode not in (GIVEN, SCENARIO):
raise FeatureError('Given steps must be the first in withing the Scenario',
number_of_line, line, prev_mode, mode)
line_number, line)
if mode == WHEN and prev_mode not in (SCENARIO, GIVEN, WHEN):
raise FeatureError('When steps must be the first or follow Given steps',
number_of_line, line, prev_mode, mode)
line_number, line)
if mode == THEN and prev_mode not in (GIVEN, WHEN, THEN):
raise FeatureError('Then steps must follow Given or When steps',
number_of_line, line, prev_mode, mode)
line_number, line)
prev_mode = mode
@ -122,10 +147,21 @@ class Feature(object):
if mode == SCENARIO:
self.scenarios[line] = scenario = Scenario(line)
else:
scenario.add_step(line)
scenario.add_step(step_name=line, step_type=mode)
@classmethod
def get_feature(cls, filename):
"""Get a feature by the filename.
:param filename: Filename of the feature file.
:return: `Feature` instance from the parsed feature cache.
:note: The features are parsed on the execution of the test and
stored in the global variable cache to improve the performance
when multiple scenarios are referencing the same file.
"""
feature = features.get(filename)
if not feature:
feature = Feature(filename)
@ -138,8 +174,23 @@ class Scenario(object):
def __init__(self, name):
self.name = name
self.params = set()
self.steps = []
def add_step(self, step):
"""Add step."""
self.steps.append(step)
def add_step(self, step_name, step_type):
"""Add step to the scenario.
:param step_name: Step name.
:param step_type: Step type.
"""
self.params.update(get_step_params(step_name))
self.steps.append(Step(name=step_name, type=step_type))
class Step(object):
"""Step."""
def __init__(self, name, type):
self.name = name
self.type = type

View File

@ -1,10 +1,10 @@
"""Pytest plugin entry point. Used for any fixtures needed."""
import os.path
import os.path # pragma: no cover
import pytest
import pytest # pragma: no cover
@pytest.fixture
@pytest.fixture # pragma: no cover
def pytestbdd_feature_base_dir(request):
return os.path.dirname(request.module.__file__)

View File

@ -9,36 +9,138 @@ test_publish_article = scenario(
feature_name='publish_article.feature',
scenario_name='Publishing the article',
)
"""
import inspect
from os import path as op
import inspect # pragma: no cover
from os import path as op # pragma: no cover
from pytest_bdd.feature import Feature
from _pytest import python
from pytest_bdd.feature import Feature # pragma: no cover
from pytest_bdd.steps import recreate_function
from pytest_bdd.types import GIVEN
class ScenarioNotFound(Exception):
class ScenarioNotFound(Exception): # pragma: no cover
"""Scenario Not Found"""
class NotEnoughScenarioParams(Exception): # pragma: no cover
"""Scenario function doesn't take enough parameters in the arguments."""
class StepTypeError(Exception): # pragma: no cover
"""Step definition is not of the type expected in the scenario."""
class GivenAlreadyUsed(Exception): # pragma: no cover
"""Fixture that implements the Given has been already used."""
def _find_step_function(request, name):
"""Match the step defined by the regular expression pattern.
:param request: PyTest request object.
:param name: Step name.
:return: Step function.
"""
try:
return request.getfuncargvalue(name)
except python.FixtureLookupError:
for fixturename, fixturedefs in request._fixturemanager._arg2fixturedefs.items():
fixturedef = fixturedefs[0]
pattern = getattr(fixturedef.func, 'pattern', None)
match = pattern.match(name) if pattern else None
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,
)
request._fixturemanager._arg2fixturedefs[arg] = [fd]
if arg in request._funcargs:
request._funcargs[arg] = value
return request.getfuncargvalue(pattern.pattern)
raise
def scenario(feature_name, scenario_name):
"""Scenario."""
"""Scenario. May be called both as decorator and as just normal function."""
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)
def decorator(request):
# 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))
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)
# Execute scenario's steps
for step in scenario.steps:
func = request.getfuncargvalue(step)
kwargs = dict((arg, request.getfuncargvalue(arg)) for arg in inspect.getargspec(func).args)
func(**kwargs)
# 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)
)
return _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_name, sorted(scenario.params), sorted(resolved_params),
)
)
givens = set()
# Execute scenario steps
for step in scenario.steps:
step_func = _find_step_function(request, step.name)
# 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)
# Get the step argument values
kwargs = dict((arg, request.getfuncargvalue(arg)) for arg in inspect.getargspec(step_func).args)
# Execute the step
step_func(**kwargs)
_scenario.pytestbdd_params = set()
if isinstance(request, python.FixtureRequest):
# Called as a normal function.
return _scenario(request)
# 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)
_scenario.pytestbdd_params = set(func_args)
return _scenario
return decorator

View File

@ -31,21 +31,24 @@ Reusing existing fixtures for a different step name:
given('I have a beautiful article', fixture='article')
"""
from __future__ import absolute_import
from types import CodeType
import inspect
import sys
from __future__ import absolute_import # pragma: no cover
import re
from types import CodeType # pragma: no cover
import inspect # pragma: no cover # pragma: no cover
import sys # pragma: no cover
import pytest
import pytest # pragma: no cover
from pytest_bdd.feature import remove_prefix
from pytest_bdd.types import GIVEN, WHEN, THEN
from pytest_bdd.feature import remove_prefix # pragma: no cover
from pytest_bdd.types import GIVEN, WHEN, THEN # pragma: no cover
PY3 = sys.version_info[0] >= 3
PY3 = sys.version_info[0] >= 3 # pragma: no cover
class StepError(Exception):
pass
class StepError(Exception): # pragma: no cover
"""Step declaration error."""
RE_TYPE = type(re.compile('')) # pragma: no cover
def given(name, fixture=None):
@ -56,12 +59,18 @@ def given(name, fixture=None):
:raises: StepError in case of wrong configuration.
:note: Can't be used as a decorator when the fixture is specified.
"""
name = remove_prefix(name)
if fixture is not None:
module = get_caller_module()
func = lambda: lambda request: request.getfuncargvalue(fixture)
contribute_to_module(module, name, pytest.fixture(func))
step_func = lambda request: request.getfuncargvalue(fixture)
step_func.step_type = GIVEN
step_func.__name__ = name
step_func.fixture = fixture
func = pytest.fixture(lambda: step_func)
func.__doc__ = 'Alias for the "{0}" fixture.'.format(fixture)
contribute_to_module(module, remove_prefix(name), func)
return _not_a_fixture_decorator
return _step_decorator(GIVEN, name)
@ -71,7 +80,9 @@ def when(name):
"""When step decorator.
:param name: Step name.
:raises: StepError in case of wrong configuration.
"""
return _step_decorator(WHEN, name)
@ -80,7 +91,9 @@ def then(name):
"""Then step decorator.
:param name: Step name.
:raises: StepError in case of wrong configuration.
"""
return _step_decorator(THEN, name)
@ -89,48 +102,75 @@ def _not_a_fixture_decorator(func):
"""Function that prevents the decoration.
:param func: Function that is going to be decorated.
:raises: `StepError` if was used as a decorator.
"""
raise StepError('Cannot be used as a decorator when the fixture is specified')
def _step_decorator(step_type, step_name):
"""Step decorator for the type and the name.
:param step_type: Step type (GIVEN, WHEN or THEN).
:param step_name: Step name as in the feature file.
:return: Decorator function for the step.
:raise: StepError if the function doesn't take group names as parameters.
:note: If the step type is GIVEN it will automatically apply the pytest
fixture decorator to the step function.
"""
step_name = remove_prefix(step_name)
pattern = None
if isinstance(step_name, RE_TYPE):
pattern = step_name
step_name = pattern.pattern
def decorator(func):
step_func = func
if step_type == GIVEN:
if not hasattr(func, '_pytestfixturefunction'):
# avoid overfixturing of a fixture
# Avoid multiple wrapping of a fixture
func = pytest.fixture(func)
step_func = lambda request: request.getfuncargvalue(func.__name__)
step_func.__doc__ = func.__doc__
step_func.fixture = func.__name__
step_func.__name__ = step_name
step_func.step_type = step_type
@pytest.fixture
def lazy_step_func():
return step_func
# Preserve the docstring
lazy_step_func.__doc__ = func.__doc__
if pattern:
lazy_step_func.pattern = pattern
contribute_to_module(
get_caller_module(),
step_name,
pytest.fixture(lambda: step_func),
lazy_step_func,
)
return func
return decorator
def contribute_to_module(module, name, func):
"""Contribute a function to a module.
def recreate_function(func, module=None, name=None, add_args=()):
"""Recreate a function, replacing some info.
:param module: Module to contribute to.
:param name: Attribute name.
:param func: Function object.
:param module: Module to contribute to.
:param add_args: Additional arguments to add to function.
:return: Function copy.
"""
def get_code(func):
@ -152,12 +192,34 @@ def contribute_to_module(module, name, func):
args = []
code = get_code(func)
for arg in argnames:
if arg == 'co_filename':
if module is not None and arg == 'co_filename':
args.append(module.__file__)
elif name is not None and arg == 'co_name':
args.append(name)
elif arg == 'co_argcount':
args.append(getattr(code, arg) + len(add_args))
elif arg == 'co_varnames':
co_varnames = getattr(code, arg)
args.append(co_varnames[:code.co_argcount] + tuple(add_args) + co_varnames[code.co_argcount:])
else:
args.append(getattr(code, arg))
set_code(func, CodeType(*args))
if name is not None:
func.__name__ = name
return func
def contribute_to_module(module, name, func):
"""Contribute a function to a module.
:param module: Module to contribute to.
:param name: Attribute name.
:param func: Function object.
"""
func = recreate_function(func, module=module)
setattr(module, name, func)

View File

@ -1,6 +1,6 @@
"""Common type definitions."""
SCENARIO = 'scenario'
GIVEN = 'given'
WHEN = 'when'
THEN = 'then'
SCENARIO = 'scenario' # pragma: no cover
GIVEN = 'given' # pragma: no cover
WHEN = 'when' # pragma: no cover
THEN = 'then' # pragma: no cover

5
requirements-testing.txt Normal file
View File

@ -0,0 +1,5 @@
mock
pytest-pep8
pytest-cov
pytest-cache
pytest-xdist

100
setup.py
View File

@ -1,107 +1,43 @@
#!/usr/bin/env python
"""
PyTest-BDD
==========
Implements a subset of Gherkin language for the behavior-driven development and
automated testing. Benefits from the pytest and its dependency injection pattern
for the true just enough specifications and maximal reusability of the BDD
definitions.
Example
```````
publish_article.feature:
.. code:: gherkin
Scenario: Publishing the article
Given I'm an author user
And I have an article
When I go to the article page
And I press the publish button
Then I should not see the error message
And the article should be published # Note: will query the database
test_publish_article.py:
.. code:: python
from pytest_bdd import scenario, given, when, then
test_publish = scenario('publish_article.feature', 'Publishing the article')
@given('I have an article')
def article(author):
return create_test_article(author=author)
@when('I go to the article page')
def go_to_article(article, browser):
browser.visit(urljoin(browser.url, '/manage/articles/{0}/'.format(article.id)))
@when('I press the publish button')
def publish_article(browser):
browser.find_by_css('button[name=publish]').first.click()
@then('I should not see the error message')
def no_error_message(browser):
with pytest.raises(ElementDoesNotExist):
browser.find_by_css('.message.error').first
@then('And the article should be published')
def article_is_published(article):
article.refresh() # Refresh the object in the SQLAlchemy session
assert article.is_published
Installation
````````````
.. code:: bash
$ pip install pytest-bdd
Links
`````
* `website <https://github.com/olegpidsadnyi/pytest-bdd>`_
* `documentation <https://pytest-bdd.readthedocs.org/en/latest/>`_
"""
import os
import sys
from setuptools import setup
from setuptools.command.test import test as TestCommand
class PyTest(TestCommand):
version = '0.6.0'
class Tox(TestCommand):
def finalize_options(self):
TestCommand.finalize_options(self)
self.test_args = []
self.test_args = ['--recreate']
self.test_suite = True
def run_tests(self):
#import here, cause outside the eggs aren't loaded
import pytest
errno = pytest.main(self.test_args)
import tox
errno = tox.cmdline(self.test_args)
sys.exit(errno)
dirname = os.path.dirname(__file__)
long_description = (
open(os.path.join(dirname, 'README.rst')).read() + '\n' +
open(os.path.join(dirname, 'CHANGES.rst')).read()
)
setup(
name='pytest-bdd',
description='BDD for pytest',
long_description=__doc__,
long_description=long_description,
author='Oleg Pidsadnyi',
license='MIT license',
author_email='oleg.podsadny@gmail.com',
url='https://github.com/olegpidsadnyi/pytest-bdd',
version='0.4.7',
version=version,
classifiers=[
'Development Status :: 6 - Mature',
'Intended Audience :: Developers',
@ -115,7 +51,7 @@ setup(
'Programming Language :: Python :: 2',
'Programming Language :: Python :: 3'
] + [('Programming Language :: Python :: %s' % x) for x in '2.6 2.7 3.0 3.1 3.2 3.3'.split()],
cmdclass={'test': PyTest},
cmdclass={'test': Tox},
install_requires=[
'pytest',
],
@ -125,6 +61,6 @@ setup(
'pytest-bdd = pytest_bdd.plugin',
]
},
tests_require=['mock'],
tests_require=['tox'],
packages=['pytest_bdd'],
)

0
tests/args/__init__.py Normal file
View File

View File

@ -0,0 +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...
Scenario: Using the same given fixture raises an error
Given I have 1 Euro
And I have 2 Euro

7
tests/args/conftest.py Normal file
View File

@ -0,0 +1,7 @@
import re
from pytest_bdd import when
@when(re.compile(r'I append (?P<n>\d+) to the list'))
def append_to_list(results, n):
results.append(int(n))

View File

View File

@ -0,0 +1,8 @@
Scenario: Executed with steps matching step definitons with arguments
Given I have a foo fixture with value "foo"
And there is a list
When I append 1 to the list
And I append 2 to the list
And I append 3 to the list
Then foo should have value "foo"
And the list should be [1, 2, 3]

View File

@ -0,0 +1,27 @@
from pytest_bdd import scenario, given, then
test_steps = scenario(
'args.feature',
'Executed with steps matching step definitons with arguments',
)
@given('I have a foo fixture with value "foo"')
def foo():
return 'foo'
@given('there is a list')
def results():
return []
@then('foo should have value "foo"')
def foo_is_foo(foo):
assert foo == 'foo'
@then('the list should be [1, 2, 3]')
def check_results(results):
assert results == [1, 2, 3]

View File

@ -0,0 +1,40 @@
import re
import pytest
from pytest_bdd import scenario, given, when, then
from pytest_bdd.scenario import GivenAlreadyUsed
test_steps = scenario(
'args_steps.feature',
'Every step takes a parameter with the same name',
)
@pytest.fixture
def values():
return ['1', '2', '1', '0', '999999']
@given(re.compile(r'I have (?P<euro>\d+) Euro'))
def i_have(euro, values):
assert euro == values.pop(0)
@when(re.compile(r'I pay (?P<euro>\d+) Euro'))
def i_pay(euro, values, request):
assert euro == values.pop(0)
@then(re.compile(r'I should have (?P<euro>\d+) Euro'))
def i_should_have(euro, values):
assert euro == values.pop(0)
def test_multiple_given(request):
"""Using the same given fixture raises an error."""
test = scenario(
'args_steps.feature',
'Using the same given fixture raises an error',
)
with pytest.raises(GivenAlreadyUsed):
test(request)

View File

@ -1,7 +1,9 @@
Scenario: Multiple given alias is not evaluated multiple times
Given I have an empty list
And I have foo (which is 1) in my list
# Alias of the "I have foo (which is 1) in my list"
And I have bar (alias of foo) in my list
When I do crash (which is 2)
And I do boom (alias of crash)
Then my list should be [1, 2, 2]

View File

@ -0,0 +1,5 @@
Scenario: Given after Then
Given something
When something else
Then nevermind
Given won't work

View File

@ -0,0 +1,4 @@
Scenario: Given after When
Given something
When something else
Given won't work

View File

@ -0,0 +1,4 @@
Scenario: Some scenario
Given 1
When 2
Then 3

View File

@ -0,0 +1,4 @@
Scenario: Parametrized given, when, thens
Given there are <start> cucumbers
When I eat <eat> cucumbers
Then I should have <left> cucumbers

View File

@ -1,6 +1,8 @@
Scenario: Given and when using the same fixture should not evaluate it twice
Given I have an empty list
And I have a fixture (appends 1 to a list)
# Alias of the "I have a fixture (appends 1 to a list)"
And I have a fixture (appends 1 to a list) in reuse syntax
When I use this fixture
Then my list should be [1]

View File

@ -21,3 +21,8 @@ Scenario: Then step can follow Given step
Scenario: All steps are declared in the conftest
Given I have a bar
Then bar should have value "bar"
Scenario: Using the same given fixture raises an error
Given I have a bar
And I have a bar

View File

@ -6,7 +6,7 @@ from pytest_bdd import scenario, given, when, then
test_steps = scenario('alias.feature', 'Multiple given alias is not evaluated multiple times')
@given('Given I have an empty list')
@given('I have an empty list')
def results():
return []

View File

@ -0,0 +1,52 @@
import pytest
from pytest_bdd.scenario import NotEnoughScenarioParams
from pytest_bdd import given, when, then, scenario
@pytest.mark.parametrize(
['start', 'eat', 'left'],
[(12, 5, 7)])
@scenario(
'parametrized.feature',
'Parametrized given, when, thens',
)
def test_parametrized(request, start, eat, left):
"""Test parametrized scenario."""
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: []."""
)
@given('there are <start> cucumbers')
def start_cucumbers(start):
return dict(start=start)
@when('I eat <eat> cucumbers')
def eat_cucumbers(start_cucumbers, start, eat):
start_cucumbers['eat'] = eat
@then('I should have <left> cucumbers')
def should_have_left_cucumbers(start_cucumbers, start, eat, left):
assert start - eat == left
assert start_cucumbers['start'] == start
assert start_cucumbers['eat'] == eat

View File

@ -0,0 +1,15 @@
import pytest
from pytest_bdd import scenario
from pytest_bdd.scenario import ScenarioNotFound
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)

View File

@ -1,4 +1,7 @@
import pytest
from pytest_bdd import scenario, given, when, then
from pytest_bdd.scenario import GivenAlreadyUsed
test_steps = scenario('steps.feature', 'Executed step by step')
@ -42,12 +45,12 @@ def check_results(results):
test_when_first = scenario('steps.feature', 'When step can be the first')
@when('When I do nothing')
@when('I do nothing')
def do_nothing():
pass
@then('Then I make no mistakes')
@then('I make no mistakes')
def no_errors():
assert True
@ -61,3 +64,13 @@ def xyz():
return
test_conftest = scenario('steps.feature', 'All steps are declared in the conftest')
def test_multiple_given(request):
"""Using the same given fixture raises an error."""
test = scenario(
'steps.feature',
'Using the same given fixture raises an error',
)
with pytest.raises(GivenAlreadyUsed):
test(request)

View File

@ -2,37 +2,70 @@
import pytest
from pytest_bdd import scenario
from pytest_bdd import scenario, given, when, then
from pytest_bdd.feature import FeatureError
from pytest_bdd.scenario import StepTypeError
@pytest.fixture(params=[
'When after then',
'Then first',
'Given after When',
'Given after Then',
])
def scenario_name(request):
return request.param
@given('something')
def given_something():
pass
def test_wrong(request, scenario_name):
@when('something else')
def when_something_else():
pass
@then('nevermind')
def then_nevermind():
pass
@pytest.mark.parametrize(
('feature', 'scenario_name'),
[
('when_after_then.feature', 'When after then'),
('then_first.feature', 'Then first'),
('given_after_when.feature', 'Given after When'),
('given_after_then.feature', 'Given after Then'),
]
)
def test_wrong(request, feature, scenario_name):
"""Test wrong feature scenarios."""
sc = scenario('wrong.feature', scenario_name)
sc = scenario(feature, scenario_name)
with pytest.raises(FeatureError):
sc(request)
# TODO: assert the exception args from parameters
@pytest.mark.parametrize(
'scenario_name',
[
'When in Given',
'When in Then',
'Then in Given',
'Given in When',
'Given in Then',
'Then in When',
]
)
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
def test_verbose_output(request):
"""Test verbose output of failed feature scenario"""
sc = scenario('wrong.feature', 'When after then')
try:
sc = scenario('when_after_then.feature', 'When after then')
with pytest.raises(FeatureError) as excinfo:
sc(request)
except FeatureError as excinfo:
msg, number_of_line, line, prev_mode, mode = excinfo.args
assert number_of_line == 4
assert line == 'When I do it again'
assert prev_mode == 'then'
assert mode == 'when'
msg, line_number, line = excinfo.value.args
assert line_number == 4
assert line == 'When I do it again'

View File

@ -0,0 +1,2 @@
Scenario: Then first
Then it won't work

View File

@ -0,0 +1,6 @@
Scenario: When after then
Given I don't always write when after then, but
When I do
Then its fine
When I do it again
Then its wrong

View File

@ -1,23 +0,0 @@
Scenario: When after then
Given I don't always write when after then, but
When I do
Then its fine
When I do it again
Then its wrong
Scenario: Then first
Then it won't work
Scenario: Given after When
Given something
When something else
Given won't work
Scenario: Given after Then
Given something
When something else
Then nevermind
Given won't work

View File

@ -0,0 +1,24 @@
Scenario: When in Given
Given something else
Scenario: When in Then
When something else
Then something else
Scenario: Then in Given
Given nevermind
Scenario: Given in When
When something
Scenario: Given in Then
When something else
Then something
Scenario: Then in When
When nevermind

View File

@ -1,6 +1,7 @@
"""Test when and then steps are callables."""
from pytest_bdd import when, then
import pytest
from pytest_bdd import given, when, then
@when('I do stuff')
@ -24,3 +25,17 @@ def test_when_then(request):
check_stuff_ = request.getfuncargvalue('I check stuff')
assert callable(check_stuff_)
@pytest.mark.parametrize(
('step', 'keyword'), [
(given, 'Given'),
(when, 'When'),
(then, 'Then')])
def test_preserve_decorator(step, keyword):
"""Check that we preserve original function attributes after decorating it."""
@step(keyword)
def func():
"""Doc string."""
assert globals()[keyword].__doc__ == 'Doc string.'

21
tox.ini Normal file
View File

@ -0,0 +1,21 @@
[tox]
distshare={homedir}/.tox/distshare
envlist=py26,py27,py27-xdist,py33
indexserver=
pypi = https://pypi.python.org/simple
[testenv]
commands= py.test tests --pep8 --junitxml={envlogdir}/junit-{envname}.xml
deps = -r{toxinidir}/requirements-testing.txt
[testenv:py27-coverage]
commands= py.test tests --cov=pytest_bdd --pep8 --junitxml={envlogdir}/junit-{envname}.xml
[testenv:py27-xdist]
basepython=python2.7
commands=
py.test tests -n3 -rfsxX \
--junitxml={envlogdir}/junit-{envname}.xml
[pytest]
pep8maxlinelength=120