Implemented shortcut to automatically bind scenarios to tests. closes #103, #89, #92, #90

This commit is contained in:
Anatoly Bubenkov 2015-03-18 01:00:53 +01:00
parent 48336f1f94
commit 29f2c1cbe4
12 changed files with 229 additions and 52 deletions

View File

@ -1,15 +1,9 @@
language: python
sudo: false
# command to install dependencies
install:
- pip install python-coveralls
- pip install -U virtualenv py
# # command to run tests
script: python setup.py test
script: make test
after_success:
- pip install -r requirements-testing.txt -e .
- py.test --cov=pytest_bdd --cov-report=term-missing tests
- coveralls
- make coveralls
notifications:
email:
- bubenkoff@gmail.com

View File

@ -1,6 +1,12 @@
Changelog
=========
2.7.0
-----
- Implemented `scenarios` shortcut to automatically bind scenarios to tests (bubenkoff)
2.6.2
-----

View File

@ -1,10 +1,22 @@
# create virtual environment
PATH := .env/bin:$(PATH)
.env:
virtualenv .env
# install all needed for development
develop: .env
.env/bin/pip install -e . -r requirements-testing.txt tox
pip install -e . -r requirements-testing.txt tox python-coveralls
coverage: develop
coverage run --source=pytest_bdd .env/bin/py.test tests
coverage report -m
test: develop
tox
coveralls: coverage
coveralls
# clean the development envrironment
clean:

View File

@ -330,6 +330,7 @@ Note that `then` step definition (`text_should_be_correct`) in this example uses
by a a `given` step (`i_have_text`) argument with the same name (`text`). This possibility is described in
the `Step arguments are fixtures as well!`_ section.
Scenario parameters
-------------------
Scenario decorator can accept such optional keyword arguments:
@ -338,6 +339,52 @@ Scenario decorator can accept such optional keyword arguments:
* ``example_converters`` - mapping to pass functions to convert example values provided in feature files.
Scenarios shortcut
------------------
If you have relatively large set of feature files, it's boring to manually bind scenarios to the tests using the
scenario decorator. Of course with the manual approach you get all the power to be able to additionally parametrize
the test, give the test function a nice name, document it, etc, but in the majority of the cases you don't need that.
Instead you want to bind `all` scenarios found in the `feature` folder(s) recursively automatically.
For this - there's a `scenarios` helper.
.. code-block:: python
from pytest_bdd import scenarios
# assume 'features' subfolder is in this file's directory
scenarios('features')
That's all you need to do to bind all scenarios found in the `features` folder!
Note that you can pass multiple paths, and those paths can be either feature files or feature folders.
.. code-block:: python
from pytest_bdd import scenarios
# pass multiple paths/files
scenarios('features', 'other_features/some.feature', 'some_other_features')
But what if you need to manually bind certain scenario, leaving others to be automatically bound?
Just write your scenario in a `normal` way, but ensure you do it `BEFORE` the call of `scenarios` helper.
.. code-block:: python
from pytest_bdd import scenario, scenarios
@scenario('features/some.feature', 'Test something')
def test_something():
pass
# assume 'features' subfolder is in this file's directory
scenarios('features')
In the example above `test_something` scenario binding will be kept manual, other scenarios found in the `features`
folder will be bound automatically.
Scenario outlines
-----------------
@ -377,7 +424,6 @@ pytest-bdd feature file format also supports example tables in different way:
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:
.. code-block:: python

View File

@ -1,12 +1,12 @@
"""pytest-bdd public API."""
__version__ = '2.6.2'
__version__ = '2.7.0'
try:
from pytest_bdd.steps import given, when, then
from pytest_bdd.scenario import scenario
from pytest_bdd.scenario import scenario, scenarios
__all__ = [given.__name__, when.__name__, then.__name__, scenario.__name__]
__all__ = [given.__name__, when.__name__, then.__name__, scenario.__name__, scenarios.__name__]
except ImportError:
# avoid import errors when only __version__ is needed (for setup.py)
pass

