pytest-json-ctrf 0.5.3__tar.gz → 0.6.0__tar.gz

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.
Files changed (21) hide show
  1. pytest_json_ctrf-0.6.0/MANIFEST.in +2 -0
  2. {pytest_json_ctrf-0.5.3 → pytest_json_ctrf-0.6.0}/PKG-INFO +31 -10
  3. {pytest_json_ctrf-0.5.3 → pytest_json_ctrf-0.6.0}/README.md +29 -8
  4. {pytest_json_ctrf-0.5.3 → pytest_json_ctrf-0.6.0}/ctrf/Report.py +37 -30
  5. pytest_json_ctrf-0.6.0/ctrf/TestObject.py +166 -0
  6. {pytest_json_ctrf-0.5.3 → pytest_json_ctrf-0.6.0}/ctrf/main.py +17 -1
  7. {pytest_json_ctrf-0.5.3 → pytest_json_ctrf-0.6.0}/pyproject.toml +10 -3
  8. {pytest_json_ctrf-0.5.3 → pytest_json_ctrf-0.6.0}/pytest_json_ctrf.egg-info/PKG-INFO +31 -10
  9. {pytest_json_ctrf-0.5.3 → pytest_json_ctrf-0.6.0}/pytest_json_ctrf.egg-info/SOURCES.txt +2 -3
  10. pytest_json_ctrf-0.6.0/pytest_json_ctrf.egg-info/top_level.txt +1 -0
  11. pytest_json_ctrf-0.5.3/ctrf/TestObject.py +0 -107
  12. pytest_json_ctrf-0.5.3/pytest_json_ctrf.egg-info/top_level.txt +0 -2
  13. pytest_json_ctrf-0.5.3/tests/test_example.py +0 -65
  14. pytest_json_ctrf-0.5.3/tests/test_plugin.py +0 -108
  15. {pytest_json_ctrf-0.5.3 → pytest_json_ctrf-0.6.0}/ctrf/BaseMetadataReport.py +0 -0
  16. {pytest_json_ctrf-0.5.3 → pytest_json_ctrf-0.6.0}/ctrf/CommonTestReportPlugin.py +0 -0
  17. {pytest_json_ctrf-0.5.3 → pytest_json_ctrf-0.6.0}/ctrf/__init__.py +0 -0
  18. {pytest_json_ctrf-0.5.3 → pytest_json_ctrf-0.6.0}/pytest_json_ctrf.egg-info/dependency_links.txt +0 -0
  19. {pytest_json_ctrf-0.5.3 → pytest_json_ctrf-0.6.0}/pytest_json_ctrf.egg-info/entry_points.txt +0 -0
  20. {pytest_json_ctrf-0.5.3 → pytest_json_ctrf-0.6.0}/pytest_json_ctrf.egg-info/requires.txt +0 -0
  21. {pytest_json_ctrf-0.5.3 → pytest_json_ctrf-0.6.0}/setup.cfg +0 -0
@@ -0,0 +1,2 @@
1
+ exclude .env.example
2
+ prune tests
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pytest-json-ctrf
3
- Version: 0.5.3
3
+ Version: 0.6.0
4
4
  Summary: Pytest plugin to generate json report in CTRF (Common Test Report Format)
5
5
  Author: Oleksii Ostapov
6
- Project-URL: Infopulse, https://infopulse.com/
6
+ Project-URL: QAMania, https://qamania.org
7
7
  Project-URL: Homepage, https://github.com/infopulse/pytest-common-test-report-json
8
8
  Classifier: Framework :: Pytest
9
9
  Classifier: Programming Language :: Python :: 3
@@ -24,7 +24,9 @@ Do not worry if report in GitHub does not appear immediately. It takes some time
24
24
  - Generates JSON report
