pytest-json-ctrf 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- plugin/BaseMetadataReport.py +34 -0
- plugin/CommonTestReportPlugin.py +26 -0
- plugin/Report.py +78 -0
- plugin/TestObject.py +73 -0
- plugin/__init__.py +0 -0
- plugin/main.py +41 -0
- pytest_json_ctrf-0.1.0.dist-info/METADATA +93 -0
- pytest_json_ctrf-0.1.0.dist-info/RECORD +11 -0
- pytest_json_ctrf-0.1.0.dist-info/WHEEL +5 -0
- pytest_json_ctrf-0.1.0.dist-info/entry_points.txt +2 -0
- pytest_json_ctrf-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from pytest import Config, hookimpl, Item, CallInfo, CollectReport, Function, TestReport, fixture, FixtureRequest
|
|
2
|
+
from .Report import Report
|
|
3
|
+
from collections import OrderedDict
|
|
4
|
+
|
|
5
|
+
metadata = '_ctrf_metadata'
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BaseMetadataReport:
|
|
9
|
+
'''This class needed only to get metadata
|
|
10
|
+
from test nodes from different workers and
|
|
11
|
+
transfer it the report objects'''
|
|
12
|
+
|
|
13
|
+
def __init__(self):
|
|
14
|
+
self._report = Report()
|
|
15
|
+
|
|
16
|
+
def pytest_configure(self, config: Config):
|
|
17
|
+
if not hasattr(config, '_ctrf'):
|
|
18
|
+
setattr(config, '_ctrf', self)
|
|
19
|
+
|
|
20
|
+
if config.option.ctrf and not self._report:
|
|
21
|
+
self._report = Report()
|
|
22
|
+
|
|
23
|
+
@hookimpl(hookwrapper=True)
|
|
24
|
+
def pytest_runtest_protocol(self, item: Item, nextitem: Item):
|
|
25
|
+
setattr(item, metadata, {})
|
|
26
|
+
yield
|
|
27
|
+
delattr(item, metadata)
|
|
28
|
+
|
|
29
|
+
@hookimpl(hookwrapper=True)
|
|
30
|
+
def pytest_runtest_makereport(self, item, call):
|
|
31
|
+
report = (yield).get_result()
|
|
32
|
+
meta = getattr(item, metadata)
|
|
33
|
+
if meta:
|
|
34
|
+
setattr(report, metadata, meta)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from pytest import Config, hookimpl, Item, CallInfo, CollectReport, Function, TestReport, fixture, FixtureRequest
|
|
2
|
+
from .Report import Report
|
|
3
|
+
from collections import OrderedDict
|
|
4
|
+
from .BaseMetadataReport import metadata, BaseMetadataReport
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class CTRF(BaseMetadataReport):
|
|
8
|
+
def __init__(self):
|
|
9
|
+
super().__init__()
|
|
10
|
+
self._json_tests = OrderedDict()
|
|
11
|
+
|
|
12
|
+
@hookimpl(tryfirst=True)
|
|
13
|
+
def pytest_sessionstart(self, session):
|
|
14
|
+
if self._report:
|
|
15
|
+
self._report.start()
|
|
16
|
+
|
|
17
|
+
@hookimpl(tryfirst=True)
|
|
18
|
+
def pytest_sessionfinish(self, session):
|
|
19
|
+
if self._report:
|
|
20
|
+
self._report.stop()
|
|
21
|
+
self._report.save(session.config.option.ctrf)
|
|
22
|
+
|
|
23
|
+
def pytest_runtest_logreport(self, report: TestReport):
|
|
24
|
+
if not hasattr(report, metadata):
|
|
25
|
+
setattr(report, metadata, {})
|
|
26
|
+
self._report.collect(report)
|
plugin/Report.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import pytest
|
|
3
|
+
import time
|
|
4
|
+
import json
|
|
5
|
+
from pytest import Item, CallInfo, TestReport, Mark
|
|
6
|
+
from collections import OrderedDict
|
|
7
|
+
from .TestObject import TestObject, TestStatus
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Report:
|
|
11
|
+
def __init__(self):
|
|
12
|
+
self.test_items = OrderedDict()
|
|
13
|
+
self.prepared_tests = dict()
|
|
14
|
+
self.start_time = None
|
|
15
|
+
self.stop_time = None
|
|
16
|
+
|
|
17
|
+
def start(self) -> None:
|
|
18
|
+
self.start_time = time.time()
|
|
19
|
+
|
|
20
|
+
def stop(self) -> None:
|
|
21
|
+
self.stop_time = time.time()
|
|
22
|
+
|
|
23
|
+
@staticmethod
|
|
24
|
+
def _get_tool() -> dict:
|
|
25
|
+
return {
|
|
26
|
+
"name": "pytest",
|
|
27
|
+
"version": str(pytest.__version__)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
def _get_summary(self) -> dict:
|
|
31
|
+
return {
|
|
32
|
+
'tests': len(self.prepared_tests),
|
|
33
|
+
'passed': len([test for test in self.prepared_tests.values() if test.status == TestStatus.PASSED]),
|
|
34
|
+
'failed': len([test for test in self.prepared_tests.values() if test.status == TestStatus.FAILED]),
|
|
35
|
+
'skipped': len([test for test in self.prepared_tests.values() if test.status == TestStatus.SKIPPED]),
|
|
36
|
+
'pending': len([test for test in self.prepared_tests.values() if test.status == TestStatus.PENDING]),
|
|
37
|
+
'other': 0,
|
|
38
|
+
'start': self.start_time,
|
|
39
|
+
'stop': self.stop_time
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
def collect(self, report: TestReport) -> None:
|
|
43
|
+
if report.nodeid not in self.test_items.keys():
|
|
44
|
+
test = TestObject()
|
|
45
|
+
else:
|
|
46
|
+
test = self.test_items.get(report.nodeid)
|
|
47
|
+
test.update(report, getattr(report, 'worker_id', None))
|
|
48
|
+
self.test_items[report.nodeid] = test
|
|
49
|
+
|
|
50
|
+
def process_retries(self) -> None:
|
|
51
|
+
for test_id, test_data in self.test_items.items():
|
|
52
|
+
name = test_id.split('[')[0]
|
|
53
|
+
test = self.prepared_tests.get(name)
|
|
54
|
+
if test:
|
|
55
|
+
test.retries += 1
|
|
56
|
+
else:
|
|
57
|
+
self.prepared_tests[name] = test_data
|
|
58
|
+
|
|
59
|
+
def get_report(self) -> dict:
|
|
60
|
+
self.process_retries()
|
|
61
|
+
return {'results':
|
|
62
|
+
{
|
|
63
|
+
"tool": self._get_tool(),
|
|
64
|
+
"summary": self._get_summary(),
|
|
65
|
+
"tests": [test.serialize() for test in self.prepared_tests.values()]
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
def save(self, report_file: str) -> None:
|
|
70
|
+
dirname = os.path.dirname(report_file)
|
|
71
|
+
if dirname:
|
|
72
|
+
try:
|
|
73
|
+
os.makedirs(dirname)
|
|
74
|
+
except Exception as e:
|
|
75
|
+
print(e)
|
|
76
|
+
raise Exception(f"Can't create directory {dirname}")
|
|
77
|
+
with open(report_file, 'w') as file:
|
|
78
|
+
json.dump(self.get_report(), file, default=str, indent=4)
|
plugin/TestObject.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
from pytest import TestReport
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class TestStatus(Enum):
|
|
6
|
+
PASSED = "passed"
|
|
7
|
+
FAILED = "failed"
|
|
8
|
+
SKIPPED = "skipped"
|
|
9
|
+
PENDING = "pending"
|
|
10
|
+
OTHER = "other"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TestObject:
|
|
14
|
+
name: str = None
|
|
15
|
+
status: TestStatus = TestStatus.PENDING
|
|
16
|
+
raw_status: str = None
|
|
17
|
+
duration: float = 0
|
|
18
|
+
start: float = None
|
|
19
|
+
stop: float = None
|
|
20
|
+
retries: int = 0
|
|
21
|
+
|
|
22
|
+
worker_id: str = None
|
|
23
|
+
file_path: str = None
|
|
24
|
+
tags: list[str] = None
|
|
25
|
+
browser: str = None
|
|
26
|
+
trace: str = None
|
|
27
|
+
|
|
28
|
+
def set_status(self, report: TestReport) -> None:
|
|
29
|
+
if self.status in (TestStatus.SKIPPED, TestStatus.FAILED):
|
|
30
|
+
return
|
|
31
|
+
elif report.skipped:
|
|
32
|
+
self.status = TestStatus.SKIPPED
|
|
33
|
+
elif report.failed:
|
|
34
|
+
self.status = TestStatus.FAILED
|
|
35
|
+
self.raw_status = f"{report.when}_{report.outcome}"
|
|
36
|
+
elif report.passed:
|
|
37
|
+
self.status = TestStatus.PASSED
|
|
38
|
+
else:
|
|
39
|
+
self.status = TestStatus.OTHER
|
|
40
|
+
|
|
41
|
+
def update(self, report: TestReport, worker_id: str = None) -> None:
|
|
42
|
+
self.name = report.head_line.split('[')[0]
|
|
43
|
+
self.set_status(report)
|
|
44
|
+
self.duration += report.duration
|
|
45
|
+
if report.when == "setup" and not self.start:
|
|
46
|
+
self.start = report.start
|
|
47
|
+
if report.when == "teardown" and not self.stop:
|
|
48
|
+
self.stop = report.stop
|
|
49
|
+
self.file_path = report.location[0]
|
|
50
|
+
if report.longrepr and len(report.longreprtext) > 0:
|
|
51
|
+
self.trace = report.longreprtext
|
|
52
|
+
if hasattr(report, '_ctrf_metadata'):
|
|
53
|
+
self.tags = report._ctrf_metadata.get('tags')
|
|
54
|
+
self.browser = report._ctrf_metadata.get('browser')
|
|
55
|
+
self.worker_id = worker_id
|
|
56
|
+
|
|
57
|
+
def serialize(self) -> dict:
|
|
58
|
+
result = {
|
|
59
|
+
'name': self.name,
|
|
60
|
+
'status': self.status.value,
|
|
61
|
+
'raw_status': self.raw_status,
|
|
62
|
+
'duration': self.duration,
|
|
63
|
+
'start': self.start,
|
|
64
|
+
'stop': self.stop,
|
|
65
|
+
'retries': None if self.retries == 1 else self.retries,
|
|
66
|
+
'file_path': self.file_path,
|
|
67
|
+
'tags': self.tags,
|
|
68
|
+
'browser': self.browser,
|
|
69
|
+
'trace': self.trace
|
|
70
|
+
}
|
|
71
|
+
if self.worker_id:
|
|
72
|
+
result['extra'] = {'worker': self.worker_id}
|
|
73
|
+
return {key: value for key, value in result.items() if value not in [None, '', []]}
|
plugin/__init__.py
ADDED
|
File without changes
|
plugin/main.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from pytest import Config, Parser, FixtureRequest, fixture, Function
|
|
2
|
+
from plugin.CommonTestReportPlugin import CTRF
|
|
3
|
+
from plugin.BaseMetadataReport import BaseMetadataReport
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def pytest_addoption(parser: Parser):
|
|
7
|
+
parser.addoption('--ctrf',
|
|
8
|
+
action='store',
|
|
9
|
+
help='generate test report. Report file name is optional')
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def pytest_configure(config: Config):
|
|
13
|
+
if not config.option.ctrf:
|
|
14
|
+
return
|
|
15
|
+
if hasattr(config, 'workerinput'):
|
|
16
|
+
plugin = BaseMetadataReport()
|
|
17
|
+
else:
|
|
18
|
+
plugin = CTRF()
|
|
19
|
+
setattr(config, '_ctrf', plugin)
|
|
20
|
+
config.pluginmanager.register(plugin, name='ctrf')
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def pytest_unconfigure(config: Config):
|
|
24
|
+
ctrf = getattr(config, '_ctrf', None)
|
|
25
|
+
if ctrf is not None:
|
|
26
|
+
delattr(config, '_ctrf')
|
|
27
|
+
if config.pluginmanager.hasplugin('ctrf'):
|
|
28
|
+
config.pluginmanager.unregister(name='ctrf')
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@fixture(autouse=True)
|
|
32
|
+
def ctrf_json_metadata(request: FixtureRequest):
|
|
33
|
+
if not request.config.option.ctrf:
|
|
34
|
+
return
|
|
35
|
+
tags = [mark.name for mark in request.node.iter_markers()]
|
|
36
|
+
request.node._ctrf_metadata.setdefault('tags', tags)
|
|
37
|
+
if hasattr(request.node, 'callspec'):
|
|
38
|
+
browser = request.node.callspec.params.get('browser_name')
|
|
39
|
+
if browser:
|
|
40
|
+
request.node._ctrf_metadata.setdefault('browser', browser)
|
|
41
|
+
return request.node._ctrf_metadata
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: pytest-json-ctrf
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Pytest plugin to generate json report in CTRF (Common Test Report Format)
|
|
5
|
+
Author: Oleksii Ostapov
|
|
6
|
+
Project-URL: Infopulse, https://infopulse.com/
|
|
7
|
+
Project-URL: Homepage, https://infopulse.com/pytest-json-ctrf
|
|
8
|
+
Classifier: Framework :: Pytest
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
Requires-Dist: pytest >6.0.0
|
|
15
|
+
|
|
16
|
+
# CTRF for pytest
|
|
17
|
+
|
|
18
|
+
Pytest implementation of Common Test Report Format (CTRF) for test results.
|
|
19
|
+
Test report will be generated in JSON format.
|
|
20
|
+
Test report can be used to prettify the report in GitHub Actions with [github-actions-ctrf](https://github.com/ctrf-io/github-actions-ctrf).
|
|
21
|
+
Do not worry if report in GitHub does not appear immediately. It takes some time to process the report.
|
|
22
|
+
|
|
23
|
+
## Features
|
|
24
|
+
- Generates JSON report
|
|
25
|
+
- Tested to work correctly with and without [pytest-xdist](https://pypi.org/project/pytest-xdist/)
|
|
26
|
+
- Tested to get browser name from [pytest-playwright](https://pypi.org/project/pytest-playwright/)
|
|
27
|
+
- Parametrized tests are supported and reported as 1 test with multiple retries
|
|
28
|
+
|
|
29
|
+
## Installation
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install pytest-json-ctrf
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Usage
|
|
36
|
+
|
|
37
|
+
generate report.json file in the root directory of the project. File path is mandatory
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pytest --ctrf report.json
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Json exampe
|
|
44
|
+
|
|
45
|
+
More info here: https://ctrf.io/docs/schema/examples
|
|
46
|
+
|
|
47
|
+
```json
|
|
48
|
+
{
|
|
49
|
+
"results": {
|
|
50
|
+
"tool": {
|
|
51
|
+
"name": "jest"
|
|
52
|
+
},
|
|
53
|
+
"summary": {
|
|
54
|
+
"tests": 3,
|
|
55
|
+
"passed": 1,
|
|
56
|
+
"failed": 1,
|
|
57
|
+
"pending": 0,
|
|
58
|
+
"skipped": 1,
|
|
59
|
+
"other": 0,
|
|
60
|
+
"start": 1706644023,
|
|
61
|
+
"stop": 1706644043
|
|
62
|
+
},
|
|
63
|
+
"tests": [
|
|
64
|
+
{
|
|
65
|
+
"name": "User should be able to login",
|
|
66
|
+
"status": "passed",
|
|
67
|
+
"duration": 1200
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
"name": "User profile information should be correct",
|
|
71
|
+
"status": "failed",
|
|
72
|
+
"duration": 800
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"name": "User should be able to logout",
|
|
76
|
+
"status": "skipped",
|
|
77
|
+
"duration": 0
|
|
78
|
+
}
|
|
79
|
+
]
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Report Example
|
|
85
|
+

|
|
86
|
+
|
|
87
|
+
## Credits
|
|
88
|
+
|
|
89
|
+
- https://ctrf.io/ -> nice data format
|
|
90
|
+
- https://github.com/numirias/pytest-json-report -> Source of inspiration and dealing with xdist sync
|
|
91
|
+
- https://github.com/testomatio/pytestomatio -> Source of inspiration for creating pytest plugins
|
|
92
|
+
- https://github.com/infopulse/Playwright-course-python -> The report will be used in the demo project as +1 report option
|
|
93
|
+
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
plugin/BaseMetadataReport.py,sha256=YWO8oYC5JXKOu6mHz1I1MlhGMiQXFmgk1rwOuMY81s4,1050
|
|
2
|
+
plugin/CommonTestReportPlugin.py,sha256=1u5SN8MYjnaLgLgcpsed-Pal2wcSNq0vd-e4SKxTz1c,868
|
|
3
|
+
plugin/Report.py,sha256=rLtjA81S64k05hMxUcJhehikEWrRJqlT04KnJbtbwSs,2659
|
|
4
|
+
plugin/TestObject.py,sha256=fHIGuWtdfYX3nUVSoOZTodx1yOppyr2pazucyI641II,2401
|
|
5
|
+
plugin/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
plugin/main.py,sha256=3JR8ZKEGeWcQgx4PInZe0ZjFx8_qf_VvDJa98xccBxA,1378
|
|
7
|
+
pytest_json_ctrf-0.1.0.dist-info/METADATA,sha256=N8Jbu1A8__CkPvN0y_eA6LmlZUwbj1DkSkJEai9aQjo,2617
|
|
8
|
+
pytest_json_ctrf-0.1.0.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
|
|
9
|
+
pytest_json_ctrf-0.1.0.dist-info/entry_points.txt,sha256=pKOt_brpWtWRajUgLmRfxbti6kTylIjf7kjtBeoA1QM,38
|
|
10
|
+
pytest_json_ctrf-0.1.0.dist-info/top_level.txt,sha256=Io_dflkI6h0vZSGOzZxx4e76CDTSANVfu4v4tVY6zsA,7
|
|
11
|
+
pytest_json_ctrf-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
plugin
|