View File

@ -44,3 +44,8 @@ class StepDefinitionNotFoundError(Exception):
class InvalidStepParserError(Exception):
"""Invalid step parser."""
class NoScenariosFound(Exception):
"""No scenarios found."""

View File

@ -29,6 +29,8 @@ import re
import sys
import textwrap
import glob2
from . import types
from . import exceptions
@ -149,6 +151,29 @@ def get_tags(line):
)
def get_features(paths):
"""Get features for given paths.
:param list paths: `list` of paths (file or dirs)
:return: `list` of `Feature` objects.
"""
seen_names = set()
features = []
for path in paths:
if path in seen_names:
continue
seen_names.add(path)
if op.isdir(path):
features.extend(get_features(glob2.iglob(op.join(path, "**", "*.feature"))))
else:
base, name = op.split(path)
feature = Feature.get_feature(base, name)
features.append(feature)
features.sort(key=lambda feature: feature.name or feature.filename)
return features
class Feature(object):
"""Feature."""

View File

@ -2,23 +2,18 @@
import itertools
import os.path
import re
from mako.lookup import TemplateLookup
import glob2
import py
from .feature import Feature
from .scenario import (
find_argumented_step_fixture_name,
force_encode
force_encode,
make_python_name,
)
from .feature import get_features
from .types import STEP_TYPES
PYTHON_REPLACE_REGEX = re.compile("\W")
ALPHA_REGEX = re.compile("^\d+_*")
tw = py.io.TerminalWriter()
template_lookup = TemplateLookup(directories=[os.path.join(os.path.dirname(__file__), "templates")])
@ -51,12 +46,6 @@ def pytest_cmdline_main(config):
return show_missing_code(config)
def make_python_name(string):
"""Make python attribute name out of a given string."""
string = re.sub(PYTHON_REPLACE_REGEX, "", string.replace(" ", "_"))
return re.sub(ALPHA_REGEX, "", string).lower()
def generate_code(features, scenarios, steps):
"""Generate test code for the given filenames."""
grouped_steps = group_steps(steps)
@ -134,29 +123,6 @@ def _find_step_fixturedef(fixturemanager, item, name, encoding="utf-8"):
return fixturedefs
def get_features(paths):
"""Get features for given paths.
:param list paths: `list` of paths (file or dirs)
:return: `list` of `Feature` objects.
"""
seen_names = set()
features = []
for path in paths:
if path in seen_names:
continue
seen_names.add(path)
if os.path.isdir(path):
features.extend(get_features(glob2.iglob(os.path.join(path, "**", "*.feature"))))
else:
base, name = os.path.split(path)
feature = Feature.get_feature(base, name)
features.append(feature)
features.sort(key=lambda feature: feature.name or feature.filename)
return features
def parse_feature_files(paths):
"""Parse feature files of given paths.

View File

@ -13,6 +13,7 @@ test_publish_article = scenario(
import collections
import inspect
import os
import re
import pytest
from _pytest import python
@ -24,6 +25,7 @@ from .feature import (
Feature,
force_encode,
force_unicode,
get_features,
)
from .steps import (
execute,
@ -33,12 +35,15 @@ from .steps import (
)
from .types import GIVEN
if six.PY3:
import runpy
execfile = runpy.run_path
PYTHON_REPLACE_REGEX = re.compile("\W")
ALPHA_REGEX = re.compile("^\d+_*")
def _inject_fixture(request, arg, value):
"""Inject fixture into pytest fixture request.
@ -363,3 +368,57 @@ def scenario(feature_name, scenario_name, encoding="utf-8", example_converters=N
caller_function,
encoding,
)
def make_python_name(string):
"""Make python attribute name out of a given string."""
string = re.sub(PYTHON_REPLACE_REGEX, "", string.replace(" ", "_"))
return re.sub(ALPHA_REGEX, "", string).lower()
def get_python_name_generator(name):
python_name = make_python_name(name)
suffix = ''
index = 0
def get_name():
return 'test_{0}{1}'.format(python_name, suffix)
while True:
yield get_name()
index += 1
suffix = '_{0}'.format(index)
def scenarios(*feature_paths):
"""Parse features from the paths and put all found scenarios in the caller module.
:param: paths
"""
frame = inspect.stack()[1]
module = inspect.getmodule(frame[0])
base_path = get_fixture(module, "pytestbdd_feature_base_dir")
abs_feature_paths = []
for path in feature_paths:
if not os.path.isabs(path):
path = os.path.abspath(os.path.join(base_path, path))
abs_feature_paths.append(path)
found = False
module_scenarios = frozenset(
(attr.__scenario__.feature.filename, attr.__scenario__.name)
for name, attr in module.__dict__.items() if hasattr(attr, '__scenario__'))
for feature in get_features(abs_feature_paths):
for scenario_name, scenario_object in feature.scenarios.items():
# skip already bound scenarios
if (scenario_object.feature.filename, scenario_name) not in module_scenarios:
@scenario(feature.filename, scenario_name)
def _scenario():
pass
for test_name in get_python_name_generator(scenario_name):
if test_name not in module.__dict__:
# found an unique test name
module.__dict__[test_name] = _scenario
break
found = True
if not found:
raise exceptions.NoScenariosFound(abs_feature_paths)

