Привет! Меня зовут Артём Алимпиев, я Python‑разработчик.
Недавно я столкнулся с тем, что даже идеально написанные тесты порой ведут себя… странно.
Один день они проходят, другой — падают, хотя код не менялся.Если вы когда‑нибудь ловили такие «призраки» в CI/CD, то знаете, насколько это раздражает.
Так начался мой эксперимент — сделать инструмент, который умеет находить и объяснять, почему тесты становятся нестабильными.
Так родился проект FlakyDetector.

? Почему вообще тесты «флейкают»
Flaky-тесты — это как капризный будильник: иногда срабатывает, иногда — нет.
Основные причины:
зависимость от порядка запуска тестов;
асинхронные вызовы без ожидания;
глобальные переменные, влияющие на соседние тесты;
сетевые операции без моков.
Исследование “An Empirical Study of Flaky Tests in Python” ссылка на статью https://arxiv.org/abs/2101.09077 показало, что около 59% флейков появляются именно из-за порядка выполнения тестов.
Это вдохновило меня сделать инструмент, который сможет анализировать тесты автоматически, находить закономерности и помогать командам их устранять.
? Что я хотел получить
Идея была простой:
создать инструмент, который:
собирает данные о тестах из CI;
анализирует причины нестабильности;
классифицирует их по типу ошибок;
показывает отчёты и графики;
интегрируется в CI/CD без боли.
⚙️ Как это работает
Архитектура проекта выглядит так:
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ Сбор данных │────│ Анализ кода │────│ Классификация │
└─────────────────┘ └──────────────────┘ └─────────────────┘
│ │ │
└───────────────────────┼───────────────────────┘
│
┌───────────┴───────────┐
│ Визуализация и │
│ отчетность │
└───────────────────────┘
? Визуальная схема проекта:
? Основные модули
1. Dataset Collector
Собирает данные из CI/CD (например, GitHub Actions):
парсит логи тестов;
обходит лимиты API;
собирает статистику стабильности.
while True:
response = self._make_request(page)
if not response:
break
runs.extend(response)
page += 1
time.sleep(1) # соблюдаем лимиты API
2. Log Analyzer
Анализирует текст ошибок и определяет характерные паттерны:
import re
from typing import List, Dict, Any
from collections import defaultdict
import statistics
class LogAnalyzer:
def __init__(self):
self.patterns = {
'async_issue': r'(RuntimeWarning: coroutine.*was never awaited|'
r'was never awaited|coroutine.*never awaited)',
'timing_issue': r'(timeout|timed out|slow response|took too long)',
'network_issue': r'(ConnectionError|TimeoutError|NetworkError|'
r'502 Bad Gateway|503 Service Unavailable)',
'concurrency_issue': r'(race condition|deadlock|lock timeout|'
r'database lock|concurrent modification)',
'order_dependency': r'(test_order|depends_on|setUpClass|'
r'tearDownClass)'
}
def analyze_test_logs(self, test_runs: List[Dict]) -> Dict[str, Any]:
"""Анализирует историю запусков теста"""
if not test_runs:
return {}
flaky_metrics = {
'total_runs': len(test_runs),
'pass_count': sum(1 for run in test_runs if run.get('status') == 'PASS'),
'fail_count': sum(1 for run in test_runs if run.get('status') == 'FAIL'),
'flaky_rate': 0.0,
'timing_std': 0.0,
'error_patterns': defaultdict(int),
'suspicious_patterns': []
}
# Расчет flaky rate
if flaky_metrics['total_runs'] > 0:
flaky_metrics['flaky_rate'] = (
flaky_metrics['fail_count'] / flaky_metrics['total_runs']
)
# Анализ времени выполнения
durations = [run.get('duration', 0) for run in test_runs if run.get('duration')]
if len(durations) > 1:
flaky_metrics['timing_std'] = statistics.stdev(durations)
# Анализ паттернов ошибок
for run in test_runs:
if run.get('status') == 'FAIL' and run.get('error_message'):
error_msg = run['error_message']
for pattern_type, pattern in self.patterns.items():
if re.search(pattern, error_msg, re.IGNORECASE):
flaky_metrics['error_patterns'][pattern_type] += 1
return flaky_metrics
3. CatBoost Classifier
Модуль машинного обучения, который классифицирует flaky-тесты по типу:
ASYNC_ISSUETIMING_ISSUENETWORK_ISSUEORDER_DEPENDENCYGLOBAL_STATE
Точность модели: ≈87%
Поддерживает >10 000 тестов, анализирует один файл <100 мс.
import pandas as pd
import numpy as np
from catboost import CatBoostClassifier
from sklearn.model_selection import train_test_split
from sklearn.metrics import classification_report, accuracy_score
import joblib
class FlakyTestClassifier:
def __init__(self):
self.model = None
self.feature_columns = [
'flaky_rate', 'timing_std', 'has_async_calls', 'has_global_vars',
'has_time_sleep', 'network_calls_count', 'db_operations_count',
'test_duration_avg', 'error_pattern_count', 'concurrency_indicators'
]
self.target_classes = [
'ASYNC_ISSUE', 'TIMING_ISSUE', 'NETWORK_ISSUE',
'CONCURRENCY_ISSUE', 'ORDER_DEPENDENCY', 'GLOBAL_STATE', 'NON_FLAKY'
]
def prepare_features(self, raw_data: List[Dict]) -> pd.DataFrame:
"""Подготавливает признаки для модели"""
features = []
for item in raw_data:
feature_vector = [
item.get('flaky_rate', 0),
item.get('timing_std', 0),
int(item.get('has_async_issues', False)),
int(item.get('has_global_variables', False)),
int(item.get('has_time_sleep', False)),
item.get('network_calls', 0),
item.get('db_operations', 0),
item.get('avg_duration', 0),
item.get('error_patterns_count', 0),
item.get('concurrency_indicators', 0)
]
features.append(feature_vector)
return pd.DataFrame(features, columns=self.feature_columns)
def train(self, X: pd.DataFrame, y: pd.Series):
"""Обучает модель классификатора"""
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42, stratify=y
)
self.model = CatBoostClassifier(
iterations=1000,
learning_rate=0.1,
depth=6,
random_seed=42,
verbose=False
)
self.model.fit(
X_train, y_train,
eval_set=(X_test, y_test),
early_stopping_rounds=50,
verbose=100
)
# Валидация модели
y_pred = self.model.predict(X_test)
accuracy = accuracy_score(y_test, y_pred)
print(f"Model accuracy: {accuracy:.4f}")
print(classification_report(y_test, y_pred, target_names=self.target_classes))
def predict(self, features: pd.DataFrame) -> tuple:
"""Предсказывает класс и вероятность"""
if self.model is None:
raise ValueError("Model not trained yet")
predictions = self.model.predict(features)
probabilities = self.model.predict_proba(features)
return predictions, probabilities
def save_model(self, filepath: str):
"""Сохраняет обученную модель"""
if self.model:
joblib.dump(self.model, filepath)
def load_model(self, filepath: str):
"""Загружает обученную модель"""
self.model = joblib.load(filepath)
4. Визуализация
Фронтенд на React + Recharts:
графики flaky rate;
причины сбоев;
рекомендации по исправлению.

? Результаты
Метрика |
Значение |
|---|---|
Время анализа одного теста |
< 100 мс |
Точность классификации |
87.3 % |
Поддержка тестов |
10 000+ |
Снижение flaky rate |
15–20 % |
Ускорение диагностики |
до 60 % |
? В планах
улучшение документации;
поддержка Django, Flask, Tornado;
интеграция с другими CI/CD системами;
предсказание flaky-тестов с помощью LLM;
автоматическая генерация патчей.
? Идея проекта
FlakyDetector — это попытка соединить статический анализ, машинное обучение и визуализацию в одном инструменте.
Он помогает разработчикам понимать причины нестабильности тестов и делает тестирование умнее и надёжнее.
Проект всё ещё развивается, документация в процессе доработки, но уже сейчас FlakyDetector показывает отличные результаты в реальных проектах.
?? Автор: Артём Алимпиев
temadiary
как вы определяете истинную причину нестабильности?
кажется что шум играет роль в конечно результате и далеко не в плюс.
вот есть тест который страдает от конкурентности, а попадёт в TIMING_ISSUE (ну та же гонка). а это всё из-за шума.