junit-time-diff 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.
- junit_time_diff-0.1.0.dist-info/METADATA +154 -0
- junit_time_diff-0.1.0.dist-info/RECORD +7 -0
- junit_time_diff-0.1.0.dist-info/WHEEL +5 -0
- junit_time_diff-0.1.0.dist-info/entry_points.txt +2 -0
- junit_time_diff-0.1.0.dist-info/licenses/LICENSE +21 -0
- junit_time_diff-0.1.0.dist-info/top_level.txt +1 -0
- junit_time_diff.py +412 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: junit-time-diff
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Compare test execution times from JUnit XML reports.
|
|
5
|
+
Author-email: Mauricio Villegas <mauricio@omnius.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Classifier: Development Status :: 4 - Beta
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: Programming Language :: Python
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Provides-Extra: test
|
|
16
|
+
Requires-Dist: pytest>=8.3.5; extra == "test"
|
|
17
|
+
Requires-Dist: pytest-cov>=6.1.1; extra == "test"
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# junit-time-diff
|
|
21
|
+
|
|
22
|
+
`junit-time-diff` compares test execution times from JUnit XML reports and highlights meaningful slowdowns, speedups, new tests, and removed tests.
|
|
23
|
+
|
|
24
|
+
It works especially well with pytest's built-in `--junit-xml` output and can compare either single runs or averages computed from multiple XML files.
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install junit-time-diff
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
For local development from this repository:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install -e .
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
This installs the `junit-time-diff` command.
|
|
39
|
+
|
|
40
|
+
## Quick Start
|
|
41
|
+
|
|
42
|
+
### Single Run Comparison
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pytest --junit-xml=baseline.xml
|
|
46
|
+
|
|
47
|
+
# make changes
|
|
48
|
+
|
|
49
|
+
pytest --junit-xml=current.xml
|
|
50
|
+
|
|
51
|
+
junit-time-diff baseline.xml current.xml
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Averaged Comparison Across Multiple Runs
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
for i in {1..5}; do pytest --junit-xml=baseline$i.xml; done
|
|
58
|
+
|
|
59
|
+
# make changes
|
|
60
|
+
|
|
61
|
+
for i in {1..5}; do pytest --junit-xml=current$i.xml; done
|
|
62
|
+
|
|
63
|
+
junit-time-diff "baseline*.xml" "current*.xml"
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Quoting the glob pattern is recommended so the tool receives the pattern and expands it consistently.
|
|
67
|
+
|
|
68
|
+
## CLI Usage
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
junit-time-diff --help
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
```text
|
|
75
|
+
usage: junit-time-diff [-h] [--threshold THRESHOLD] [--min-diff MIN_DIFF]
|
|
76
|
+
baseline current
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Arguments:
|
|
80
|
+
|
|
81
|
+
- `baseline`: Baseline JUnit XML file or glob pattern such as `baseline.xml` or `baseline*.xml`
|
|
82
|
+
- `current`: Current JUnit XML file or glob pattern such as `current.xml` or `current*.xml`
|
|
83
|
+
|
|
84
|
+
Options:
|
|
85
|
+
|
|
86
|
+
- `--threshold`: Ratio threshold for reporting changes, default `1.10`
|
|
87
|
+
- `--min-diff`: Minimum absolute duration change in seconds, default `0.01`
|
|
88
|
+
|
|
89
|
+
## Example Output
|
|
90
|
+
|
|
91
|
+
```text
|
|
92
|
+
================================================================================
|
|
93
|
+
TEST TIMING COMPARISON REPORT
|
|
94
|
+
================================================================================
|
|
95
|
+
|
|
96
|
+
SUMMARY
|
|
97
|
+
--------------------------------------------------------------------------------
|
|
98
|
+
Baseline: 9 tests, 3.64s total
|
|
99
|
+
Current: 9 tests, 3.69s total
|
|
100
|
+
Difference: +0.05s (+1.3%)
|
|
101
|
+
New tests: 1, Removed tests: 1
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
The report then includes any significant slower tests, faster tests, new tests, removed tests, and a final verdict.
|
|
105
|
+
|
|
106
|
+
## Typical Workflows
|
|
107
|
+
|
|
108
|
+
### Compare Python Versions
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
python3.11 -m pytest --junit-xml=py311.xml
|
|
112
|
+
python3.12 -m pytest --junit-xml=py312.xml
|
|
113
|
+
|
|
114
|
+
junit-time-diff py311.xml py312.xml
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Compare a Specific Test Subset
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
pytest tests/test_api.py --junit-xml=baseline_api.xml
|
|
121
|
+
|
|
122
|
+
# make changes
|
|
123
|
+
|
|
124
|
+
pytest tests/test_api.py --junit-xml=current_api.xml
|
|
125
|
+
|
|
126
|
+
junit-time-diff baseline_api.xml current_api.xml
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### Use a Stricter Threshold
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
junit-time-diff baseline.xml current.xml --threshold 1.05
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Tips
|
|
136
|
+
|
|
137
|
+
- Run several repetitions and compare averages to reduce noise.
|
|
138
|
+
- Compare results on the same machine when possible.
|
|
139
|
+
- Keep the selected test set consistent between baseline and current runs.
|
|
140
|
+
- Ignore tiny changes unless they are part of a repeated pattern.
|
|
141
|
+
|
|
142
|
+
## Development
|
|
143
|
+
|
|
144
|
+
Build the package:
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
python3 -m build
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Run tests:
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
pytest
|
|
154
|
+
```
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
junit_time_diff.py,sha256=yaKoU0Kz-t0KlMBNNql7f7ykestJFfP435tZhFP0nn4,13207
|
|
2
|
+
junit_time_diff-0.1.0.dist-info/licenses/LICENSE,sha256=dz9IJLqyJ8GH5ihpx6XMeXLMvkW7wuryN1S6MqVB_KQ,1115
|
|
3
|
+
junit_time_diff-0.1.0.dist-info/METADATA,sha256=elZmLbxmxcseF74uHRRHAiXg-oxD8_0ACXZiahk-61k,3644
|
|
4
|
+
junit_time_diff-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
5
|
+
junit_time_diff-0.1.0.dist-info/entry_points.txt,sha256=5d1hgSazKUjZlx5D3S3lDtrxLx-xRQi15ksH4megL4M,57
|
|
6
|
+
junit_time_diff-0.1.0.dist-info/top_level.txt,sha256=2CWY8czXC_uo09EFM2kdHzaP_U1teWQeng_Klh2hhkM,16
|
|
7
|
+
junit_time_diff-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026-present, Mauricio Villegas <mauricio@omnius.com>
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
junit_time_diff
|
junit_time_diff.py
ADDED
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Compare test execution times from JUnit XML reports."""
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import glob
|
|
6
|
+
import statistics
|
|
7
|
+
import sys
|
|
8
|
+
import xml.etree.ElementTree as ET
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
__version__ = "0.1.0"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def load_junit_timings(filepath: Path) -> dict[str, Any]:
|
|
16
|
+
"""Load timing data from a JUnit XML file.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
filepath: Path to the JUnit XML file.
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
Dictionary with 'total_duration', 'test_count', and 'tests' keys.
|
|
23
|
+
"""
|
|
24
|
+
tree = ET.parse(filepath)
|
|
25
|
+
root = tree.getroot()
|
|
26
|
+
|
|
27
|
+
tests: dict[str, dict[str, Any]] = {}
|
|
28
|
+
total_duration = 0.0
|
|
29
|
+
|
|
30
|
+
# Handle both <testsuites> wrapper and direct <testsuite>
|
|
31
|
+
testsuites = root.findall(".//testsuite")
|
|
32
|
+
if not testsuites:
|
|
33
|
+
testsuites = [root] if root.tag == "testsuite" else []
|
|
34
|
+
|
|
35
|
+
for testsuite in testsuites:
|
|
36
|
+
for testcase in testsuite.findall("testcase"):
|
|
37
|
+
classname = testcase.get("classname", "")
|
|
38
|
+
name = testcase.get("name", "")
|
|
39
|
+
time_str = testcase.get("time", "0")
|
|
40
|
+
|
|
41
|
+
# Create unique test ID
|
|
42
|
+
test_id = f"{classname}::{name}"
|
|
43
|
+
|
|
44
|
+
# Parse time
|
|
45
|
+
try:
|
|
46
|
+
duration = float(time_str)
|
|
47
|
+
except (TypeError, ValueError):
|
|
48
|
+
duration = 0.0
|
|
49
|
+
|
|
50
|
+
# Determine outcome
|
|
51
|
+
if testcase.find("failure") is not None:
|
|
52
|
+
outcome = "failed"
|
|
53
|
+
elif testcase.find("error") is not None:
|
|
54
|
+
outcome = "error"
|
|
55
|
+
elif testcase.find("skipped") is not None:
|
|
56
|
+
outcome = "skipped"
|
|
57
|
+
else:
|
|
58
|
+
outcome = "passed"
|
|
59
|
+
|
|
60
|
+
tests[test_id] = {
|
|
61
|
+
"duration": duration,
|
|
62
|
+
"outcome": outcome,
|
|
63
|
+
}
|
|
64
|
+
total_duration += duration
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
"total_duration": total_duration,
|
|
68
|
+
"test_count": len(tests),
|
|
69
|
+
"tests": tests,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def load_and_average_timings(pattern: str) -> dict[str, Any]:
|
|
74
|
+
"""Load timing data from one or more JUnit XML files and average durations.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
pattern: File pattern to match (e.g., "baseline*.xml" or "baseline.xml")
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
Dictionary with averaged timing data.
|
|
81
|
+
"""
|
|
82
|
+
# Find all matching files
|
|
83
|
+
files = sorted(glob.glob(pattern))
|
|
84
|
+
|
|
85
|
+
if not files:
|
|
86
|
+
raise FileNotFoundError(f"No files match pattern: {pattern}")
|
|
87
|
+
|
|
88
|
+
print(f"Loading {len(files)} file(s) matching '{pattern}':")
|
|
89
|
+
for file in files:
|
|
90
|
+
print(f" - {file}")
|
|
91
|
+
|
|
92
|
+
# Load all timing data
|
|
93
|
+
all_timings = [load_junit_timings(Path(file)) for file in files]
|
|
94
|
+
|
|
95
|
+
if len(all_timings) == 1:
|
|
96
|
+
# Single file, return as-is
|
|
97
|
+
return all_timings[0]
|
|
98
|
+
|
|
99
|
+
# Collect all test IDs across all runs
|
|
100
|
+
all_test_ids = set()
|
|
101
|
+
for timing in all_timings:
|
|
102
|
+
all_test_ids.update(timing["tests"].keys())
|
|
103
|
+
|
|
104
|
+
# Calculate average duration for each test
|
|
105
|
+
averaged_tests: dict[str, dict[str, Any]] = {}
|
|
106
|
+
for test_id in all_test_ids:
|
|
107
|
+
durations = []
|
|
108
|
+
outcomes = []
|
|
109
|
+
|
|
110
|
+
for timing in all_timings:
|
|
111
|
+
if test_id in timing["tests"]:
|
|
112
|
+
durations.append(timing["tests"][test_id]["duration"])
|
|
113
|
+
outcomes.append(timing["tests"][test_id]["outcome"])
|
|
114
|
+
|
|
115
|
+
# Average duration
|
|
116
|
+
avg_duration = statistics.mean(durations) if durations else 0.0
|
|
117
|
+
|
|
118
|
+
# Most common outcome (or first if tied)
|
|
119
|
+
outcome = max(set(outcomes), key=outcomes.count) if outcomes else "unknown"
|
|
120
|
+
|
|
121
|
+
averaged_tests[test_id] = {
|
|
122
|
+
"duration": avg_duration,
|
|
123
|
+
"outcome": outcome,
|
|
124
|
+
"runs": len(durations), # Track how many runs included this test
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
# Calculate total duration
|
|
128
|
+
total_duration = sum(test["duration"] for test in averaged_tests.values())
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
"total_duration": total_duration,
|
|
132
|
+
"test_count": len(averaged_tests),
|
|
133
|
+
"tests": averaged_tests,
|
|
134
|
+
"num_runs": len(all_timings),
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def compare_timings(
|
|
139
|
+
baseline: dict[str, Any],
|
|
140
|
+
current: dict[str, Any],
|
|
141
|
+
threshold: float = 1.10,
|
|
142
|
+
min_threshold_seconds: float = 0.01,
|
|
143
|
+
) -> int:
|
|
144
|
+
"""Compare timing data and report significant differences.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
baseline: Baseline timing data (before changes)
|
|
148
|
+
current: Current timing data (after changes)
|
|
149
|
+
threshold: Percentage threshold for reporting (e.g., 1.10 = 10% slower)
|
|
150
|
+
min_threshold_seconds: Minimum absolute time difference to report (in seconds)
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
Exit code (0 = no issues, 1 = significant slowdown detected)
|
|
154
|
+
"""
|
|
155
|
+
baseline_tests = baseline["tests"]
|
|
156
|
+
current_tests = current["tests"]
|
|
157
|
+
|
|
158
|
+
# Find common tests
|
|
159
|
+
common_tests = set(baseline_tests.keys()) & set(current_tests.keys())
|
|
160
|
+
new_tests = set(current_tests.keys()) - set(baseline_tests.keys())
|
|
161
|
+
removed_tests = set(baseline_tests.keys()) - set(current_tests.keys())
|
|
162
|
+
|
|
163
|
+
# Calculate total time changes
|
|
164
|
+
baseline_total = baseline["total_duration"]
|
|
165
|
+
current_total = current["total_duration"]
|
|
166
|
+
total_diff = current_total - baseline_total
|
|
167
|
+
total_percent = (current_total / baseline_total - 1) * 100 if baseline_total > 0 else 0.0
|
|
168
|
+
|
|
169
|
+
print("=" * 80)
|
|
170
|
+
print("TEST TIMING COMPARISON REPORT")
|
|
171
|
+
print("=" * 80)
|
|
172
|
+
print()
|
|
173
|
+
|
|
174
|
+
# Summary
|
|
175
|
+
print("SUMMARY")
|
|
176
|
+
print("-" * 80)
|
|
177
|
+
baseline_runs = baseline.get("num_runs", 1)
|
|
178
|
+
current_runs = current.get("num_runs", 1)
|
|
179
|
+
if baseline_runs > 1 or current_runs > 1:
|
|
180
|
+
print(
|
|
181
|
+
f"Baseline: {baseline['test_count']} tests, {baseline_total:.2f}s total "
|
|
182
|
+
f"(averaged over {baseline_runs} run(s))"
|
|
183
|
+
)
|
|
184
|
+
print(
|
|
185
|
+
f"Current: {current['test_count']} tests, {current_total:.2f}s total "
|
|
186
|
+
f"(averaged over {current_runs} run(s))"
|
|
187
|
+
)
|
|
188
|
+
else:
|
|
189
|
+
print(f"Baseline: {baseline['test_count']} tests, {baseline_total:.2f}s total")
|
|
190
|
+
print(f"Current: {current['test_count']} tests, {current_total:.2f}s total")
|
|
191
|
+
print(f"Difference: {total_diff:+.2f}s ({total_percent:+.1f}%)")
|
|
192
|
+
print(f"New tests: {len(new_tests)}, Removed tests: {len(removed_tests)}")
|
|
193
|
+
print()
|
|
194
|
+
|
|
195
|
+
# Analyze common tests
|
|
196
|
+
slower = []
|
|
197
|
+
faster = []
|
|
198
|
+
|
|
199
|
+
for test_id in common_tests:
|
|
200
|
+
baseline_duration = baseline_tests[test_id]["duration"]
|
|
201
|
+
current_duration = current_tests[test_id]["duration"]
|
|
202
|
+
diff = current_duration - baseline_duration
|
|
203
|
+
|
|
204
|
+
if baseline_duration > 0:
|
|
205
|
+
ratio = current_duration / baseline_duration
|
|
206
|
+
percent_change = (ratio - 1) * 100
|
|
207
|
+
else:
|
|
208
|
+
ratio = float("inf") if current_duration > 0 else 1.0
|
|
209
|
+
percent_change = 0.0
|
|
210
|
+
|
|
211
|
+
# Check if difference is significant
|
|
212
|
+
is_significant = (ratio >= threshold and diff >= min_threshold_seconds) or (
|
|
213
|
+
ratio <= (2 - threshold) and abs(diff) >= min_threshold_seconds
|
|
214
|
+
)
|
|
215
|
+
if not is_significant:
|
|
216
|
+
continue
|
|
217
|
+
|
|
218
|
+
test_info = {
|
|
219
|
+
"id": test_id,
|
|
220
|
+
"baseline": baseline_duration,
|
|
221
|
+
"current": current_duration,
|
|
222
|
+
"diff": diff,
|
|
223
|
+
"percent": percent_change,
|
|
224
|
+
}
|
|
225
|
+
if diff > 0:
|
|
226
|
+
slower.append(test_info)
|
|
227
|
+
else:
|
|
228
|
+
faster.append(test_info)
|
|
229
|
+
|
|
230
|
+
# Report slower tests
|
|
231
|
+
if slower:
|
|
232
|
+
slower.sort(key=lambda item: abs(item["diff"]), reverse=True)
|
|
233
|
+
|
|
234
|
+
print(f"SLOWER TESTS ({len(slower)} tests)")
|
|
235
|
+
print("-" * 80)
|
|
236
|
+
print(f"{'Test':<60} {'Before':>10} {'After':>10} {'Diff':>10} {'Change':>8}")
|
|
237
|
+
print("-" * 80)
|
|
238
|
+
|
|
239
|
+
for test in slower[:20]: # Show top 20
|
|
240
|
+
test_name = test["id"]
|
|
241
|
+
if len(test_name) > 60:
|
|
242
|
+
test_name = "..." + test_name[-57:]
|
|
243
|
+
|
|
244
|
+
print(
|
|
245
|
+
f"{test_name:<60} "
|
|
246
|
+
f"{test['baseline']:>9.3f}s "
|
|
247
|
+
f"{test['current']:>9.3f}s "
|
|
248
|
+
f"{test['diff']:>+9.3f}s "
|
|
249
|
+
f"{test['percent']:>+7.1f}%"
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
if len(slower) > 20:
|
|
253
|
+
print(f"... and {len(slower) - 20} more slower tests")
|
|
254
|
+
print()
|
|
255
|
+
|
|
256
|
+
# Report faster tests
|
|
257
|
+
if faster:
|
|
258
|
+
faster.sort(key=lambda item: abs(item["diff"]), reverse=True)
|
|
259
|
+
|
|
260
|
+
print(f"FASTER TESTS ({len(faster)} tests)")
|
|
261
|
+
print("-" * 80)
|
|
262
|
+
print(f"{'Test':<60} {'Before':>10} {'After':>10} {'Diff':>10} {'Change':>8}")
|
|
263
|
+
print("-" * 80)
|
|
264
|
+
|
|
265
|
+
for test in faster[:20]: # Show top 20
|
|
266
|
+
test_name = test["id"]
|
|
267
|
+
if len(test_name) > 60:
|
|
268
|
+
test_name = "..." + test_name[-57:]
|
|
269
|
+
|
|
270
|
+
print(
|
|
271
|
+
f"{test_name:<60} "
|
|
272
|
+
f"{test['baseline']:>9.3f}s "
|
|
273
|
+
f"{test['current']:>9.3f}s "
|
|
274
|
+
f"{test['diff']:>+9.3f}s "
|
|
275
|
+
f"{test['percent']:>+7.1f}%"
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
if len(faster) > 20:
|
|
279
|
+
print(f"... and {len(faster) - 20} more faster tests")
|
|
280
|
+
print()
|
|
281
|
+
|
|
282
|
+
# Report new tests
|
|
283
|
+
if new_tests:
|
|
284
|
+
new_test_list = sorted(
|
|
285
|
+
((test_id, current_tests[test_id]["duration"]) for test_id in new_tests),
|
|
286
|
+
key=lambda item: item[1],
|
|
287
|
+
reverse=True,
|
|
288
|
+
)
|
|
289
|
+
new_tests_total = sum(duration for _, duration in new_test_list)
|
|
290
|
+
print(f"NEW TESTS ({len(new_tests)} tests, {new_tests_total:.2f}s total)")
|
|
291
|
+
print("-" * 80)
|
|
292
|
+
|
|
293
|
+
for test_id, duration in new_test_list[:10]: # Show top 10
|
|
294
|
+
test_name = test_id
|
|
295
|
+
if len(test_name) > 60:
|
|
296
|
+
test_name = "..." + test_name[-57:]
|
|
297
|
+
print(f"{test_name:<60} {duration:>9.3f}s")
|
|
298
|
+
|
|
299
|
+
if len(new_tests) > 10:
|
|
300
|
+
print(f"... and {len(new_tests) - 10} more new tests")
|
|
301
|
+
print()
|
|
302
|
+
|
|
303
|
+
# Report removed tests
|
|
304
|
+
if removed_tests:
|
|
305
|
+
print(f"REMOVED TESTS ({len(removed_tests)} tests)")
|
|
306
|
+
print("-" * 80)
|
|
307
|
+
for test_id in sorted(removed_tests)[:10]: # Show top 10
|
|
308
|
+
test_name = test_id
|
|
309
|
+
if len(test_name) > 70:
|
|
310
|
+
test_name = "..." + test_name[-67:]
|
|
311
|
+
print(f" {test_name}")
|
|
312
|
+
|
|
313
|
+
if len(removed_tests) > 10:
|
|
314
|
+
print(f"... and {len(removed_tests) - 10} more removed tests")
|
|
315
|
+
print()
|
|
316
|
+
|
|
317
|
+
# Final verdict
|
|
318
|
+
print("=" * 80)
|
|
319
|
+
print("VERDICT")
|
|
320
|
+
print("-" * 80)
|
|
321
|
+
|
|
322
|
+
if not slower:
|
|
323
|
+
print("✓ No significant performance regressions detected!")
|
|
324
|
+
return_code = 0
|
|
325
|
+
elif total_percent > 5:
|
|
326
|
+
print(f"⚠ WARNING: Overall test suite is {total_percent:.1f}% slower!")
|
|
327
|
+
print(f" {len(slower)} tests got slower (threshold: {(threshold - 1) * 100:.0f}%)")
|
|
328
|
+
return_code = 1
|
|
329
|
+
else:
|
|
330
|
+
print(f"ℹ INFO: Some tests got slower, but overall impact is {total_percent:+.1f}%")
|
|
331
|
+
print(f" {len(slower)} tests got slower (threshold: {(threshold - 1) * 100:.0f}%)")
|
|
332
|
+
return_code = 0
|
|
333
|
+
|
|
334
|
+
if faster:
|
|
335
|
+
print(f"✓ {len(faster)} tests got faster!")
|
|
336
|
+
|
|
337
|
+
print("=" * 80)
|
|
338
|
+
return return_code
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
342
|
+
"""Create the CLI argument parser."""
|
|
343
|
+
parser = argparse.ArgumentParser(
|
|
344
|
+
prog="junit-time-diff",
|
|
345
|
+
description="Compare test execution times from JUnit XML reports.",
|
|
346
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
347
|
+
epilog="""
|
|
348
|
+
Examples:
|
|
349
|
+
# Compare single baseline with current run
|
|
350
|
+
%(prog)s baseline.xml current.xml
|
|
351
|
+
|
|
352
|
+
# Compare averaged results from multiple runs
|
|
353
|
+
%(prog)s "baseline*.xml" "current*.xml"
|
|
354
|
+
|
|
355
|
+
# Use custom threshold (15%% instead of default 10%%)
|
|
356
|
+
%(prog)s baseline.xml current.xml --threshold 1.15
|
|
357
|
+
|
|
358
|
+
# Ignore differences less than 0.05 seconds
|
|
359
|
+
%(prog)s baseline.xml current.xml --min-diff 0.05
|
|
360
|
+
|
|
361
|
+
Generate JUnit XML with pytest:
|
|
362
|
+
# Single run
|
|
363
|
+
pytest --junit-xml=baseline.xml
|
|
364
|
+
|
|
365
|
+
# Multiple runs (reduces timing variance)
|
|
366
|
+
for i in {{1..5}}; do pytest --junit-xml=baseline$i.xml; done
|
|
367
|
+
for i in {{1..5}}; do pytest --junit-xml=current$i.xml; done
|
|
368
|
+
%(prog)s "baseline*.xml" "current*.xml"
|
|
369
|
+
""".strip(),
|
|
370
|
+
)
|
|
371
|
+
parser.add_argument(
|
|
372
|
+
"baseline",
|
|
373
|
+
help="Baseline JUnit XML file or pattern (for example 'baseline.xml' or 'baseline*.xml').",
|
|
374
|
+
)
|
|
375
|
+
parser.add_argument(
|
|
376
|
+
"current",
|
|
377
|
+
help="Current JUnit XML file or pattern (for example 'current.xml' or 'current*.xml').",
|
|
378
|
+
)
|
|
379
|
+
parser.add_argument(
|
|
380
|
+
"--threshold",
|
|
381
|
+
type=float,
|
|
382
|
+
default=1.10,
|
|
383
|
+
help="Ratio threshold for reporting significant changes (default: 1.10 = 10%% slower).",
|
|
384
|
+
)
|
|
385
|
+
parser.add_argument(
|
|
386
|
+
"--min-diff",
|
|
387
|
+
type=float,
|
|
388
|
+
default=0.01,
|
|
389
|
+
help="Minimum absolute time difference in seconds to report (default: 0.01).",
|
|
390
|
+
)
|
|
391
|
+
return parser
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def main(argv: list[str] | None = None) -> int:
|
|
395
|
+
"""Run the command-line interface."""
|
|
396
|
+
parser = build_parser()
|
|
397
|
+
args = parser.parse_args(argv)
|
|
398
|
+
|
|
399
|
+
try:
|
|
400
|
+
print()
|
|
401
|
+
baseline = load_and_average_timings(args.baseline)
|
|
402
|
+
print()
|
|
403
|
+
current = load_and_average_timings(args.current)
|
|
404
|
+
print()
|
|
405
|
+
return compare_timings(baseline, current, args.threshold, args.min_diff)
|
|
406
|
+
except Exception as ex:
|
|
407
|
+
print(f"Error: {ex}", file=sys.stderr)
|
|
408
|
+
return 1
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
if __name__ == "__main__":
|
|
412
|
+
sys.exit(main())
|