View File

@ -242,7 +242,7 @@ def get_caller_module(depth=2):
frame = sys._getframe(depth)
module = inspect.getmodule(frame)
if module is None:
raise Exception("empty module")
return get_caller_module(depth=depth)
return module

View File

@ -0,0 +1,63 @@
"""Test scenarios shortcut."""
import textwrap
def test_scenarios(testdir):
"""Test scenarios shortcut."""
testdir.makeconftest("""
import pytest
from pytest_bdd import given
@given('I have a bar')
def i_have_bar():
print('bar!')
return 'bar'
""")
features = testdir.mkdir('features')
features.join('test.feature').write_text(textwrap.dedent(u"""
Scenario: Test scenario
Given I have a bar
"""), 'utf-8', ensure=True)
features.join('subfolder', 'test.feature').write_text(textwrap.dedent(u"""
Scenario: Test subfolder scenario
Given I have a bar
Scenario: Test failing subfolder scenario
Given I have a failing bar
Scenario: Test already bound scenario
Given I have a bar
Scenario: Test scenario
Given I have a bar
"""), 'utf-8', ensure=True)
testdir.makepyfile("""
import pytest
from pytest_bdd import scenarios, scenario
@scenario('features/subfolder/test.feature', 'Test already bound scenario')
def test_already_bound():
pass
scenarios('features')
""")
result = testdir.runpytest('-v', '-s')
result.stdout.fnmatch_lines(['*collected 5 items'])
result.stdout.fnmatch_lines(['*test_test_subfolder_scenario *bar!', 'PASSED'])
result.stdout.fnmatch_lines(['*test_test_scenario *bar!', 'PASSED'])
result.stdout.fnmatch_lines(['*test_test_failing_subfolder_scenario *FAILED'])
result.stdout.fnmatch_lines(['*test_already_bound *bar!', 'PASSED'])
result.stdout.fnmatch_lines(['*test_test_scenario_1 *bar!', 'PASSED'])
def test_scenarios_none_found(testdir):
"""Test scenarios shortcut when no scenarios found."""
testdir.makepyfile("""
import pytest
from pytest_bdd import scenarios
scenarios('.')
""")
result = testdir.runpytest('-vv')
result.stdout.fnmatch_lines(['*collected 0 items / 1 errors'])
result.stdout.fnmatch_lines(['*NoScenariosFound*'])

View File

@ -1,6 +1,7 @@
[tox]
distshare={homedir}/.tox/distshare
envlist=py26,py27,py27-xdist,py27-pytest-latest,py33,py34
skip_missing_interpreters = true
[testenv]
commands= py.test pytest_bdd tests --pep8 --junitxml={envlogdir}/junit-{envname}.xml