Skip to content

Commit ffe906b

Browse files
author
Oleg Pidsadnyi
committed
Merge pull request #46 from albertjan/cucumber-json-formatter
Cucumber json formatter
2 parents 7df81d9 + 6e80100 commit ffe906b

13 files changed

Lines changed: 411 additions & 35 deletions

File tree

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1+
*.rej
12
*.py[cod]
3+
/.env
4+
*.orig
25

36
# C extensions
47
*.so
@@ -40,6 +43,9 @@ nosetests.xml
4043
# Sublime
4144
/*.sublime-*
4245

46+
#PyCharm
47+
/.idea
48+
4349
# virtualenv
4450
/.Python
4551
/lib

CHANGES.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
Changelog
22
=========
33

4+
2.2.0
5+
-----
6+
7+
- Implemented cucumber json formatter (bubenkoff, albertjan)
8+
49

510
2.1.2
611
-----

Makefile

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# create virtual environment
2+
.env:
3+
virtualenv .env
4+
5+
# install all needed for development
6+
develop: .env
7+
.env/bin/pip install -e . -r requirements-testing.txt
8+
9+
# clean the development envrironment
10+
clean:
11+
-rm -rf .env

README.rst

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -594,6 +594,20 @@ Tools recommended to use for browser testing:
594594
* `pytest-splinter <https://github.com/paylogic/pytest-splinter>`_ - pytest `splinter <http://splinter.cobrateam.info/>`_ integration for the real browser testing
595595

596596

597+
Reporting
598+
---------
599+
600+
It's important to have nice reporting out of your bdd tests. Cucumber introduced some kind of standard for
601+
`json format <https://www.relishapp.com/cucumber/cucumber/docs/json-output-formatter>`_
602+
which can be used for `this <https://wiki.jenkins-ci.org/display/JENKINS/Cucumber+Test+Result+Plugin>`_ jenkins
603+
plugin
604+
605+
To have an output in json format:
606+
607+
::
608+
609+
py.test --cucumberjson=<path to json report>
610+
597611

598612
Migration of your tests from versions 0.x.x-1.x.x
599613
-------------------------------------------------

pytest_bdd/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
__version__ = '2.1.2'
1+
"""pytest-bdd public api."""
2+
__version__ = '2.2.0'
23

34
try:
45
from pytest_bdd.steps import given, when, then # pragma: no cover

pytest_bdd/cucumber_json.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
"""Cucumber json output formatter."""
2+
import json
3+
import os
4+
import time
5+
6+
import py
7+
8+
from .feature import force_unicode
9+
10+
11+
def pytest_addoption(parser):
12+
group = parser.getgroup('pytest-bdd')
13+
group.addoption(
14+
'--cucumberjson', '--cucumber-json', action='store',
15+
dest='cucumber_json_path', metavar='path', default=None,
16+
help='create cucumber json style report file at given path.')
17+
18+
19+
def pytest_configure(config):
20+
cucumber_json_path = config.option.cucumber_json_path
21+
# prevent opening json log on slave nodes (xdist)
22+
if cucumber_json_path and not hasattr(config, 'slaveinput'):
23+
config._bddcucumberjson = LogBDDCucumberJSON(cucumber_json_path)
24+
config.pluginmanager.register(config._bddcucumberjson)
25+
26+
27+
def pytest_unconfigure(config):
28+
xml = getattr(config, '_bddcucumberjson', None)
29+
if xml is not None:
30+
del config._bddcucumberjson
31+
config.pluginmanager.unregister(xml)
32+
33+
34+
class LogBDDCucumberJSON(object):
35+
36+
"""Logging plugin for cucumber like json output."""
37+
38+
def __init__(self, logfile):
39+
logfile = os.path.expanduser(os.path.expandvars(logfile))
40+
self.logfile = os.path.normpath(os.path.abspath(logfile))
41+
self.features = {}
42+
43+
def append(self, obj):
44+
self.features[-1].append(obj)
45+
46+
def _get_result(self, step, report):
47+
"""Get scenario test run result.
48+
49+
:param step: `Step` step we get result for
50+
:param report: pytest `Report` object
51+
:return: `dict` in form {'status': '<passed|failed|skipped>', ['error_message': '<error_message>']}
52+
"""
53+
if report.passed or not step['failed']: # ignore setup/teardown
54+
return {'status': 'passed'}
55+
elif report.failed and step['failed']:
56+
return {
57+
'status': 'failed',
58+
'error_message': force_unicode(report.longrepr),
59+
}
60+
elif report.skipped:
61+
return {'status': 'skipped'}
62+
63+
def pytest_runtest_logreport(self, report):
64+
try:
65+
scenario = report.scenario
66+
except AttributeError:
67+
# skip reporting for non-bdd tests
68+
return
69+
70+
if not scenario['steps'] or report.when != 'call':
71+
# skip if there isn't a result or scenario has no steps
72+
return
73+
74+
def stepmap(step):
75+
return {
76+
"keyword": step['keyword'],
77+
"name": step['name'],
78+
"line": step['line_number'],
79+
"match": {
80+
"location": ""
81+
},
82+
"result": self._get_result(step, report),
83+
}
84+
85+
if scenario['feature']['filename'] not in self.features:
86+
self.features[scenario['feature']['filename']] = {
87+
"keyword": "Feature",
88+
"uri": scenario['feature']['rel_filename'],
89+
"name": scenario['feature']['name'] or scenario['feature']['rel_filename'],
90+
"id": scenario['feature']['rel_filename'].lower().replace(' ', '-'),
91+
"line": scenario['feature']['line_number'],
92+
"description": scenario['feature']['description'],
93+
"tags": [],
94+
"elements": [],
95+
}
96+
97+
self.features[scenario['feature']['filename']]['elements'].append({
98+
"keyword": "Scenario",
99+
"id": report.item['name'],
100+
"name": scenario['name'],
101+
"line": scenario['line_number'],
102+
"description": '',
103+
"tags": [],
104+
"type": "scenario",
105+
"steps": [stepmap(step) for step in scenario['steps']],
106+
})
107+
108+
def pytest_sessionstart(self):
109+
self.suite_start_time = time.time()
110+
111+
def pytest_sessionfinish(self):
112+
if py.std.sys.version_info[0] < 3:
113+
logfile_open = py.std.codecs.open
114+
else:
115+
logfile_open = open
116+
with logfile_open(self.logfile, 'w', encoding='utf-8') as logfile:
117+
logfile.write(json.dumps(list(self.features.values())))
118+
119+
def pytest_terminal_summary(self, terminalreporter):
120+
terminalreporter.write_sep('-', 'generated json file: %s' % (self.logfile))

pytest_bdd/feature.py

Lines changed: 51 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
one line.
2424
2525
"""
26+
from os import path as op # pragma: no cover
27+
2628
import re # pragma: no cover
2729
import sys # pragma: no cover
2830
import textwrap
@@ -32,6 +34,7 @@
3234

3335

3436
class FeatureError(Exception): # pragma: no cover
37+
3538
"""Feature parse error."""
3639

3740
message = u'{0}.\nLine number: {1}.\nLine: {2}.'
@@ -93,18 +96,17 @@ def strip_comments(line):
9396
return line.strip()
9497

9598

96-
def remove_prefix(line):
97-
"""Remove the step prefix (Scenario, Given, When, Then or And).
99+
def parse_line(line):
100+
"""Parse step line to get the step prefix (Scenario, Given, When, Then or And) and the actual step name.
98101
99102
:param line: Line of the Feature file.
100103
101-
:return: Line without the prefix.
102-
104+
:return: `tuple` in form ('<prefix>', '<Line without the prefix>').
103105
"""
104106
for prefix, _ in STEP_PREFIXES:
105107
if line.startswith(prefix):
106-
return line[len(prefix):].strip()
107-
return line
108+
return prefix.strip(), line[len(prefix):].strip()
109+
return '', line
108110

109111

110112
def _open_file(filename, encoding):
@@ -114,11 +116,19 @@ def _open_file(filename, encoding):
114116
return open(filename, 'r', encoding=encoding)
115117

116118

117-
def force_unicode(string, encoding='utf-8'):
118-
if sys.version_info < (3, 0) and isinstance(string, str):
119-
return string.decode(encoding)
119+
def force_unicode(obj, encoding='utf-8'):
120+
"""Get the unicode string out of given object (python 2 and python 3).
121+
122+
:param obj: `object`, usually a string
123+
:return: unicode string
124+
"""
125+
if sys.version_info < (3, 0):
126+
if isinstance(obj, str):
127+
return obj.decode(encoding)
128+
else:
129+
return unicode(obj)
120130
else:
121-
return string
131+
return str(obj)
122132

123133

124134
def force_encode(string, encoding='utf-8'):
@@ -129,17 +139,20 @@ def force_encode(string, encoding='utf-8'):
129139

130140

131141
class Feature(object):
142+
132143
"""Feature."""
133144

134-
def __init__(self, filename, encoding='utf-8'):
145+
def __init__(self, basedir, filename, encoding='utf-8'):
135146
"""Parse the feature file.
136147
137148
:param filename: Relative path to the feature file.
138149
139150
"""
140151
self.scenarios = {}
141-
142-
self.filename = filename
152+
self.rel_filename = op.join(op.basename(basedir), filename)
153+
self.filename = filename = op.abspath(op.join(basedir, filename))
154+
self.line_number = 1
155+
self.name = None
143156
scenario = None
144157
mode = None
145158
prev_mode = None
@@ -181,16 +194,17 @@ def __init__(self, filename, encoding='utf-8'):
181194

182195
if mode == types.FEATURE:
183196
if prev_mode != types.FEATURE:
184-
self.name = remove_prefix(clean_line)
197+
_, self.name = parse_line(clean_line)
198+
self.line_number = line_number
185199
else:
186200
description.append(clean_line)
187201

188202
prev_mode = mode
189203

190204
# Remove Feature, Given, When, Then, And
191-
clean_line = remove_prefix(clean_line)
205+
keyword, clean_line = parse_line(clean_line)
192206
if mode in [types.SCENARIO, types.SCENARIO_OUTLINE]:
193-
self.scenarios[clean_line] = scenario = Scenario(self, clean_line)
207+
self.scenarios[clean_line] = scenario = Scenario(self, clean_line, line_number)
194208
elif mode == types.EXAMPLES:
195209
mode = types.EXAMPLES_HEADERS
196210
elif mode == types.EXAMPLES_VERTICAL:
@@ -205,14 +219,16 @@ def __init__(self, filename, encoding='utf-8'):
205219
scenario.add_example_row(clean_line[0], clean_line[1:])
206220
elif mode and mode != types.FEATURE:
207221
step = scenario.add_step(
208-
step_name=clean_line, step_type=mode, indent=line_indent, line_number=line_number)
222+
step_name=clean_line, step_type=mode, indent=line_indent, line_number=line_number,
223+
keyword=keyword)
209224

210225
self.description = u'\n'.join(description)
211226

212227
@classmethod
213-
def get_feature(cls, filename, encoding='utf-8'):
228+
def get_feature(cls, base_path, filename, encoding='utf-8'):
214229
"""Get a feature by the filename.
215230
231+
:param base_path: Base feature directory.
216232
:param filename: Filename of the feature file.
217233
218234
:return: `Feature` instance from the parsed feature cache.
@@ -222,37 +238,43 @@ def get_feature(cls, filename, encoding='utf-8'):
222238
when multiple scenarios are referencing the same file.
223239
224240
"""
225-
feature = features.get(filename)
241+
full_name = op.abspath(op.join(base_path, filename))
242+
feature = features.get(full_name)
226243
if not feature:
227-
feature = Feature(filename, encoding=encoding)
228-
features[filename] = feature
244+
feature = Feature(base_path, filename, encoding=encoding)
245+
features[full_name] = feature
229246
return feature
230247

231248

232249
class Scenario(object):
250+
233251
"""Scenario."""
234252

235-
def __init__(self, feature, name, example_converters=None):
253+
def __init__(self, feature, name, line_number, example_converters=None):
236254
self.feature = feature
237255
self.name = name
238256
self.params = set()
239257
self.steps = []
240258
self.example_params = []
241259
self.examples = []
242260
self.vertical_examples = []
261+
self.line_number = line_number
243262
self.example_converters = example_converters
244263

245-
def add_step(self, step_name, step_type, indent, line_number):
264+
def add_step(self, step_name, step_type, indent, line_number, keyword):
246265
"""Add step to the scenario.
247266
248267
:param step_name: Step name.
249268
:param step_type: Step type.
250-
269+
:param indent: `int` step text indent
270+
:param line_number: `int` line number
271+
:param keyword: `str` step keyword
251272
"""
252273
params = get_step_params(step_name)
253274
self.params.update(params)
254275
step = Step(
255-
name=step_name, type=step_type, params=params, scenario=self, indent=indent, line_number=line_number)
276+
name=step_name, type=step_type, params=params, scenario=self, indent=indent, line_number=line_number,
277+
keyword=keyword)
256278
self.steps.append(step)
257279
return step
258280

@@ -326,16 +348,19 @@ def validate(self):
326348

327349

328350
class Step(object):
351+
329352
"""Step."""
330353

331-
def __init__(self, name, type, params, scenario, indent, line_number):
354+
def __init__(self, name, type, params, scenario, indent, line_number, keyword):
332355
self.name = name
356+
self.keyword = keyword
333357
self.lines = []
334358
self.indent = indent
335359
self.type = type
336360
self.params = params
337361
self.scenario = scenario
338362
self.line_number = line_number
363+
self.failed = False
339364

340365
def add_line(self, line):
341366
"""Add line to the multiple step."""

0 commit comments

Comments
 (0)