From 47694d029913d18bc2635f5a933dc56099c38bb3 Mon Sep 17 00:00:00 2001 From: Anatoly Bubenkov Date: Sat, 20 Sep 2014 23:54:39 +0000 Subject: [PATCH] BDD tests validation/generation helpers --- CHANGES.rst | 1 + pytest_bdd/generation.py | 145 ++++++++++++++++++++++ pytest_bdd/plugin.py | 5 + pytest_bdd/scenario.py | 32 +++-- setup.py | 1 + tests/generation/__init__.py | 0 tests/generation/generation.feature | 12 ++ tests/generation/test_generate_missing.py | 40 ++++++ 8 files changed, 224 insertions(+), 12 deletions(-) create mode 100644 pytest_bdd/generation.py create mode 100644 tests/generation/__init__.py create mode 100644 tests/generation/generation.feature create mode 100644 tests/generation/test_generate_missing.py diff --git a/CHANGES.rst b/CHANGES.rst index be19af2..4fc6f15 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -7,6 +7,7 @@ Unreleased - Better reporting of a not found scenario (bubenkoff) - Simple test code generation implemented (bubenkoff) - Correct timing values for cucumber json reporting (bubenkoff) +- BDD tests validation/generation helpers (bubenkoff) 2.4.0 diff --git a/pytest_bdd/generation.py b/pytest_bdd/generation.py new file mode 100644 index 0000000..9a74a8c --- /dev/null +++ b/pytest_bdd/generation.py @@ -0,0 +1,145 @@ +"""pytest-bdd missing test code generation.""" +import os.path +import itertools +import py + +from _pytest.python import getlocation +from collections import defaultdict + +tw = py.io.TerminalWriter() +verbose = 1 + +from .feature import Feature +from .scenario import ( + _find_argumented_step_fixture_name, + force_encode +) + + +def pytest_addoption(parser): + """Add pytest-bdd options.""" + group = parser.getgroup("bdd") + group._addoption( + '--generate-missing', + action="store_true", dest="generate_missing", + default=False, + help="Generate missing bdd test code for given feature files") + group._addoption( + '--feature-file', + action="append", dest="features", + help="Feature file(s) to generate missing code for.") + + +def pytest_cmdline_main(config): + """Check config option to show missing code.""" + if config.option.generate_missing: + return show_missing_code(config) + + +def show_missing_code(config): + """Wrap pytest session to show missing code.""" + from _pytest.main import wrap_session + return wrap_session(config, _show_missing_code_main) + + +def print_missing_code(scenarios, steps): + """Print missing code with TerminalWriter.""" + curdir = py.path.local() + + scenario = step = None + + for scenario in sorted(scenarios, key=lambda scenario: scenario.name): + tw.line() + tw.line( + 'Scenario is not bound to any test: "{scenario.name}" in feature "{scenario.feature.name}"' + ' in {scenario.feature.filename}'.format(scenario=scenario), red=True) + + if scenario: + tw.sep('-', red=True) + + for step in sorted(steps, key=lambda step: step.name): + tw.line() + tw.line( + 'Step is not defined: "{step.name}" in scenario: "{step.scenario.name}" in feature' + ' "{step.scenario.feature.name}" in {step.scenario.feature.filename}'.format(step=step), red=True) + + if step: + tw.sep('-', red=True) + + # if len(fixtures) > 1: + # fixtures = sorted(fixtures, key=lambda key: key[2]) + + # for baseid, module, bestrel, fixturedef in fixtures: + + # if previous_argname != argname: + # tw.line() + # tw.sep("-", argname) + # previous_argname = argname + + # if verbose <= 0 and argname[0] == "_": + # continue + + # funcargspec = bestrel + + # tw.line(funcargspec) + + +def _find_step_fixturedef(fixturemanager, item, name, encoding='utf-8'): + """Find step fixturedef. + + :param request: PyTest Item object. + :param step: `Step`. + + :return: Step function. + """ + fixturedefs = fixturemanager.getfixturedefs(force_encode(name, encoding), item.nodeid) + if not fixturedefs: + name = _find_argumented_step_fixture_name(name, fixturemanager) + if name: + return _find_step_fixturedef(fixturemanager, item, name, encoding) + else: + return fixturedefs + + +def _show_missing_code_main(config, session): + """Preparing fixture duplicates for output.""" + session.perform_collect() + + fm = session._fixturemanager + + features = [Feature.get_feature(*os.path.split(feature_file)) for feature_file in config.option.features] + scenarios = list(itertools.chain.from_iterable(feature.scenarios.values() for feature in features)) + steps = list(itertools.chain.from_iterable(scenario.steps for scenario in scenarios)) + for item in session.items: + scenario = getattr(item.obj, '__scenario__', None) + if scenario: + if scenario in scenarios: + scenarios.remove(scenario) + for step in scenario.steps: + fixturedefs = _find_step_fixturedef(fm, item, step.name, ) + if fixturedefs: + try: + steps.remove(step) + except ValueError: + pass + # assert fixturedefs is not None + # if not fixturedefs: + # continue + + # for fixturedef in fixturedefs: + # loc = getlocation(fixturedef.func, curdir) + + # fixture = ( + # len(fixturedef.baseid), + # fixturedef.func.__module__, + # curdir.bestrelpath(loc), + # fixturedef + # ) + # if fixture[2] not in [f[2] for f in available[argname]]: + # available[argname].append(fixture) + for scenario in scenarios: + for step in scenario.steps: + steps.remove(step) + print_missing_code(scenarios, steps) + if scenarios or steps: + session.exitstatus = 100 diff --git a/pytest_bdd/plugin.py b/pytest_bdd/plugin.py index 8ebf109..fb069d4 100644 --- a/pytest_bdd/plugin.py +++ b/pytest_bdd/plugin.py @@ -82,6 +82,11 @@ def pytest_runtest_makereport(item, call, __multicall__): return rep +def pytest_addoption(parser): + """Add pytest-bdd options.""" + parser.getgroup("bdd", "BDD") + + @given('trace') @when('trace') @then('trace') diff --git a/pytest_bdd/scenario.py b/pytest_bdd/scenario.py index 1c14435..c9e7bc2 100644 --- a/pytest_bdd/scenario.py +++ b/pytest_bdd/scenario.py @@ -76,6 +76,23 @@ def _inject_fixture(request, arg, value): request.fixturenames.append(arg) +def _find_argumented_step_fixture_name(name, fixturemanager, request=None): + """Find argumented step fixture name.""" + for fixturename, fixturedefs in fixturemanager._arg2fixturedefs.items(): + for fixturedef in fixturedefs: + + 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) + if request: + _inject_fixture(request, arg, value) + return pattern.pattern + + def _find_step_function(request, step, encoding): """Match the step defined by the regular expression pattern. @@ -89,18 +106,9 @@ def _find_step_function(request, step, encoding): return request.getfuncargvalue(force_encode(name, encoding)) except python.FixtureLookupError: try: - for fixturename, fixturedefs in request._fixturemanager._arg2fixturedefs.items(): - for fixturedef in fixturedefs: - - 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) + name = _find_argumented_step_fixture_name(name, request._fixturemanager, request) + if name: + return request.getfuncargvalue(name) raise except python.FixtureLookupError: raise exceptions.StepDefinitionNotFoundError( diff --git a/setup.py b/setup.py index 4f64177..1d5643c 100755 --- a/setup.py +++ b/setup.py @@ -65,6 +65,7 @@ setup( 'pytest11': [ 'pytest-bdd = pytest_bdd.plugin', 'pytest-bdd-cucumber-json = pytest_bdd.cucumber_json', + 'pytest-bdd-generation = pytest_bdd.generation', ], 'console_scripts': [ 'pytest-bdd = pytest_bdd.scripts:main' diff --git a/tests/generation/__init__.py b/tests/generation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/generation/generation.feature b/tests/generation/generation.feature new file mode 100644 index 0000000..4b2fab3 --- /dev/null +++ b/tests/generation/generation.feature @@ -0,0 +1,12 @@ +Feature: Missing code generation + + Scenario: Scenario tests which are already bound to the tests stay as is + Given I have a bar + + + Scenario: Code is generated for scenarios which are not bound to any tests + Given I have a bar + + + Scenario: Code is generated for scenario steps which are not yet defined(implemented) + Given I have a custom bar diff --git a/tests/generation/test_generate_missing.py b/tests/generation/test_generate_missing.py new file mode 100644 index 0000000..4c53381 --- /dev/null +++ b/tests/generation/test_generate_missing.py @@ -0,0 +1,40 @@ +"""Code generation and assertion tests.""" +import os.path + +import py + + +def test_generate_missing(testdir): + tests = testdir.mkpydir('tests') + with open(os.path.join(os.path.dirname(__file__), 'generation.feature')) as fd: + tests.join('generation.feature').write(fd.read()) + + tests.join("test_foo.py").write(py.code.Source(""" + import functools + + from pytest_bdd import scenario, given + + scenario = functools.partial(scenario, 'generation.feature') + + @given('I have a bar') + def i_have_a_bar(): + return 'bar' + + @scenario('Scenario tests which are already bound to the tests stay as is') + def test_foo(): + pass + + @scenario('Code is generated for scenario steps which are not yet defined(implemented)') + def test_missing_steps(): + pass + """)) + + result = testdir.runpytest( + "tests", "--generate-missing", "--feature-file", tests.join('generation.feature').strpath) + + result.stdout.fnmatch_lines([ + 'Scenario is not bound to any test: "Code is generated for scenarios which are not bound to any tests" *']) + + result.stdout.fnmatch_lines([ + 'Step is not defined: "I have a custom bar" in scenario: "Code is generated for scenario steps which are not ' + 'yet defined(implemented)" *'])