25
25
  - Tested to work correctly with and without [pytest-xdist](https://pypi.org/project/pytest-xdist/)
26
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
27
+ - Reruns from [pytest-rerunfailures](https://pypi.org/project/pytest-rerunfailures/) are reported as `retries`, `retryAttempts` and `flaky`
28
+ - Each test gets a `suite` hierarchy: file, then classes
29
+ - Parametrized tests are supported: each case is reported as a separate test named by its full pytest node id (e.g. `test_file.py::test_param[1]`), with its values in `parameters`
28
30
 
29
31
  ## Installation
30
32
 
@@ -41,13 +43,20 @@ pytest --ctrf report.json
41
43
  ```
42
44
  Environment Variables may be used to specify the required Environment Object
43
45
  fields when using the CTRF [slack-test-reporter][ctrf-slack-test-reporter-url].
46
+ All of them are optional: a variable that is not set is left out of the report, see [.env.example](./.env.example).
44
47
 
45
48
  ```bash
46
49
  CTRF_BUILD_NAME="Pytest JSON CTRF Report"
47
- CTRF_BUILD_NUMBER="000"
48
- CTRF_BUILD_URL="https://ctrf.io]"
50
+ CTRF_BUILD_NUMBER=123
51
+ CTRF_BUILD_URL="https://ctrf.io"
52
+ CTRF_TEST_ENVIRONMENT="staging"
53
+ CTRF_TEST_TYPE="e2e"
49
54
  ```
50
55
 
56
+ `CTRF_BUILD_NUMBER` must be an integer. Otherwise a warning is issued and `buildNumber` is omitted from the report.
57
+ `osPlatform`, `osRelease` and `osVersion` are detected automatically.
58
+ `CTRF_TEST_TYPE` is written to every test's `type` field.
59
+
51
60
  ## JSON example
52
61
 
53
62
  More info here: https://ctrf.io/docs/schema/examples
@@ -55,11 +64,12 @@ More info here: https://ctrf.io/docs/schema/examples
55
64
  ```json
56
65
  {
57
66
  "reportFormat": "CTRF",
58
- "specVersion": "0.0.0",
59
- "generatedBy": "pytest",
67
+ "specVersion": "1.0.0",
68
+ "generatedBy": "pytest",
60
69
  "results": {
61
70
  "tool": {
62
- "name": "jest"
71
+ "name": "pytest",
72
+ "version": "9.0.3"
63
73
  },
64
74
  "summary": {
65
75
  "tests": 3,
@@ -73,8 +83,12 @@ More info here: https://ctrf.io/docs/schema/examples
73
83
  },
74
84
  "environment": {
75
85
  "buildName": "Pytest JSON CTRF Report",
76
- "buildNumber": "000",
77
- "buildUrl": "https://ctrf.io"
86
+ "buildUrl": "https://ctrf.io",
87
+ "osPlatform": "linux",
88
+ "osRelease": "6.8.0-45-generic",
89
+ "osVersion": "#45-Ubuntu SMP PREEMPT_DYNAMIC",
90
+ "testEnvironment": "staging",
91
+ "buildNumber": 123
78
92
  },
79
93
  "tests": [
80
94
  {
@@ -114,6 +128,13 @@ The `pytest_runtest_logreport` hook in the controller node is used to collect th
114
128
  - https://github.com/infopulse/Playwright-course-python -> The report will be used in the demo project as +1 report option
115
129
 
116
130
  ## Changelog
131
+ - 0.6.0 - fixed issue 12 - report now validates against the CTRF schema. Thanks to [@jamesarosen](https://github.com/jamesarosen) for the detailed report!
132
+ - `filePath`, `rawStatus` and integer `buildNumber`; environment fields are added only when their variables are set (no defaults), plus `osPlatform`, `osRelease`, `osVersion` and `type` (`CTRF_TEST_TYPE`)
133
+ - parametrized tests are named by the full node id and get `parameters`
134
+ - `message` is the real failure message; skip and xfail reasons are reported in `message`
135
+ - reruns (pytest-rerunfailures) are reported as `retries`, `retryAttempts` and `flaky`
136
+ - new `suite` field and summary `other`, `flaky`, `suites`, `duration`
137
+ - CI validates reports against the CTRF schema
117
138
  - 0.5.3 - fixed issue 6 - @pytest.mark.parametrize will no longer be added to tags to prevent exhausting memory
118
139
  - 0.5.1 - added mandatory root object fields: reportFormat, specVersion, generatedBy
119
140
  - 0.5.0 - Changed logic of handling parametrized tests. Previously they were marked as retries, now they are reported as separate tests
@@ -9,7 +9,9 @@ Do not worry if report in GitHub does not appear immediately. It takes some time
9
9
  - Generates JSON report
10
10
  - Tested to work correctly with and without [pytest-xdist](https://pypi.org/project/pytest-xdist/)
11
11
  - Tested to get browser name from [pytest-playwright](https://pypi.org/project/pytest-playwright/)
12
- - Parametrized tests are supported and reported as 1 test with multiple retries
12
+ - Reruns from [pytest-rerunfailures](https://pypi.org/project/pytest-rerunfailures/) are reported as `retries`, `retryAttempts` and `flaky`
13
+ - Each test gets a `suite` hierarchy: file, then classes
14
+ - Parametrized tests are supported: each case is reported as a separate test named by its full pytest node id (e.g. `test_file.py::test_param[1]`), with its values in `parameters`
13
15
 
14
16
  ## Installation
15
17
 
@@ -26,13 +28,20 @@ pytest --ctrf report.json
26
28
  ```
27
29
  Environment Variables may be used to specify the required Environment Object
28
30
  fields when using the CTRF [slack-test-reporter][ctrf-slack-test-reporter-url].
31
+ All of them are optional: a variable that is not set is left out of the report, see [.env.example](./.env.example).
29
32
 
30
33
  ```bash
31
34
  CTRF_BUILD_NAME="Pytest JSON CTRF Report"
32
- CTRF_BUILD_NUMBER="000"
33
- CTRF_BUILD_URL="https://ctrf.io]"
35
+ CTRF_BUILD_NUMBER=123
36
+ CTRF_BUILD_URL="https://ctrf.io"
37
+ CTRF_TEST_ENVIRONMENT="staging"
38
+ CTRF_TEST_TYPE="e2e"
34
39
  ```
35
40
 
41
+ `CTRF_BUILD_NUMBER` must be an integer. Otherwise a warning is issued and `buildNumber` is omitted from the report.
42
+ `osPlatform`, `osRelease` and `osVersion` are detected automatically.
43
+ `CTRF_TEST_TYPE` is written to every test's `type` field.
44
+
36
45
  ## JSON example
37
46
 
38
47
  More info here: https://ctrf.io/docs/schema/examples
@@ -40,11 +49,12 @@ More info here: https://ctrf.io/docs/schema/examples
40
49
  ```json
41
50
  {
42
51
  "reportFormat": "CTRF",
43
- "specVersion": "0.0.0",
44
- "generatedBy": "pytest",
52
+ "specVersion": "1.0.0",
53
+ "generatedBy": "pytest",
45
54
  "results": {
46
55
  "tool": {
47
- "name": "jest"
56
+ "name": "pytest",
57
+ "version": "9.0.3"
48
58
  },
49
59
  "summary": {
50
60
  "tests": 3,
@@ -58,8 +68,12 @@ More info here: https://ctrf.io/docs/schema/examples
58
68
  },
59
69
  "environment": {
60
70
  "buildName": "Pytest JSON CTRF Report",
61
- "buildNumber": "000",
62
- "buildUrl": "https://ctrf.io"
71
+ "buildUrl": "https://ctrf.io",
72
+ "osPlatform": "linux",
73
+ "osRelease": "6.8.0-45-generic",
74
+ "osVersion": "#45-Ubuntu SMP PREEMPT_DYNAMIC",
75
+ "testEnvironment": "staging",
76
+ "buildNumber": 123
63
77
  },
64
78
  "tests": [
65
79
  {
@@ -99,6 +113,13 @@ The `pytest_runtest_logreport` hook in the controller node is used to collect th
99
113
  - https://github.com/infopulse/Playwright-course-python -> The report will be used in the demo project as +1 report option
100
114
 
101
115
  ## Changelog
116
+ - 0.6.0 - fixed issue 12 - report now validates against the CTRF schema. Thanks to [@jamesarosen](https://github.com/jamesarosen) for the detailed report!
117
+ - `filePath`, `rawStatus` and integer `buildNumber`; environment fields are added only when their variables are set (no defaults), plus `osPlatform`, `osRelease`, `osVersion` and `type` (`CTRF_TEST_TYPE`)
118
+ - parametrized tests are named by the full node id and get `parameters`
119
+ - `message` is the real failure message; skip and xfail reasons are reported in `message`
120
+ - reruns (pytest-rerunfailures) are reported as `retries`, `retryAttempts` and `flaky`
121
+ - new `suite` field and summary `other`, `flaky`, `suites`, `duration`
122
+ - CI validates reports against the CTRF schema
102
123
  - 0.5.3 - fixed issue 6 - @pytest.mark.parametrize will no longer be added to tags to prevent exhausting memory
103
124
  - 0.5.1 - added mandatory root object fields: reportFormat, specVersion, generatedBy
104
125
  - 0.5.0 - Changed logic of handling parametrized tests. Previously they were marked as retries, now they are reported as separate tests
@@ -1,8 +1,11 @@
1
1
  import os
2
+ import platform
3
+ import sys
2
4
  import pytest
3
5
  import time
4
6
  import json
5
- from typing import Optional
7
+ import warnings
8
+ from uuid import uuid4
6
9
  from datetime import datetime, timezone
7
10
  from pytest import TestReport
8
11
  from collections import OrderedDict
@@ -12,7 +15,6 @@ from .TestObject import TestObject, TestStatus
12
15
  class Report:
13
16
  def __init__(self):
14
17
  self.test_items = OrderedDict()
15
- self.prepared_tests = dict()
16
18
  self.start_time = None
17
19
  self.stop_time = None
18
20
 
@@ -30,27 +32,47 @@ class Report:
30
32
  }
31
33
 
32
34
  def list_tests_by_status(self, status: TestStatus) -> list:
33
- return [test for test in self.prepared_tests.values() if test.status == status]
35
+ return [test for test in self.test_items.values() if test.status == status]
34
36
 
35
37
  def _get_summary(self) -> dict:
36
- return {
37
- 'tests': len(self.prepared_tests),
38
+ tests = list(self.test_items.values())
39
+ summary = {
40
+ 'tests': len(tests),
38
41
  'passed': len(self.list_tests_by_status(TestStatus.PASSED)),
39
42
  'failed': len(self.list_tests_by_status(TestStatus.FAILED)),
40
43
  'skipped': len(self.list_tests_by_status(TestStatus.SKIPPED)),
41
44
  'pending': len(self.list_tests_by_status(TestStatus.PENDING)),
42
- 'other': 0,
45
+ 'other': len(self.list_tests_by_status(TestStatus.OTHER)),
46
+ 'flaky': len([test for test in tests if test.flaky]),
47
+ # a suite is a file or class that directly contains tests
48
+ 'suites': len({tuple(test.suite) for test in tests if test.suite}),
43
49
  'start': self.start_time,
44
- 'stop': self.stop_time
50
+ 'stop': self.stop_time,
45
51
  }
52
+ if self.start_time is not None and self.stop_time is not None:
53
+ summary['duration'] = self.stop_time - self.start_time
54
+ return summary
46
55
 
47
56
  @staticmethod
48
57
  def _get_environment() -> dict:
49
- return {
50
- "buildName": os.getenv("CTRF_BUILD_NAME", "Pytest JSON CTRF Report"),
51
- "buildNumber": os.getenv("CTRF_BUILD_NUMBER", "000"),
52
- "buildUrl": os.getenv("CTRF_BUILD_URL", "https://ctrf.io"),
58
+ env = {
59
+ "osPlatform": sys.platform,
60
+ "osRelease": platform.release(),
61
+ "osVersion": platform.version(),
53
62
  }
63
+ for field, variable in (("buildName", "CTRF_BUILD_NAME"),
64
+ ("buildUrl", "CTRF_BUILD_URL"),
65
+ ("testEnvironment", "CTRF_TEST_ENVIRONMENT")):
66
+ value = os.getenv(variable)
67
+ if value:
68
+ env[field] = value
69
+ raw = os.getenv("CTRF_BUILD_NUMBER")
70
+ if raw:
71
+ try:
72
+ env["buildNumber"] = int(raw)
73
+ except ValueError:
74
+ warnings.warn(f"CTRF_BUILD_NUMBER={raw!r} is not an integer; buildNumber omitted from the report")
75
+ return env
54
76
 
55
77
  def collect(self, report: TestReport) -> None:
56
78
  if report.nodeid not in self.test_items.keys():
@@ -61,34 +83,19 @@ class Report:
61
83
  test.update(report)
62
84
  self.test_items[report.nodeid] = test
63
85
 
64
- def process_retries(self) -> None:
65
- for test_id, test_data in self.test_items.items():
66
- name = str(test_id)
67
- test: Optional[TestObject] = self.prepared_tests.get(name)
68
- if test:
69
- test.status = test_data.status
70
- test.raw_status = test_data.raw_status
71
- if test_data.status != TestStatus.PASSED:
72
- test.status = test_data.status
73
- test.raw_status = test_data.raw_status
74
- test.message = test_data.message
75
- test.trace = test_data.trace
76
- test.retries += 1
77
- else:
78
- self.prepared_tests[name] = test_data
79
-
80
86
  def get_report(self) -> dict:
81
- self.process_retries()
87
+ test_type = os.getenv("CTRF_TEST_TYPE") or None
82
88
  return {
83
89
  'reportFormat': 'CTRF',
84
- 'specVersion': '0.0.0',
90
+ 'specVersion': '1.0.0',
91
+ 'reportId': str(uuid4()),
85
92
  'timestamp': datetime.now(timezone.utc).isoformat(),
86
93
  'generatedBy': 'pytest',
87
94
  'results': {
88
95
  "tool": self._get_tool(),
89
96
  "summary": self._get_summary(),
90
97
  "environment": self._get_environment(),
91
- "tests": [test.serialize() for test in self.prepared_tests.values()]
98
+ "tests": [test.serialize(test_type) for test in self.test_items.values()]
92
99
  }
93
100
  }
94
101
 
@@ -0,0 +1,166 @@
1
+ from enum import Enum
2
+ from typing import Any, Optional, List, Dict
3
+
4
+ from pytest import TestReport
5
+
6
+
7
+ class TestStatus(Enum):
8
+ PASSED = "passed"
9
+ FAILED = "failed"
10
+ SKIPPED = "skipped"
11
+ PENDING = "pending"
12
+ OTHER = "other"
13
+
14
+
15
+ class TestObject:
16
+ name: str
17
+ _status: TestStatus
18
+ raw_status: Optional[str]
19
+ start: int
20
+ stop: int
21
+ retries: int
22
+ message: Optional[str]
23
+
24
+ worker_id: Optional[str]
25
+ file_path: str
26
+ suite: List[str]
27
+ tags: List[str]
28
+ browser: Optional[str]
29
+ parameters: Optional[Dict[str, Any]]
30
+ trace: Optional[str]
31
+ retry_attempts: List[Dict[str, Any]]
32
+
33
+ def __init__(self, report: TestReport, worker_id: Optional[str] = None):
34
+ self.name = report.nodeid
35
+ self.worker_id = worker_id
36
+ self.file_path = report.location[0]
37
+ self.suite = self._suite(report.nodeid)
38
+ self.tags: List[str] = []
39
+ self.browser = None
40
+ self.parameters = None
41
+ self.retry_attempts = []
42
+ self._reset_attempt()
43
+
44
+ def _reset_attempt(self) -> None:
45
+ self._status = TestStatus.PENDING
46
+ self.raw_status = None
47
+ self.message = None
48
+ self.trace = None
49
+ self.start = 0
50
+ self.stop = 0
51
+ self._setup_seen = False
52
+
53
+ @staticmethod
54
+ def _suite(nodeid: str) -> List[str]:
55
+ # "dir/test_file.py::TestClass::test_name[param]" -> ["dir/test_file.py", "TestClass"]
56
+ # parameters are cut off first, they may contain "::"
57
+ return nodeid.split('[', 1)[0].split('::')[:-1]
58
+
59
+ @property
60
+ def status(self) -> TestStatus:
61
+ return self._status
62
+
63
+ @property
64
+ def retries(self) -> int:
65
+ return len(self.retry_attempts)
66
+
67
+ @property
68
+ def flaky(self) -> bool:
69
+ return self._status == TestStatus.PASSED and any(
70
+ attempt['status'] == TestStatus.FAILED.value for attempt in self.retry_attempts)
71
+
72
+ def _finish_attempt(self) -> None:
73
+ attempt = {
74
+ 'attempt': len(self.retry_attempts) + 1,
75
+ 'status': self._status.value,
76
+ 'duration': max(self.stop - self.start, 0),
77
+ 'start': self.start,
78
+ 'stop': self.stop,
79
+ 'message': self.message,
80
+ 'trace': self.trace,
81
+ }
82
+ self.retry_attempts.append({key: value for key, value in attempt.items() if value is not None})
83
+ self._reset_attempt()
84
+
85
+ def set_status(self, report: TestReport) -> TestStatus:
86
+ # the first non-passed outcome wins; message and trace are taken from that same report
87
+ if self._status in (TestStatus.SKIPPED, TestStatus.FAILED, TestStatus.OTHER):
88
+ return self._status
89
+ elif report.skipped:
90
+ self._status = TestStatus.SKIPPED
91
+ if hasattr(report, 'wasxfail'):
92
+ self.raw_status = 'xfailed'
93
+ self.message = report.wasxfail or None
94
+ self.trace = report.longreprtext or None
95
+ else:
96
+ self.message = self._skip_reason(report)
97
+ elif report.failed or report.outcome == 'rerun':
98
+ # "rerun" is a failed attempt that pytest-rerunfailures will run again
99
+ self._status = TestStatus.FAILED
100
+ self.raw_status = f"{report.when}_{report.outcome}"
101
+ self.message = self._failure_message(report)
102
+ self.trace = report.longreprtext or None
103
+ elif report.passed:
104
+ self._status = TestStatus.PASSED
105
+ else:
106
+ self._status = TestStatus.OTHER
107
+ return self._status
108
+
109
+ @staticmethod
110
+ def _failure_message(report: TestReport) -> Optional[str]:
111
+ # same text pytest shows in the short test summary, e.g. "AssertionError: assert 1 == 2"
112
+ crash = getattr(report.longrepr, 'reprcrash', None)
113
+ if crash is not None and crash.message:
114
+ return crash.message
115
+ # plain-text longrepr (some plugins, xdist worker crashes): the last line is usually the error
116
+ lines = [line for line in report.longreprtext.splitlines() if line.strip()]
117
+ return lines[-1].strip() if lines else None
118
+
119
+ @staticmethod
120
+ def _skip_reason(report: TestReport) -> Optional[str]:
121
+ # for skips pytest stores longrepr as a (path, lineno, reason) tuple
122
+ if isinstance(report.longrepr, tuple) and len(report.longrepr) == 3:
123
+ reason = str(report.longrepr[2])
124
+ if reason.startswith('Skipped: '):
125
+ reason = reason[len('Skipped: '):]
126
+ return reason or None
127
+ return report.longreprtext or None
128
+
129
+ def update(self, report: TestReport) -> None:
130
+ if report.when == "setup":
131
+ # every attempt starts with setup, a second one means the test is being rerun
132
+ if self._setup_seen:
133
+ self._finish_attempt()
134
+ self._setup_seen = True
135
+ self.start = int(report.start * 1000)
136
+ self.set_status(report)
137
+ if report.when == "teardown":
138
+ self.stop = int(report.stop * 1000)
139
+ if hasattr(report, '_ctrf_metadata'):
140
+ self.tags = report._ctrf_metadata.get('tags')
141
+ self.browser = report._ctrf_metadata.get('browser')
142
+ self.parameters = report._ctrf_metadata.get('parameters')
143
+
144
+ def serialize(self, test_type: Optional[str] = None) -> dict:
145
+ result: Dict[str, Any] = {
146
+ 'name': self.name,
147
+ 'status': self._status.value,
148
+ 'rawStatus': self.raw_status,
149
+ 'duration': self.stop - self.start,
150
+ 'start': self.start,
151
+ 'stop': self.stop,
152
+ 'suite': self.suite,
153
+ 'type': test_type,
154
+ 'retries': self.retries,
155
+ 'retryAttempts': self.retry_attempts,
156
+ 'flaky': True if self.flaky else None,
157
+ 'filePath': self.file_path,
158
+ 'tags': self.tags,
159
+ 'browser': self.browser,
160
+ 'parameters': self.parameters,
161
+ 'trace': self.trace,
162
+ 'message': self.message
163
+ }
164
+ if self.worker_id:
165
+ result['extra'] = {'worker': self.worker_id}
166
+ return {key: value for key, value in result.items() if value not in [None, '', []]}
@@ -48,7 +48,23 @@ def ctrf_json_metadata(request: FixtureRequest):
48
48
  tags.append(tag)
49
49
  request.node._ctrf_metadata.setdefault('tags', tags)
50
50
  if hasattr(request.node, 'callspec'):
51
- browser = request.node.callspec.params.get('browser_name')
51
+ params = request.node.callspec.params
52
+ browser = params.get('browser_name')
52
53
  if browser:
53
54
  request.node._ctrf_metadata.setdefault('browser', browser)
55
+ request.node._ctrf_metadata.setdefault(
56
+ 'parameters', {key: _serializable_param(value) for key, value in params.items()})
54
57
  return request.node._ctrf_metadata
58
+
59
+
60
+ MAX_PARAM_LENGTH = 200
61
+
62
+
63
+ def _serializable_param(value):
64
+ # keep only primitives: report metadata is sent between xdist workers and dumped to json;
65
+ # long values are truncated to keep the report small (see issue 6)
66
+ if not isinstance(value, (str, int, float, bool, type(None))):
67
+ value = repr(value)
68
+ if isinstance(value, str) and len(value) > MAX_PARAM_LENGTH:
69
+ value = value[:MAX_PARAM_LENGTH] + '...'
70
+ return value
@@ -6,11 +6,11 @@ build-backend = "setuptools.build_meta"
6
6
  exclude = [
7
7
  ".github", "tests", "build", "dist", ".venv",
8
8
  "pytest_json_ctrf.egg-info", ".env", ".gitignore",
9
- "CHANGELOG.md", "assets", "test-results"]
9
+ "CHANGELOG.md", "assets", "test-results", "schema_validator"]
10
10
 
11
11
  [project]
12
12
  name = "pytest-json-ctrf"
13
- version = "0.5.3"
13
+ version = "0.6.0"
14
14
 
15
15
  dependencies = [
16
16
  "pytest>6.0.0",
@@ -30,7 +30,7 @@ classifiers = [
30
30
  ]
31
31
 
32
32
  [project.urls]
33
- "Infopulse" = "https://infopulse.com/"
33
+ "QAMania" = "https://qamania.org"
34
34
  "Homepage" = "https://github.com/infopulse/pytest-common-test-report-json"
35
35
 
36
36
  [tool.pytest.ini_options]
@@ -41,3 +41,10 @@ markers = [
41
41
 
42
42
  [project.entry-points.pytest11]
43
43
  ctrf = "ctrf.main"
44
+
45
+ [dependency-groups]
46
+ dev = [
47
+ "jsonschema>=4.23.0",
48
+ "pytest-rerunfailures>=14.0",
49
+ "pytest-xdist>=3.6.1",
50
+ ]
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pytest-json-ctrf
3
- Version: 0.5.3
3
+ Version: 0.6.0
4
4
  Summary: Pytest plugin to generate json report in CTRF (Common Test Report Format)
5
5
  Author: Oleksii Ostapov
6
- Project-URL: Infopulse, https://infopulse.com/
6
+ Project-URL: QAMania, https://qamania.org
7
7
  Project-URL: Homepage, https://github.com/infopulse/pytest-common-test-report-json
8
8
  Classifier: Framework :: Pytest
9
9
  Classifier: Programming Language :: Python :: 3
@@ -24,7 +24,9 @@ Do not worry if report in GitHub does not appear immediately. It takes some time
24
24
  - Generates JSON report
25
25
  - Tested to work correctly with and without [pytest-xdist](https://pypi.org/project/pytest-xdist/)
26
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
27
+ - Reruns from [pytest-rerunfailures](https://pypi.org/project/pytest-rerunfailures/) are reported as `retries`, `retryAttempts` and `flaky`
28
+ - Each test gets a `suite` hierarchy: file, then classes
29
+ - Parametrized tests are supported: each case is reported as a separate test named by its full pytest node id (e.g. `test_file.py::test_param[1]`), with its values in `parameters`
28
30
 
29
31
  ## Installation
30
32
 
@@ -41,13 +43,20 @@ pytest --ctrf report.json
41
43
  ```
42
44
  Environment Variables may be used to specify the required Environment Object
43
45
  fields when using the CTRF [slack-test-reporter][ctrf-slack-test-reporter-url].
46
+ All of them are optional: a variable that is not set is left out of the report, see [.env.example](./.env.example).
44
47
 
45
48
  ```bash
46
49
  CTRF_BUILD_NAME="Pytest JSON CTRF Report"
47
- CTRF_BUILD_NUMBER="000"
48
- CTRF_BUILD_URL="https://ctrf.io]"
50
+ CTRF_BUILD_NUMBER=123
51
+ CTRF_BUILD_URL="https://ctrf.io"
52
+ CTRF_TEST_ENVIRONMENT="staging"
53
+ CTRF_TEST_TYPE="e2e"
49
54
  ```
50
55
 
56
+ `CTRF_BUILD_NUMBER` must be an integer. Otherwise a warning is issued and `buildNumber` is omitted from the report.
57
+ `osPlatform`, `osRelease` and `osVersion` are detected automatically.
58
+ `CTRF_TEST_TYPE` is written to every test's `type` field.
59
+
51
60
  ## JSON example
52
61
 
53
62
  More info here: https://ctrf.io/docs/schema/examples
@@ -55,11 +64,12 @@ More info here: https://ctrf.io/docs/schema/examples
55
64
  ```json
56
65
  {
57
66
  "reportFormat": "CTRF",
58
- "specVersion": "0.0.0",
59
- "generatedBy": "pytest",
67
+ "specVersion": "1.0.0",
68
+ "generatedBy": "pytest",
60
69
  "results": {
61
70
  "tool": {
62
- "name": "jest"
71
+ "name": "pytest",
72
+ "version": "9.0.3"
63
73
  },
64
74
  "summary": {
65
75
  "tests": 3,
@@ -73,8 +83,12 @@ More info here: https://ctrf.io/docs/schema/examples
73
83
  },
74
84
  "environment": {
75
85
  "buildName": "Pytest JSON CTRF Report",
76
- "buildNumber": "000",
77
- "buildUrl": "https://ctrf.io"
86
+ "buildUrl": "https://ctrf.io",
87
+ "osPlatform": "linux",
88
+ "osRelease": "6.8.0-45-generic",
89
+ "osVersion": "#45-Ubuntu SMP PREEMPT_DYNAMIC",
90
+ "testEnvironment": "staging",
91
+ "buildNumber": 123
78
92
  },
79
93
  "tests": [
80
94
  {
@@ -114,6 +128,13 @@ The `pytest_runtest_logreport` hook in the controller node is used to collect th
114
128
  - https://github.com/infopulse/Playwright-course-python -> The report will be used in the demo project as +1 report option
115
129
 
116
130
  ## Changelog
131
+ - 0.6.0 - fixed issue 12 - report now validates against the CTRF schema. Thanks to [@jamesarosen](https://github.com/jamesarosen) for the detailed report!
132
+ - `filePath`, `rawStatus` and integer `buildNumber`; environment fields are added only when their variables are set (no defaults), plus `osPlatform`, `osRelease`, `osVersion` and `type` (`CTRF_TEST_TYPE`)
133
+ - parametrized tests are named by the full node id and get `parameters`
134
+ - `message` is the real failure message; skip and xfail reasons are reported in `message`
135
+ - reruns (pytest-rerunfailures) are reported as `retries`, `retryAttempts` and `flaky`
136
+ - new `suite` field and summary `other`, `flaky`, `suites`, `duration`
137
+ - CI validates reports against the CTRF schema
117
138
  - 0.5.3 - fixed issue 6 - @pytest.mark.parametrize will no longer be added to tags to prevent exhausting memory
118
139
  - 0.5.1 - added mandatory root object fields: reportFormat, specVersion, generatedBy
119
140
  - 0.5.0 - Changed logic of handling parametrized tests. Previously they were marked as retries, now they are reported as separate tests
@@ -1,3 +1,4 @@
1
+ MANIFEST.in
1
2
  README.md
2
3
  pyproject.toml
3
4
  ctrf/BaseMetadataReport.py
@@ -11,6 +12,4 @@ pytest_json_ctrf.egg-info/SOURCES.txt
11
12
  pytest_json_ctrf.egg-info/dependency_links.txt
12
13
  pytest_json_ctrf.egg-info/entry_points.txt
13
14
  pytest_json_ctrf.egg-info/requires.txt
14
- pytest_json_ctrf.egg-info/top_level.txt
15
- tests/test_example.py
16
- tests/test_plugin.py
15
+ pytest_json_ctrf.egg-info/top_level.txt
@@ -1,107 +0,0 @@
1
- from enum import Enum
2
- from typing import Any, Optional, List, Dict
3
-
4
- from pytest import TestReport
5
-
6
-
7
- class TestStatus(Enum):
8
- PASSED = "passed"
9
- FAILED = "failed"
10
- SKIPPED = "skipped"
11
- PENDING = "pending"
12
- OTHER = "other"
13
-
14
-
15
- class TestObject:
16
- name: str
17
- _status: TestStatus
18
- raw_status: Optional[str]
19
- start: int
20
- stop: int
21
- retries: int
22
- message: Optional[str]
23
-
24
- worker_id: Optional[str]
25
- file_path: str
26
- tags: List[str]
27
- browser: Optional[str]
28
- trace: Optional[str]
29
-
30
- def __init__(self, report: TestReport, worker_id: Optional[str] = None):
31
- self.name = report.nodeid.split('[')[0]
32
- self._status = TestStatus.PENDING
33
- self._status = self.set_status(report)
34
- self.raw_status = None
35
- self.start = 0
36
- self.stop = 0
37
- self.retries = 0
38
- self.message = None
39
-
40
- self.worker_id = worker_id
41
- self.file_path = report.location[0]
42
- self.tags: List[str] = []
43
- self.browser = None
44
- self.trace = None
45
-
46
- @property
47
- def status(self) -> TestStatus:
48
- return self._status
49
-
50
- @status.setter
51
- def status(self, value: TestStatus) -> None:
52
- if self._status in (TestStatus.PENDING, TestStatus.PASSED):
53
- self._status = value
54
- return
55
- elif self._status in (TestStatus.SKIPPED, TestStatus.FAILED):
56
- return
57
-
58
- def set_status(self, report: TestReport) -> TestStatus:
59
- if self._status in (TestStatus.SKIPPED, TestStatus.FAILED):
60
- return self._status
61
- elif report.skipped:
62
- self._status = TestStatus.SKIPPED
63
- elif report.failed:
64
- self._status = TestStatus.FAILED
65
- self.raw_status = f"{report.when}_{report.outcome}"
66
- self.message = f"The test failed in the {report.when} phase"
67
- if hasattr(report, 'longreprtext'):
68
- if "AssertionError" in report.longreprtext:
69
- self.message += " due to an assertion error"
70
- elif "Exception" in report.longreprtext:
71
- self.message += " due to an exception"
72
- elif report.passed:
73
- self._status = TestStatus.PASSED
74
- else:
75
- self._status = TestStatus.OTHER
76
- return self._status
77
-
78
- def update(self, report: TestReport) -> None:
79
- self.set_status(report)
80
- if report.when == "setup" and self.start == 0:
81
- self.start = int(report.start * 1000)
82
- if report.when == "teardown" and self.stop == 0:
83
- self.stop = int(report.stop * 1000)
84
- if report.longrepr and len(report.longreprtext) > 0:
85
- self.trace = report.longreprtext
86
- if hasattr(report, '_ctrf_metadata'):
87
- self.tags = report._ctrf_metadata.get('tags')
88
- self.browser = report._ctrf_metadata.get('browser')
89
-
90
- def serialize(self) -> dict:
91
- result: Dict[str, Any] = {
92
- 'name': self.name,
93
- 'status': self._status.value,
94
- 'raw_status': self.raw_status,
95
- 'duration': self.stop - self.start,
96
- 'start': self.start,
97
- 'stop': self.stop,
98
- 'retries': None if self.retries == 1 else self.retries,
99
- 'file_path': self.file_path,
100
- 'tags': self.tags,
101
- 'browser': self.browser,
102
- 'trace': self.trace,
103
- 'message': self.message
104
- }
105
- if self.worker_id:
106
- result['extra'] = {'worker': self.worker_id}
107
- return {key: value for key, value in result.items() if value not in [None, '', []]}
@@ -1,2 +0,0 @@
1
- build
2
- ctrf
@@ -1,65 +0,0 @@
1
- from pytest import mark, fixture
2
-
3
-
4
- @fixture
5
- def pre_fail():
6
- raise Exception("This is a test exception")
7
-
8
-
9
- @fixture
10
- def post_fail():
11
- yield
12
- raise Exception("This is a test exception")
13
-
14
-
15
- def test_do_calculations_1_pass():
16
- assert 2 + 2 == 4
17
-
18
-
19
- @mark.regression
20
- def test_do_calculations_2_pass():
21
- assert 3 + 3 == 6
22
-
23
-
24
- @mark.skip
25
- def test_do_calculations_3_skip():
26
- assert 4 + 4 == 8
27
-
28
-
29
- @mark.smoke
30
- def test_do_calculations_4_smoke():
31
- assert 5 + 5 == 10
32
-
33
-
34
- def test_do_calculations_5_fail():
35
- assert 6 + 6 == 13
36
-
37
-
38
- def test_do_calculations_6_error():
39
- raise Exception("This is a test exception")
40
-
41
-
42
- def test_do_calculations_7_pre_error(pre_fail):
43
- assert 7 + 7 == 14
44
-
45
-
46
- def test_do_calculations_8_post_error(post_fail):
47
- assert 8 + 8 == 16
48
-
49
-
50
- @mark.parametrize('param', [1, 2, 3, 4, 5])
51
- def test_parametrized(param):
52
- assert param < 10
53
-
54
-
55
- @mark.parametrize('param', [6, 7, 8, 9, 10])
56
- def test_parametrized_fail(param):
57
- assert param != 8
58
-
59
- @mark.test_id(1234, hello='world')
60
- def test_with_test_id():
61
- assert 1 == 1
62
-
63
- @mark.test_id(1235)
64
- def test_with_test_id_2():
65
- assert 1 == 1
@@ -1,108 +0,0 @@
1
- import json
2
- from pytest import Pytester, fixture
3
-
4
- pytest_plugins = ["pytester", "xdist", "ctrf"]
5
- test_file = "tests/test_example.py"
6
- basic_test_args = ["-k", "test_example"]
7
-
8
-
9
- @fixture
10
- def ctrf_report_sync(pytester: Pytester):
11
- pytester.copy_example(test_file)
12
- pytester.runpytest(*[*basic_test_args, "--ctrf", "report.json"])
13
- with open(pytester.path / "report.json") as file:
14
- yield json.load(file)
15
-
16
-
17
- @fixture
18
- def ctrf_report_xdist(pytester: Pytester):
19
- pytester.copy_example(test_file)
20
- pytester.runpytest(*[*basic_test_args, "--ctrf", "report.json", "-n", "3"])
21
- with open(pytester.path / "report.json") as file:
22
- yield json.load(file)
23
-
24
-
25
- def test_without_plugin_no_xdist(pytester: Pytester):
26
- pytester.copy_example(test_file)
27
- result = pytester.runpytest(*basic_test_args)
28
- result.assert_outcomes(passed=15, skipped=1, failed=3, errors=2)
29
-
30
-
31
- def test_without_plugin_with_xdist(pytester: Pytester):
32
- pytester.copy_example(test_file)
33
- result = pytester.runpytest(*[*basic_test_args, "-n", "3"])
34
- result.assert_outcomes(passed=15, skipped=1, failed=3, errors=2)
35
- assert "created: 3/3 workers" in result.stdout.str()
36
-
37
-
38
- def test_with_markers(pytester: Pytester):
39
- pytester.copy_example(test_file)
40
- args = [*basic_test_args, "--ctrf", "report.json", "-m smoke"]
41
- pytester.runpytest(*args)
42
- with open(pytester.path / "report.json") as file:
43
- report = json.load(file)
44
- assert report["results"]["summary"]["tests"] == 1
45
- assert report["results"]["summary"]["passed"] == 1
46
- assert report["results"]["summary"]["failed"] == 0
47
- assert report["results"]["summary"]["skipped"] == 0
48
-
49
-
50
- def test_with_plugin_no_xdist(ctrf_report_sync):
51
- assert ctrf_report_sync["results"]["summary"]["tests"] == 20
52
- assert ctrf_report_sync["results"]["summary"]["passed"] == 14
53
- assert ctrf_report_sync["results"]["summary"]["failed"] == 5
54
- assert ctrf_report_sync["results"]["summary"]["skipped"] == 1
55
-
56
-
57
- def test_with_plugin_failed_details(ctrf_report_sync):
58
- for test in ctrf_report_sync["results"]["tests"]:
59
- if test["status"] == "failed" and test.get("raw_status") == "call_failed":
60
- assert test.get('trace') is not None
61
- assert test.get("message") is not None
62
- assert test.get("raw_status") is not None
63
-
64
-
65
- def test_any_test_has_timestamps(ctrf_report_sync):
66
- for test in ctrf_report_sync["results"]["tests"]:
67
- assert test.get("start") is not None
68
- assert test.get("stop") is not None
69
- assert test.get("duration") is not None
70
-
71
-
72
- def test_with_plugin_with_xdist(ctrf_report_xdist):
73
- assert ctrf_report_xdist["results"]["summary"]["tests"] == 20
74
- assert ctrf_report_xdist["results"]["summary"]["passed"] == 14
75
- assert ctrf_report_xdist["results"]["summary"]["failed"] == 5
76
- assert ctrf_report_xdist["results"]["summary"]["skipped"] == 1
77
-
78
-
79
- def test_with_plugin_failed_details_xdist(ctrf_report_xdist):
80
- for test in ctrf_report_xdist["results"]["tests"]:
81
- if test["status"] == "failed" and test.get("raw_status") == "call_failed":
82
- assert test.get('trace') is not None
83
- assert test.get("message") is not None
84
- assert test.get("raw_status") is not None
85
-
86
-
87
- def test_any_test_has_timestamps_xdist(ctrf_report_xdist):
88
- for test in ctrf_report_xdist["results"]["tests"]:
89
- assert test.get("start") is not None
90
- assert test.get("stop") is not None
91
- assert test.get("duration") is not None
92
-
93
- def test_parametrize_marker_not_in_tags(pytester: Pytester):
94
- pytester.makepyfile("""
95
- import pytest
96
-
97
- @pytest.mark.parametrize('param', [{'key': 'value', 'data': 'x' * 1000}, {'key': 'other', 'data': 'y' * 1000}], ids=['case1', 'case2'])
98
- def test_parametrized(param):
99
- assert True
100
- """)
101
- pytester.runpytest("--ctrf", "report.json")
102
- with open(pytester.path / "report.json") as file:
103
- report = json.load(file)
104
- for test in report["results"]["tests"]:
105
- tags = test.get("tags", [])
106
- assert all("parametrize" not in tag for tag in tags), (
107
- f"parametrize marker should not appear in tags, but got: {tags}"
108
- )