Skip to content

Commit 74f8e59

Browse files
committed
Add a final statistics report
1 parent 76e7ad9 commit 74f8e59

3 files changed

Lines changed: 103 additions & 51 deletions

File tree

examples/test_load_example.py

Lines changed: 13 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -7,67 +7,46 @@
77
1. Weighted test selection (tests run with different frequencies)
88
2. Shared state tracking across workers using shared_json_fixture_factory
99
3. Conditional stopping based on shared state
10+
4. Automatic execution report at session end
1011
1112
TEST_CODE:
1213
```python
1314
result = pytester.runpytest('--load-test', '-n', '2', '-v')
1415
result.stdout.fnmatch_lines([
15-
'*Interrupted: System health critical*',
16+
'*Interrupted: Test session completed*',
1617
])
1718
assert result.ret == pytest.ExitCode.INTERRUPTED
1819
```
1920
"""
2021

21-
import logging
22-
import sys
23-
2422
import pytest
2523

2624
from pytest_load_testing import stop_load_testing, weight
2725

28-
logger = logging.getLogger(__name__)
29-
3026

3127
@pytest.fixture(scope="session")
32-
def progress_tracker(request, shared_json_fixture_factory):
28+
def progress_tracker(shared_json_fixture_factory):
3329
"""Track errors across all workers."""
34-
35-
def on_last_worker(shared):
36-
"""Called once by the last worker to finish."""
37-
# Read the data that was collected during test execution
38-
data = shared.read()
39-
40-
# Write to a file to prove callback executed
41-
with open("/tmp/load_test_report.txt", "w") as f:
42-
f.write("=" * 60 + "\n")
43-
f.write("LOAD TEST REPORT\n")
44-
f.write("=" * 60 + "\n")
45-
f.write(f"Total test executions: {data.get('count', 0)}\n")
46-
f.write("=" * 60 + "\n")
47-
48-
# Also write to stderr
49-
report = []
50-
report.append("\n" + "=" * 60)
51-
report.append("LOAD TEST REPORT")
52-
report.append("=" * 60)
53-
report.append(f"\nTotal test executions: {data.get('count', 0)}")
54-
report.append("=" * 60 + "\n")
55-
sys.stderr.write("\n".join(report))
56-
sys.stderr.flush()
57-
58-
return shared_json_fixture_factory(name="counter", on_first_worker={"count": 0}, on_last_worker=on_last_worker)
30+
return shared_json_fixture_factory(name="progress", on_first_worker={"count": 0, "results_by_node": {}})
5931

6032

6133
@pytest.fixture
6234
def iteration_counter(request, progress_tracker):
6335
"""Common fixture that tracks iterations and stops after threshold."""
36+
node_id = request.node.nodeid
37+
6438
with progress_tracker.locked_dict() as data:
6539
data["count"] = data.get("count", 0) + 1
6640
current_count = data["count"]
6741

68-
# Stop after 20 total test executions across all workers
42+
# Track results by node id
43+
results_by_node = data.get("results_by_node", {})
44+
results_by_node[node_id] = results_by_node.get(node_id, 0) + 1
45+
data["results_by_node"] = results_by_node
46+
47+
# Stop after 100 total test executions across all workers
6948
if current_count >= 100:
70-
stop_load_testing(request, "System health critical")
49+
stop_load_testing(request, "Test session completed")
7150

7251

7352
@weight(70)

src/pytest_load_testing/plugin.py

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
"""pytest-xdist-load-testing plugin implementation."""
2+
23
import logging
34

45
import pytest
@@ -73,7 +74,6 @@ def pytest_runtest_setup(self, item: pytest.Item):
7374

7475
# If item has been run before, we need to clear fixtures for re-execution
7576
if has_been_run and request_obj is not None: # type: ignore[attr-defined]
76-
7777
self._cleanup_previous_run(item)
7878

7979
# Get ready for next time
@@ -92,7 +92,6 @@ def _save_weight_for_controller(self, item: pytest.Item) -> None:
9292
item.user_properties.append((LoadTestPlugin.WEIGHT_PROPERTY_KEY, weight_value))
9393

9494
def _cleanup_previous_run(self, item: pytest.Item) -> None:
95-
9695
setupstate = item.session._setupstate
9796

9897
# If item is in stack, run its teardown functions first
@@ -164,7 +163,7 @@ def pytest_runtest_logreport(self, report: pytest.TestReport):
164163
return
165164

166165
# Check for stop signal and weight in user_properties
167-
if hasattr(report, 'user_properties'):
166+
if hasattr(report, "user_properties"):
168167
for key, value in report.user_properties:
169168
if key == LOAD_TEST_STOP_SIGNAL and isinstance(value, str):
170169
scheduler.stop(value)
@@ -194,6 +193,13 @@ def pytest_sessionstart(self, session: pytest.Session):
194193

195194
def pytest_sessionfinish(self, session: pytest.Session, exitstatus: int):
196195
"""Hook called after whole test run finished, right before returning exit status."""
196+
# Print load test statistics if enabled
197+
if self.is_enabled(session.config):
198+
scheduler = session.config.stash.get(stash_key_scheduler, None)
199+
if scheduler:
200+
# Use stderr=False (logger) by default, can be changed to True for stderr output
201+
scheduler.print_final_statistics(use_stderr=True)
202+
197203
if stash_key_session in session.config.stash:
198204
del session.config.stash[stash_key_session]
199205

@@ -206,8 +212,7 @@ def pytest_configure(config: pytest.Config):
206212
"""
207213
# Register custom markers
208214
config.addinivalue_line(
209-
"markers",
210-
"weight(value): Set the weight of a test for load testing (higher = more likely to be selected)"
215+
"markers", "weight(value): Set the weight of a test for load testing (higher = more likely to be selected)"
211216
)
212217

