FlakyDetector — мой путь к созданию инструмента для ловли «нестабильных» тестов в Python

В каждой команде есть те самые тесты, которым никто не верит. Они падают «иногда», часто по причине, которую никто не может воспроизвести.

Ты запускаешь тесты — всё зелёное. CI запускает те же тесты — и внезапно падает какая-то ерунда. Разработчик пишет: «У меня работает». QA отвечает: «У нас — нет».

Так я впервые глубоко задумался о феномене flaky-тестов. Эти тесты подрывают доверие к CI, замедляют релизы и крадут часы разработчиков. Поэтому я решил создать инструмент, который сможет:

— находить такие тесты автоматически, — объяснять, что именно в них нестабильно, — и предлагать путь к исправлению.

Так появился FlakyDetector — мой персональный проект, который перерос в полноценный инструмент анализа нестабильных тестов. Ниже — история о том, как я его создавал.

🔥 Зачем вообще ловить flaky-тесты?

Потому что одна “нестабильная” проверка:

  • может завалить релиз,
  • заставить команду перезапускать CI раз за разом,
  • замедлить разработку в 3–5 раз,
  • полностью уничтожить доверие к тестам.

Причины у flaky бывают разные:

  • гонки в async-коде,
  • тайминги и сетевые таймауты,
  • зависимость тестов друг от друга,
  • глобальное состояние,
  • неправильные моки.

Но всё это нужно сначала обнаружить, иначе команда будет просто спорить.

🎯 С чего начался путь

Меня дико раздражало, что CI иногда «падает сам по себе». Логи огромные, тестов тысячи, а времени разбираться у разработчиков — ноль.

Я искал уже готовые инструменты:

  • что-то было слишком сложным,
  • что-то требовало переписывать весь CI,
  • что-то работало только под конкретный тест-раннер.

В итоге я решил: создам собственный инструмент, который решает задачу именно так, как нужно мне.

⚙ Архитектура FlakyDetector: просто о сложном

Чтобы понять, насколько тест «нестабилен», нужно собрать огромную картину:

  1. Логи CI — ошибки, тайминги, зависшие процессы.
  2. Анализ кода теста — глобальные переменные, async, моки.
  3. Статистика последних запусков — где тест падал, сколько раз, почему.
  4. ML-классификация — тип поведения: race condition? timeout? order dependency?

В итоге архитектура родилась такая:

FlakyDetector — мой путь к созданию инструмента для ловли «нестабильных» тестов в Python

Но давай по частям.

📥 Модуль Dataset Collector: мозг, который собирает хаос

CI-логи — это ад.

GitHub Actions может дать:

  • 200 МБ логов,
  • разные форматы вывода,
  • rate limit,
  • обрезанные строки.

Мне нужно было из хаоса извлечь:

  • статус каждого теста,
  • время выполнения,
  • тип ошибки,
  • стабильность за N последних билдов.

Dataset Collector стал первым модулем, который я написал. Он умеет:

  • обходить лимиты GitHub API,
  • парсить разнородные логи,
  • нормализовать события,
  • строить статистику по тестам.

🔍 Log Analyzer: объясняет, что именно пошло не так

Тесты падают не просто так.

И чаще всего виноваты:

  • AsyncIssue — гонки в event loop,
  • TimingIssue — реальные/ложные таймауты,
  • NetworkIssue — временная потеря связи,
  • OrderDependency — тесты зависят друг от друга,
  • GlobalState — кто-то меняет переменную на уровне модуля.

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

🤖 ML модель: CatBoost классифицирует flaky-поведение

Я не хотел ограничиваться простыми правилами типа:

«Если тест упал с таймаутом → значит этот тест flaky».

Это неправда.

Я собрал датасет и обучил CatBoostClassifier, который анализирует:

  • текст ошибки,
  • стек-трейс,
  • скорость выполнения теста,
  • стабильность за последние 20+ запусков,
  • влияние порядка тестов.

Модель даёт точность 87.3% — и этого достаточно, чтобы автоматически сортировать тесты по типу нестабильности.

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)

📊 Dashboard: чтобы команда сразу видела картину

Я сделал дашборд на React + Recharts:

  • график flaky rate
  • топ нестабильных тестов
  • рекомендации по каждому тесту
  • история ошибок
  • вкладка диагностики
  • экспорт отчётов

Удобно для QA, для тимлидов и для CI-инженеров.

FlakyDetector — мой путь к созданию инструмента для ловли «нестабильных» тестов в Python

📈 Результаты

Метрика Значение
Время анализа одного теста < 100 мс
Точность классификации 87.3 %
Поддержка тестов 10 000+
Снижение flaky rate 15–20 %
Ускорение диагностики до 60 %

🔧 Над чем ещё работаю

Проект ещё развивается.

Сейчас в плане:

  • привести документацию в полный порядок,
  • добавить поддержку Pytest markers,
  • расширить анализ async-тестов,
  • улучшить визуализацию,
  • добавить интеграцию с GitLab CI и Bitbucket,
  • вынести модель в отдельный сервис.

Хочу, чтобы FlakyDetector стал полноценным инструментом, который можно использовать в любой Python-команде.

🧠 Итоги

Flaky-тесты — это не просто “разовая неприятность”. Это скрытая угроза стабильности продукта, скорости разработки и нервов команды.

Создание FlakyDetector научило меня:

  • понимать природу нестабильного поведения тестов,
  • работать с ML на реальных данных,
  • строить систему логирования и нормализации,
  • проектировать удобные инструменты для разработчиков,
  • оптимизировать CI/CD пайплайны.

И самое главное — автоматизировать то, что команды обычно делают вручную.

Если вам знакома проблема flaky-тестов — возможно, вам пригодится мой опыт. А если хотите попробовать FlakyDetector — пишите,

поделюсь.

1
Начать дискуссию