11"""Load testing scheduler implementation."""
2+
23import logging
34import random
5+ import sys
46import time
7+ from dataclasses import dataclass
58from typing import Any , Optional
69
710import pytest
1316logger = 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+
1634class 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