Skip to content

Commit d1c62bc

Browse files
authored
v0.3.1 release (#88) (#89)
* Drop support for python 3.8 and update all library versions (#88) * 87 remove support for python 38 (#90) * Drop support for python 3.8 and update all library versions * Workflow for running on windows * update steps for unit test in windows machine * update all the emojis * Update assert message * Fix tests * Fix failing test in windows * fix windows error * Update unit-test.yml * Update test_plugin_logging.py * Update unit-test.yml * Add feature for generating combined xml (#92) * Updated the name of default json report (#93) * Fix for flaky badge showing for those tests that are failed after re-run (#94) * Fixed flaky flag to not show on failed tests when using xdist * Update README.md * Update README.md * Update README.md
1 parent 07fb17d commit d1c62bc

15 files changed

Lines changed: 389 additions & 85 deletions
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
name: Unit Tests
2+
3+
on:
4+
pull_request:
5+
branches: [ main ]
6+
7+
jobs:
8+
test:
9+
runs-on: windows-latest
10+
11+
strategy:
12+
matrix:
13+
python-version: [ "3.13" ]
14+
15+
steps:
16+
- name: 📥 Checkout code
17+
uses: actions/checkout@v3
18+
19+
- name: 🐍 Set up Python ${{ matrix.python-version }}
20+
uses: actions/setup-python@v4
21+
with:
22+
python-version: ${{ matrix.python-version }}
23+
24+
- name: Install Poetry
25+
run: |
26+
(Invoke-WebRequest -Uri https://install.python-poetry.org -UseBasicParsing).Content | python -
27+
28+
- name: Add Poetry to PATH
29+
run: |
30+
echo "$env:APPDATA\Python\Scripts" | Out-File -Append -Encoding ascii $env:GITHUB_PATH
31+
echo "Poetry PATH set to $env:APPDATA\Python\Scripts"
32+
33+
- name: Check Poetry Version
34+
run: poetry --version
35+
36+
- name: Configure Poetry
37+
run: poetry config virtualenvs.create false
38+
39+
- name: 📦 Install dependencies with Poetry
40+
run: |
41+
poetry lock
42+
poetry install --with dev
43+
44+
- name: 🧪 Run tests with coverage
45+
run: |
46+
poetry run pytest --cov=pytest_reporter_plus tests/ --cov-fail-under=81 --cov-report=term --reruns 1

.github/workflows/unit-test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ jobs:
1010

1111
strategy:
1212
matrix:
13-
python-version: [ "3.9", "3.10", "3.11" ]
13+
python-version: [ "3.9", "3.10", "3.11", "3.12", "3.13" ]
1414

1515
steps:
1616
- name: 📥 Checkout code

README.md

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
# 🧪 pytest-reporter-plus
1+
# 🧪 pytest-reporter-plus - A Single page, actionable, plug-and-play unified Test Reports:
22

3-
A powerful, plug-and-play Pytest plugin to generate **HTML + JSON reports**, detect **flaky tests**, and optionally *
4-
*send reports via email**. Works beautifully with or without `xdist`.
3+
## Get a single, easy-to-read HTML report summarizing all your test results — no hassle, just clarity. Detect **flaky tests**, and optionally send reports via email**. Works beautifully with or without `xdist`.
54

65
[![PyPI Downloads](https://static.pepy.tech/badge/pytest-reporter-plus)](https://pepy.tech/projects/pytest-reporter-plus) ![PyPI](https://img.shields.io/pypi/v/pytest-reporter-plus) ![Python Versions](https://img.shields.io/pypi/pyversions/pytest-reporter-plus) ![License](https://img.shields.io/pypi/l/pytest-reporter-plus) [![Unit Tests](https://github.com/reach2jeyan/pytest-report-plus/actions/workflows/unit-test.yml/badge.svg)](https://github.com/reach2jeyan/pytest-report-plus/actions/workflows/unit-test.yml)
76

@@ -36,22 +35,34 @@ report.html – a clean, styled HTML report
3635

3736
## Available Options
3837

39-
| Option | Description | Default | Choices |
40-
|-------------------------|-----------------------------------------------------|--------------------------|-----------------------------------|
41-
| `--json-report` | Path to save individual JSON test reports | `playwright_report.json` | *Any valid file path* |
42-
| `--capture-screenshots` | When to capture screenshots | `failed` | `failed`, `all`, `none` |
43-
| `--html-output` | Directory to output HTML reports | `report_output` | *Any valid directory* |
44-
| `--screenshots` | Directory where screenshots will be stored | `screenshots` | *Any valid directory* |
45-
| `--send-email` | Send HTML report via email after the test run | `False` | `True`, `False` |
46-
| `--should-open-report` | Open your HTML report automatically post completion | `failed` | `always`, `failed`, `never` |
38+
| Option | Description | Default | Choices |
39+
|-------------------------|-----------------------------------------------------|---------------------|-----------------------------------|
40+
| `--json-report` | Path to save individual JSON test reports | `final_report.json` | *Any valid file path* |
41+
| `--capture-screenshots` | When to capture screenshots | `failed` | `failed`, `all`, `none` |
42+
| `--html-output` | Directory to output HTML reports | `report_output` | *Any valid directory* |
43+
| `--screenshots` | Directory where screenshots will be stored | `screenshots` | *Any valid directory* |
44+
| `--send-email` | Send HTML report via email after the test run | `False` | `True`, `False` |
45+
| `--should-open-report` | Open your HTML report automatically post completion | `failed` | `always`, `failed`, `never` |
46+
| `--generate-xml` | Easily generate combined xml of all runs | `False` | `False`, `True` |
47+
| `--xml-report` | Path to output the XML report (used with --generate-xml) | `None` | *Any valid file path* |
48+
4749

4850
---
4951

5052
# Keep using your regular pytest commands — just plug this in to unlock the below powerful reporting features with zero extra effort.
5153

5254
## ✨ Features
5355

54-
#### 🧩 Unified Test Reports: Get a single, easy-to-read HTML report summarizing all your test results — no hassle, just clarity.
56+
#### 🧩 Seamless Combined XML Export to your favourite test management tools — No Plugins Needed
57+
Export a fully merged JUnit XML report effortlessly — no external tools or plugins required. (No More merge html additional plugins or steps in your YAML to feed xml reports)
58+
59+
✔ Links, logs, stdout/stderr, and even flaky history — all included
60+
✔ Works out-of-the-box with your test management tools (like TestRail, XRay, Zephyr)
61+
✔ Just one flag. No extra lines of code. Total traceability.
62+
63+
64+
![ScreenRecording2025-07-06at11 38 21PM-ezgif com-video-to-gif-converter](https://github.com/user-attachments/assets/02da5cc9-7ef5-4a3a-a475-88907964a9c6)
65+
5566

5667
#### Easily track Untracked test scenarios
5768

pyproject.toml

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "pytest-reporter-plus"
3-
version = "0.3.0"
3+
version = "0.3.1"
44
description = "Lightweight enhanced HTML reporter for Pytest"
55
readme = "README.md"
66
authors = ["Emjey", "Karan"]
@@ -20,19 +20,19 @@ urls.Source = "https://github.com/reach2jeyan/pytest-report-plus"
2020
urls.Tracker = "https://github.com/reach2jeyan/pytest-report-plus/issues"
2121

2222
[tool.poetry.dependencies]
23-
python = ">=3.8"
23+
python = ">=3.9,<4.0"
2424
yagmail = ">=0.15.293,<0.16.0"
25-
keyring = ">=24.3.1,<26.0.0"
25+
keyring = ">=25.6.0,<26.0.0"
2626

2727
[tool.poetry.dev-dependencies]
28-
pytest = "^8.0.0"
29-
pytest-cov = "^4.1.0"
30-
playwright = "^1.42.0"
31-
pytest-playwright = "^0.4.3"
32-
pytest-xdist = "^3.6.1"
28+
pytest = "^8.4.1"
29+
pytest-cov = "^6.2.1"
30+
playwright = "^1.53.0"
31+
pytest-playwright = "^0.7.0"
32+
pytest-xdist = "^3.8.0"
3333

3434
[tool.poetry.group.dev.dependencies]
35-
pytest-rerunfailures = "^14.0"
35+
pytest-rerunfailures = "^15.1"
3636

3737
[build-system]
3838
requires = ["poetry-core"]

pytest_reporter_plus/generate_html_report.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ def main():
2323

2424

2525
class JSONReporter:
26-
def __init__(self, report_path="playwright_report.json", screenshots_dir="screenshots", output_dir="report_output"):
26+
def __init__(self, report_path="final_report.json", screenshots_dir="screenshots", output_dir="report_output"):
2727
self.parsed_data = None
2828
self.report_path = report_path
2929
self.screenshots_dir = screenshots_dir
@@ -88,7 +88,7 @@ def write_report(self):
8888
with open(self.report_path, "w", encoding="utf-8") as f:
8989
json.dump(self.results, f, indent=2)
9090
except Exception as e:
91-
print(f"Failed to write JSON report ")
91+
print(f"Failed to write JSON report ")
9292

9393
def copy_all_screenshots(self):
9494
screenshots_output_dir = os.path.join(self.output_dir, "screenshots")
@@ -538,7 +538,8 @@ def generate_html_report(self):
538538
error_html = f"<pre>{self.format_error_with_diffs(error_text)}</pre>" if error_text else ""
539539
screenshot_path = self.find_screenshot_and_copy(test['test'])
540540
screenshot_html = f'<div class="details-screenshot"><img src="{screenshot_path}" alt="Screenshot" onclick="toggleFullscreen(this)"></div>' if screenshot_path else ""
541-
marker_str = ",".join(test.get("markers", []))
541+
markers = test.get("markers")
542+
marker_str = ",".join(markers) if isinstance(markers, list) else ""
542543
stdout_html = ""
543544
if test.get('stdout'):
544545
stdout_html = f"<div><strong>STDOUT:</strong><pre>{test['stdout']}</pre></div>"
@@ -578,7 +579,7 @@ def generate_html_report(self):
578579

579580
html += f'''
580581
581-
<div class="test test-card" data-name="{test['test']}" data-link="{','.join(test.get('links', []))}" data-markers="{marker_str}">
582+
<div class="test test-card" data-name="{test['test']}" data-link="{','.join(test.get('links') or [])}" data-markers="{marker_str}">
582583
<div class="header {status_class}" onclick="toggleDetails(this)">
583584
<div class="header-section test-info">
584585
<span class="toggle"></span>

pytest_reporter_plus/json_merge.py

Lines changed: 36 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -3,38 +3,42 @@
33
from collections import defaultdict
44

55

6-
def merge_json_reports(directory=".pytest_worker_jsons", output_path="playwright_report.json"):
7-
all_tests = []
8-
for filename in sorted(os.listdir(directory)):
9-
if filename.endswith(".json"):
10-
filepath = os.path.join(directory, filename)
11-
with open(filepath) as f:
12-
try:
13-
data = json.load(f)
14-
if isinstance(data, list):
15-
all_tests.extend(data)
16-
elif isinstance(data, dict) and "results" in data:
17-
all_tests.extend(data["results"])
18-
except Exception as e:
19-
print(f"⚠️ Could not parse {filename}: {e}")
6+
def merge_json_reports(directory=".pytest_worker_jsons", output_path="final_report.json"):
7+
all_tests = []
8+
for filename in sorted(os.listdir(directory)):
9+
if filename.endswith(".json"):
10+
filepath = os.path.join(directory, filename)
11+
with open(filepath) as f:
12+
try:
13+
data = json.load(f)
14+
if isinstance(data, list):
15+
all_tests.extend(data)
16+
elif isinstance(data, dict) and "results" in data:
17+
all_tests.extend(data["results"])
18+
except Exception as e:
19+
print(f"Could not parse {filename}: {e}")
2020

21-
# Group tests by nodeid
22-
tests_by_nodeid = defaultdict(list)
23-
for test in all_tests:
24-
nodeid = test.get("nodeid") or test.get("test") # fallback if needed
25-
tests_by_nodeid[nodeid].append(test)
21+
# Group tests by nodeid
22+
tests_by_nodeid = defaultdict(list)
23+
for test in all_tests:
24+
nodeid = test.get("nodeid") or test.get("test") # fallback if needed
25+
tests_by_nodeid[nodeid].append(test)
2626

27-
# Create merged results with flaky info
28-
merged_results = []
29-
for nodeid, attempts in tests_by_nodeid.items():
30-
# Take last attempt as final
31-
final_test = attempts[-1].copy()
32-
final_test["flaky"] = len(attempts) > 1
33-
final_test["flaky_attempts"] = [t.get("status") for t in attempts]
34-
merged_results.append(final_test)
27+
# Create merged results with flaky info
28+
merged_results = []
29+
for nodeid, attempts in tests_by_nodeid.items():
30+
final_test = attempts[-1].copy()
31+
statuses = [t.get("status") for t in attempts]
32+
unique_statuses = set(statuses)
3533

36-
try:
37-
with open(output_path, "w") as f:
38-
json.dump(merged_results, f, indent=2)
39-
except Exception as e:
40-
print(f"❌ Failed to write merged report to {output_path}: {e}")
34+
# Mark flaky only if test status changed across runs
35+
final_test["flaky"] = len(unique_statuses) > 1
36+
final_test["flaky_attempts"] = statuses
37+
38+
merged_results.append(final_test)
39+
40+
try:
41+
with open(output_path, "w") as f:
42+
json.dump(merged_results, f, indent=2)
43+
except Exception as e:
44+
print(f" Failed to write merged report to {output_path}: {e}")
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import json
2+
import xml.etree.ElementTree as ET
3+
4+
def sanitize_classname(filepath):
5+
if not filepath:
6+
return "default"
7+
return filepath.replace("\\", ".").replace("/", ".").removesuffix(".py")
8+
9+
def sanitize_test_name(test):
10+
return test.get("test") or test.get("nodeid", "unknown").split("::")[-1]
11+
12+
def convert_json_to_junit_xml(json_path, xml_path):
13+
with open(json_path, "r", encoding="utf-8") as f:
14+
test_results = json.load(f)
15+
16+
testsuite = ET.Element("testsuite", {
17+
"name": "Test Suite",
18+
"tests": str(len(test_results)),
19+
})
20+
21+
for test in test_results:
22+
testcase = ET.SubElement(testsuite, "testcase", {
23+
"classname": sanitize_classname(test.get("file")),
24+
"name": sanitize_test_name(test),
25+
"time": str(test.get("duration", 0)),
26+
})
27+
28+
if test.get("timestamp"):
29+
testcase.set("timestamp", test["timestamp"])
30+
if test.get("line") is not None:
31+
testcase.set("line", str(test["line"]))
32+
if test.get("worker"):
33+
testcase.set("worker", test["worker"])
34+
if test.get("flaky") is not None:
35+
testcase.set("flaky", str(test["flaky"]).lower())
36+
37+
# Add status tag
38+
status = test.get("status", "").lower()
39+
if status == "failed":
40+
failure = ET.SubElement(testcase, "failure", {
41+
"message": test.get("error") or "Test failed",
42+
"type": "AssertionError"
43+
})
44+
failure.text = test.get("stderr", "")
45+
elif status == "skipped":
46+
ET.SubElement(testcase, "skipped")
47+
48+
# Add stdout/stderr
49+
if test.get("stdout"):
50+
system_out = ET.SubElement(testcase, "system-out")
51+
system_out.text = test["stdout"]
52+
if test.get("stderr"):
53+
system_err = ET.SubElement(testcase, "system-err")
54+
system_err.text = test["stderr"]
55+
56+
# Add <properties>
57+
properties = ET.SubElement(testcase, "properties")
58+
59+
# Markers
60+
for marker in test.get("markers", []):
61+
ET.SubElement(properties, "property", {
62+
"name": "marker",
63+
"value": marker
64+
})
65+
66+
# Links
67+
for link in test.get("links", []):
68+
ET.SubElement(properties, "property", {
69+
"name": "link",
70+
"value": link
71+
})
72+
73+
# Screenshot
74+
if test.get("screenshot"):
75+
ET.SubElement(properties, "property", {
76+
"name": "screenshot",
77+
"value": test["screenshot"]
78+
})
79+
80+
# Logs
81+
for log in test.get("logs", []):
82+
ET.SubElement(properties, "property", {
83+
"name": "log",
84+
"value": str(log)
85+
})
86+
87+
# Write XML to file
88+
tree = ET.ElementTree(testsuite)
89+
tree.write(xml_path, encoding="utf-8", xml_declaration=True)
90+
print(f"XML report generated at: {xml_path}")

0 commit comments

Comments
 (0)