-
Notifications
You must be signed in to change notification settings - Fork 40
Expand file tree
/
Copy pathdetect_flaky_tests.py
More file actions
379 lines (316 loc) · 13.2 KB
/
Copy pathdetect_flaky_tests.py
File metadata and controls
379 lines (316 loc) · 13.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
#!/usr/bin/env python
"""
Flaky Test Detection Script for py3plex
This script runs the test suite multiple times to identify flaky tests
(tests that pass sometimes and fail sometimes) and provides detailed
analysis of the failures.
Usage:
python detect_flaky_tests.py --runs 5 --output flaky_tests_report.json
python detect_flaky_tests.py --runs 10 --test-subset tests/test_dsl_v2.py
python detect_flaky_tests.py --runs 3 --parallel 4
"""
import argparse
import json
import subprocess
import sys
import time
from collections import defaultdict
from dataclasses import dataclass, asdict
from pathlib import Path
from typing import List, Dict, Optional, Set
import re
@dataclass
class TestRun:
"""Represents a single test run."""
test_id: str
status: str # 'passed', 'failed', 'skipped', 'error'
duration: float
failure_message: Optional[str] = None
@dataclass
class TestStatistics:
"""Statistics for a test across multiple runs."""
test_id: str
total_runs: int
passed: int
failed: int
skipped: int
errors: int
failure_messages: List[str]
durations: List[float]
@property
def pass_rate(self) -> float:
"""Calculate pass rate."""
if self.total_runs == 0:
return 0.0
return self.passed / self.total_runs
@property
def is_flaky(self) -> bool:
"""Determine if test is flaky (not consistently passing or failing)."""
# A test is flaky if it has both passes and failures
return self.passed > 0 and self.failed > 0
@property
def avg_duration(self) -> float:
"""Average duration across runs."""
if not self.durations:
return 0.0
return sum(self.durations) / len(self.durations)
class FlakyTestDetector:
"""Detects flaky tests by running tests multiple times."""
def __init__(self, runs: int = 5, test_path: str = "tests/", parallel: int = 1):
self.runs = runs
self.test_path = test_path
self.parallel = parallel
self.test_results: Dict[str, List[TestRun]] = defaultdict(list)
def run_tests(self, run_number: int) -> Dict[str, TestRun]:
"""Run tests once and return results."""
print(f"\n{'='*80}")
print(f"Running test suite: Run {run_number + 1}/{self.runs}")
print(f"{'='*80}\n")
# Use pytest with JSON report
cmd = [
sys.executable, "-m", "pytest",
self.test_path,
"-v",
"--tb=short",
"-q",
f"-n={self.parallel}" if self.parallel > 1 else "-n=auto",
"--junit-xml=pytest_results.xml",
"--strict-markers",
]
start_time = time.time()
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=600 # 10 minute timeout per run
)
duration = time.time() - start_time
# Parse output
test_results = self._parse_pytest_output(result.stdout, result.stderr)
print(f"\nRun {run_number + 1} completed in {duration:.2f}s")
print(f"Exit code: {result.returncode}")
return test_results
except subprocess.TimeoutExpired:
print(f" Run {run_number + 1} timed out after 600s")
return {}
except Exception as e:
print(f" Run {run_number + 1} failed with error: {e}")
return {}
def _parse_pytest_output(self, stdout: str, stderr: str) -> Dict[str, TestRun]:
"""Parse pytest output to extract test results."""
results = {}
# Pattern to match pytest output lines
# Example: tests/test_file.py::TestClass::test_method PASSED
# Example: tests/test_file.py::test_function FAILED
pattern = r'([\w/\.\-]+\.py::\S+)\s+(PASSED|FAILED|SKIPPED|ERROR)\s*(\[.*?\])?\s*(?:\(([\d\.]+)s\))?'
for line in stdout.split('\n'):
match = re.search(pattern, line)
if match:
test_id = match.group(1)
status = match.group(2).lower()
duration_str = match.group(4)
duration = float(duration_str) if duration_str else 0.0
# Extract failure message from following lines if failed
failure_msg = None
if status == 'failed':
# Look for assertion errors or exception info
failure_msg = self._extract_failure_message(stdout, test_id)
results[test_id] = TestRun(
test_id=test_id,
status=status,
duration=duration,
failure_message=failure_msg
)
return results
def _extract_failure_message(self, output: str, test_id: str) -> Optional[str]:
"""Extract failure message for a failed test."""
# Simple extraction - look for lines after the test failure
lines = output.split('\n')
for i, line in enumerate(lines):
if test_id in line and 'FAILED' in line:
# Get next few lines as failure message
msg_lines = []
for j in range(i + 1, min(i + 10, len(lines))):
if lines[j].startswith('___') or lines[j].startswith('==='):
break
msg_lines.append(lines[j])
return '\n'.join(msg_lines[:5]) # First 5 lines
return None
def detect_flaky_tests(self) -> List[TestStatistics]:
"""Run tests multiple times and detect flaky tests."""
print(f" Starting flaky test detection")
print(f" Runs: {self.runs}")
print(f" Test path: {self.test_path}")
print(f" Parallel workers: {self.parallel}")
# Run tests multiple times
for run_num in range(self.runs):
run_results = self.run_tests(run_num)
# Store results
for test_id, test_run in run_results.items():
self.test_results[test_id].append(test_run)
# Analyze results
return self._analyze_results()
def _analyze_results(self) -> List[TestStatistics]:
"""Analyze test results to identify flaky tests."""
statistics = []
for test_id, runs in self.test_results.items():
passed = sum(1 for r in runs if r.status == 'passed')
failed = sum(1 for r in runs if r.status == 'failed')
skipped = sum(1 for r in runs if r.status == 'skipped')
errors = sum(1 for r in runs if r.status == 'error')
failure_messages = [r.failure_message for r in runs if r.failure_message]
durations = [r.duration for r in runs if r.duration > 0]
stats = TestStatistics(
test_id=test_id,
total_runs=len(runs),
passed=passed,
failed=failed,
skipped=skipped,
errors=errors,
failure_messages=failure_messages,
durations=durations
)
statistics.append(stats)
return statistics
def generate_report(self, statistics: List[TestStatistics], output_file: str):
"""Generate a report of flaky tests."""
# Separate flaky and stable tests
flaky_tests = [s for s in statistics if s.is_flaky]
stable_passed = [s for s in statistics if s.passed == s.total_runs]
stable_failed = [s for s in statistics if s.failed == s.total_runs]
print("\n" + "="*80)
print("FLAKY TEST DETECTION REPORT")
print("="*80)
print(f"\n Overall Statistics:")
print(f" Total unique tests: {len(statistics)}")
print(f" Flaky tests: {len(flaky_tests)}")
print(f" Stable passing: {len(stable_passed)}")
print(f" Stable failing: {len(stable_failed)}")
print(f" Flaky rate: {len(flaky_tests)/len(statistics)*100:.2f}%")
if flaky_tests:
print(f"\n FLAKY TESTS ({len(flaky_tests)}):")
print("-" * 80)
# Sort by pass rate (most flaky first)
flaky_tests.sort(key=lambda x: abs(0.5 - x.pass_rate))
for i, test in enumerate(flaky_tests[:20], 1): # Show top 20
print(f"\n{i}. {test.test_id}")
print(f" Pass rate: {test.pass_rate*100:.1f}% ({test.passed}/{test.total_runs})")
print(f" Failed: {test.failed}, Errors: {test.errors}, Skipped: {test.skipped}")
print(f" Avg duration: {test.avg_duration:.3f}s")
if test.failure_messages:
print(f" Sample failure: {test.failure_messages[0][:100]}...")
# Save JSON report
report_data = {
"runs": self.runs,
"test_path": self.test_path,
"total_tests": len(statistics),
"flaky_tests_count": len(flaky_tests),
"flaky_rate": len(flaky_tests)/len(statistics) if statistics else 0,
"flaky_tests": [
{
"test_id": s.test_id,
"pass_rate": s.pass_rate,
"passed": s.passed,
"failed": s.failed,
"skipped": s.skipped,
"errors": s.errors,
"avg_duration": s.avg_duration,
"failure_messages": s.failure_messages[:3] # Include only first 3
}
for s in flaky_tests
],
"stable_failing": [
{
"test_id": s.test_id,
"failure_messages": s.failure_messages[:1]
}
for s in stable_failed[:10] # Include first 10
]
}
with open(output_file, 'w') as f:
json.dump(report_data, f, indent=2)
print(f"\n Full report saved to: {output_file}")
# Generate recommendations
self._generate_recommendations(flaky_tests)
def _generate_recommendations(self, flaky_tests: List[TestStatistics]):
"""Generate recommendations for fixing flaky tests."""
if not flaky_tests:
print("\n No flaky tests detected!")
return
print("\n" + "="*80)
print("RECOMMENDATIONS FOR FIXING FLAKY TESTS")
print("="*80)
print("\n1. Common causes of flaky tests:")
print(" - Missing random seeds in tests using random/numpy.random")
print(" - Race conditions in parallel tests")
print(" - Timing-dependent assertions")
print(" - Unordered collection comparisons (sets, dicts)")
print(" - Filesystem state dependencies")
print(" - Network/external resource dependencies")
print("\n2. Recommended actions:")
print(" - Add @pytest.mark.flaky decorator to known flaky tests")
print(" - Install pytest-rerunfailures: pip install pytest-rerunfailures")
print(" - Set random seeds explicitly in tests")
print(" - Use freezegun for time-dependent tests")
print(" - Mock external dependencies")
print(" - Sort collections before comparison")
print("\n3. Example fix for random seed issues:")
print(" ```python")
print(" import random")
print(" import numpy as np")
print(" ")
print(" def test_with_randomness():")
print(" random.seed(42) # Set seed")
print(" np.random.seed(42) # Set numpy seed")
print(" # test code here")
print(" ```")
def main():
parser = argparse.ArgumentParser(
description="Detect flaky tests in py3plex test suite"
)
parser.add_argument(
"--runs",
type=int,
default=5,
help="Number of times to run the test suite (default: 5)"
)
parser.add_argument(
"--test-subset",
type=str,
default="tests/",
help="Path to test subset to check (default: tests/)"
)
parser.add_argument(
"--output",
type=str,
default="flaky_tests_report.json",
help="Output file for JSON report (default: flaky_tests_report.json)"
)
parser.add_argument(
"--parallel",
type=int,
default=1,
help="Number of parallel workers (default: 1, use -1 for auto)"
)
args = parser.parse_args()
# Create detector
detector = FlakyTestDetector(
runs=args.runs,
test_path=args.test_subset,
parallel=args.parallel
)
# Detect flaky tests
statistics = detector.detect_flaky_tests()
# Generate report
detector.generate_report(statistics, args.output)
# Exit with error if flaky tests found
flaky_count = sum(1 for s in statistics if s.is_flaky)
if flaky_count > 0:
print(f"\n Found {flaky_count} flaky tests")
sys.exit(1)
else:
print("\n No flaky tests detected")
sys.exit(0)
if __name__ == "__main__":
main()