213218
# Only register plugin instance if load testing is enabled
@@ -218,11 +223,11 @@ def pytest_configure(config: pytest.Config):
218223

219224
def pytest_addoption(parser):
220225
"""Add plugin-specific command line options."""
221-
group = parser.getgroup('xdist-load-testing')
226+
group = parser.getgroup("xdist-load-testing")
222227
group.addoption(
223-
'--load-test',
224-
action='store_true',
228+
"--load-test",
229+
action="store_true",
225230
dest=LoadTestPlugin.PYTEST_OPTION_NAME,
226231
default=False,
227-
help='Enable load testing mode with continuous test execution.'
232+
help="Enable load testing mode with continuous test execution.",
228233
)

src/pytest_load_testing/scheduler.py

Lines changed: 76 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
"""Load testing scheduler implementation."""
2+
23
import logging
34
import random
5+
import sys
46
import time
7+
from dataclasses import dataclass
58
from typing import Any, Optional
69

710
import pytest
@@ -13,6 +16,21 @@
1316
logger = logging.getLogger(__name__)
1417

1518

19+
@dataclass
20+
class TestStatistic:
21+
"""Statistics for a single test's execution."""
22+
23+
nodeid: str
24+
total: int
25+
passes: int
26+
failures: int
27+
28+
@property
29+
def failure_rate(self) -> float:
30+
"""Calculate the failure rate for this test."""
31+
return self.failures / self.total if self.total > 0 else 0.0
32+
33+
1634
class LoadTestScheduler(LoadScheduling):
1735
"""
1836
Custom scheduler for pytest-xdist that continuously supplies tests to workers.
@@ -31,6 +49,7 @@ class LogProducer:
3149
Compatible with xdist's Producer interface but routes messages through
3250
Python's logging system.
3351
"""
52+
3453
def __init__(self, name: str, *, enabled: bool = True) -> None:
3554
self.name = name
3655
self.enabled = enabled
@@ -168,11 +187,7 @@ def _send_weighted_tests(self, node: WorkerController, num: int) -> None:
168187
# Select 'num' tests randomly with weights
169188
# Tests with weight 0 will never be selected
170189
try:
171-
selected_indices = random.choices(
172-
range(len(self.collection)),
173-
weights=self.weights,
174-
k=num
175-
)
190+
selected_indices = random.choices(range(len(self.collection)), weights=self.weights, k=num)
176191
except ValueError:
177192
# This shouldn't happen if we checked for all-zero weights above
178193
self.log("Error selecting tests with weights, stopping scheduler")
@@ -215,9 +230,7 @@ def schedule(self) -> None:
215230
for node in self.nodes:
216231
self.check_schedule(node)
217232

218-
def mark_test_complete(
219-
self, node: WorkerController, item_index: int, duration: float = 0
220-
) -> None:
233+
def mark_test_complete(self, node: WorkerController, item_index: int, duration: float = 0) -> None:
221234
"""Called when a test completes - schedule more work."""
222235
# Call parent, which will call check_schedule
223236
super().mark_test_complete(node, item_index, duration)
@@ -286,6 +299,48 @@ def remove_node(self, node: WorkerController) -> Optional[str]:
286299
if pending:
287300
# Return the first item that was running
288301
return self.collection[pending[0]] if self.collection else None
302+
303+
def _calculate_test_statistics(self) -> list[TestStatistic]:
304+
"""Calculate execution statistics for all tests."""
305+
all_tests = set(self.test_passes.keys()) | set(self.test_failures.keys())
306+
stats = []
307+
308+
for nodeid in all_tests:
309+
passes = self.test_passes.get(nodeid, 0)
310+
failures = self.test_failures.get(nodeid, 0)
311+
stats.append(TestStatistic(nodeid, passes + failures, passes, failures))
312+
313+
return sorted(stats, key=lambda x: x.total, reverse=True)
314+
315+
def _format_statistics_report(self, stats: list[TestStatistic]) -> list[str]:
316+
"""Format statistics into report lines."""
317+
SEPARATOR_WIDTH = 70
318+
total_executions = sum(s.total for s in stats)
319+
320+
lines = [
321+
"=" * SEPARATOR_WIDTH,
322+
"Load Test Execution Report",
323+
"=" * SEPARATOR_WIDTH,
324+
]
325+
326+
for stat in stats:
327+
status = f"✓ {stat.passes}{stat.failures}"
328+
lines.append(f"{stat.nodeid}: {stat.total} executions ({status})")
329+
330+
lines.append(f"Total executions: {total_executions}")
331+
lines.append("=" * SEPARATOR_WIDTH)
332+
333+
return lines
334+
335+
def _output_report(self, lines: list[str], use_stderr: bool = False) -> None:
336+
"""Output report lines to stderr or logger."""
337+
if use_stderr:
338+
sys.stderr.write("\n" + "\n".join(lines) + "\n")
339+
sys.stderr.flush()
340+
else:
341+
for line in lines:
342+
logger.info(line)
343+
289344
return None
290345

291346
def stop(self, reason: str = "Interrupted"):
@@ -302,3 +357,16 @@ def stop(self, reason: str = "Interrupted"):
302357
for node in self.nodes:
303358
if not node.shutting_down:
304359
node.shutdown()
360+
361+
def print_final_statistics(self, use_stderr: bool = False) -> None:
362+
"""Print test execution statistics at the end of the session.
363+
364+
Args:
365+
use_stderr: If True, write to stderr. If False, use logger.info (default).
366+
"""
367+
if not self.test_passes and not self.test_failures:
368+
return
369+
370+
stats = self._calculate_test_statistics()
371+
lines = self._format_statistics_report(stats)
372+
self._output_report(lines, use_stderr)

0 commit comments

Comments
 (0)