El Problema de los Tests Flaky

Los tests flaky son la plaga de la automatización de pruebas moderna. Pasan y fallan intermitentemente sin cambios en el código, erosionando la confianza en los test suites y desperdiciando horas de ingeniería en investigación. Los estudios muestran que 15-25% de los tests en bases de código grandes exhiben comportamiento flaky, y los equipos gastan 10-30% del tiempo de QA debuggeando fallos falsos.

Los enfoques tradicionales para detectar tests flaky dependen de re-ejecutar tests múltiples veces y analizar patrones manualmente. Esto es lento, costoso y reactivo. Machine Learning (como se discute en AI Code Smell Detection: Finding Problems in Test Automation with ML) ofrece una solución proactiva: predecir qué tests probablemente serán flaky antes de que causen problemas, identificar causas raíz automáticamente y sugerir correcciones.

Este artículo explora cómo aprovechar ML (como se discute en AI-powered Test Generation: The Future Is Already Here) para detección de tests flaky, con algoritmos prácticos, ejemplos de implementación y estrategias probadas para construir test suites estables.

Entendiendo los Tests Flaky

Tipos de Flakiness

TipoDescripciónEjemplo
Dependiente del OrdenEl resultado del test depende del orden de ejecuciónTest A pasa solo pero falla después del Test B
Problemas de Espera AsyncRace conditions, esperas insuficientesClick en botón antes de que sea clickeable
Fugas de RecursosEstado compartido no limpiadoConexión de base de datos dejada abierta
Problemas de ConcurrenciaMulti-threading, ejecución paralelaRace conditions en ejecuciones paralelas
Flakes de InfraestructuraLatencia de red, dependencias externasTimeout de API, caída de servicio 3rd-party
Código No DeterministaDatos random, timestamps, UUIDsTest espera valor UUID específico
Específico de PlataformaDiferencias de OS, navegador, entornoFunciona en Chrome, falla en Safari

Costo de Tests Flaky

Costos directos:

  • Tiempo de investigación: 2-8 horas por test flaky
  • Retrasos en pipeline CI/CD: Retraso promedio de 15 minutos por re-ejecución
  • Confianza falsa: Ignorar fallos reales enmascarados por flakiness

Costos indirectos:

  • Frustración del desarrollador y moral disminuida
  • Confianza reducida en test suite → saltando tests
  • Releases retrasados debido a incertidumbre sobre fallos

Enfoques de Machine Learning para Detección de Tests Flaky

1. Supervised Learning: Modelos de Clasificación

Entrenar un modelo para clasificar tests como “flaky” o “estable” basándose en datos históricos y características de código.

Feature Engineering

Características de historial de ejecución:

execution_features = {
    'pass_rate': 0.73,  # Pasa 73% del tiempo
    'consecutive_failures': 2,
    'failure_variability': 0.45,  # Alta varianza en resultados
    'avg_execution_time': 12.3,
    'execution_time_stddev': 4.2,  # Alta varianza de tiempo
    'last_10_outcomes': [1,1,0,1,1,0,1,1,1,0],  # 1=pasa, 0=falla
}

Características basadas en código:

import ast

def extract_code_features(test_code):
    tree = ast.parse(test_code)

    return {
        'uses_sleep': 'time.sleep' in test_code,
        'uses_random': 'random' in test_code,
        'async_count': test_code.count('async '),
        'network_calls': test_code.count('requests.') + test_code.count('httpx.'),
        'database_queries': test_code.count('execute('),
        'wait_statements': test_code.count('WebDriverWait'),
        'thread_usage': test_code.count('Thread('),
        'external_deps': test_code.count('mock.') == 0,  # Sin mocking
        'assertion_count': test_code.count('assert'),
        'test_length': len(test_code.split('\n')),
    }

Entrenar un Random Forest Classifier

from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import train_test_split
import pandas as pd

# Cargar dataset etiquetado
data = pd.read_csv('test_history.csv')

# Separar características y etiquetas
X = data.drop(['test_id', 'is_flaky'], axis=1)
y = data['is_flaky']

# Dividir datos
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42
)

# Entrenar modelo
model = RandomForestClassifier(
    n_estimators=100,
    max_depth=10,
    random_state=42
)
model.fit(X_train, y_train)

# Evaluar
from sklearn.metrics import classification_report

y_pred = model.predict(X_test)
print(classification_report(y_test, y_pred))

# Importancia de características
feature_importance = pd.DataFrame({
    'feature': X.columns,
    'importance': model.feature_importances_
}).sort_values('importance', ascending=False)

print("\nPrincipales indicadores de test flaky:")
print(feature_importance.head(10))

2. Unsupervised Learning: Detección de Anomalías

Identificar patrones de comportamiento de test inusuales sin datos etiquetados.

from sklearn.ensemble import IsolationForest
import numpy as np

class FlakyTestAnomalyDetector:
    def __init__(self, contamination=0.1):
        self.model = IsolationForest(
            contamination=contamination,
            random_state=42
        )

    def fit(self, test_execution_history):
        features = self._extract_time_series_features(test_execution_history)
        self.model.fit(features)
        return self

    def _extract_time_series_features(self, history):
        features = []
        for test_id in history['test_id'].unique():
            test_data = history[history['test_id'] == test_id]
            outcomes = test_data['outcome'].values
            times = test_data['execution_time'].values

            features.append({
                'pass_rate': np.mean(outcomes),
                'pass_rate_variance': np.var(outcomes),
                'execution_time_mean': np.mean(times),
                'execution_time_variance': np.var(times),
            })

        return pd.DataFrame(features)

    def predict_flaky(self, test_execution_history):
        features = self._extract_time_series_features(test_execution_history)
        predictions = self.model.predict(features)
        anomaly_scores = self.model.score_samples(features)

        results = pd.DataFrame({
            'test_id': test_execution_history['test_id'].unique(),
            'anomaly_score': anomaly_scores,
            'is_flaky': predictions == -1
        })

        return results.sort_values('anomaly_score')

3. Análisis de Series Temporales: Reconocimiento de Patrones de Fallo

import tensorflow as tf
from tensorflow import keras

class FlakyTestPredictor:
    def __init__(self, sequence_length=20):
        self.sequence_length = sequence_length
        self.model = self._build_model()

    def _build_model(self):
        model = keras.Sequential([
            keras.layers.LSTM(64, input_shape=(self.sequence_length, 1)),
            keras.layers.Dropout(0.2),
            keras.layers.Dense(32, activation='relu'),
            keras.layers.Dense(1, activation='sigmoid')
        ])

        model.compile(
            optimizer='adam',
            loss='binary_crossentropy',
            metrics=['accuracy']
        )

        return model

    def predict_stability(self, recent_outcomes):
        if len(recent_outcomes) < self.sequence_length:
            return None

        X = np.array(recent_outcomes[-self.sequence_length:])
        X = X.reshape(1, self.sequence_length, 1)

        prediction = self.model.predict(X, verbose=0)[0][0]

        # Alta varianza en predicciones indica flakiness
        predictions = []
        for _ in range(10):
            pred = self.model.predict(X, verbose=0)[0][0]
            predictions.append(pred)

        prediction_variance = np.var(predictions)

        return {
            'next_pass_probability': prediction,
            'prediction_variance': prediction_variance,
            'likely_flaky': prediction_variance > 0.05 or (0.3 < prediction < 0.7)
        }

Implementación Práctica

Construyendo un Pipeline de Detección de Tests Flaky

class FlakyTestDetectionPipeline:
    def __init__(self):
        self.classifier = RandomForestClassifier()
        self.anomaly_detector = FlakyTestAnomalyDetector()

    def detect_flaky_tests(self, execution_data):
        # 1. Análisis estadístico
        stats = self._calculate_test_statistics(execution_data)

        # 2. Clasificación ML
 (como se discute en [AI Test Metrics Analytics: Intelligent Analysis of QA Metrics](/blog/ai-test-metrics))        ml_predictions = self._ml_classification(stats)

        # 3. Detección de anomalías
        anomalies = self.anomaly_detector.predict_flaky(execution_data)

        # Combinar resultados
        flaky_tests = self._combine_predictions(stats, ml_predictions, anomalies)

        return flaky_tests

    def _calculate_test_statistics(self, data):
        stats = []
        for test_id in data['test_id'].unique():
            test_data = data[data['test_id'] == test_id]
            outcomes = test_data['outcome'].values

            stats.append({
                'test_id': test_id,
                'pass_rate': np.mean(outcomes),
                'total_runs': len(outcomes),
                'failures': np.sum(outcomes == 0),
                'variance': np.var(outcomes),
            })

        return pd.DataFrame(stats)

    def _combine_predictions(self, stats, ml_preds, anomalies):
        results = stats.copy()

        # Votación simple: test es flaky si 2+ métodos están de acuerdo
        results['statistical_flaky'] = (results['pass_rate'] < 0.95) & (results['pass_rate'] > 0.05)
        results['ml_flaky'] = ml_preds if ml_preds is not None else False
        results['anomaly_flaky'] = anomalies['is_flaky'].values

        results['votes'] = (
            results['statistical_flaky'].astype(int) +
            results['ml_flaky'].astype(int) +
            results['anomaly_flaky'].astype(int)
        )

        results['is_flaky'] = results['votes'] >= 2

        return results[results['is_flaky']].sort_values('pass_rate')

Integración con CI/CD

# .github/workflows/flaky-detection.yml
name: Detección de Tests Flaky

on:
  schedule:
    - cron: '0 2 * * *'  # Diario a las 2 AM
  workflow_dispatch:

jobs:
  detect-flaky:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - name: Ejecutar tests con detección flaky
        run: |
          pip install flaky-detector pytest pytest-json-report
          pytest --json-report --json-report-file=report.json

      - name: Analizar tests flaky
        run: |
          python scripts/detect_flaky_tests.py --report report.json

      - name: Crear issue para tests flaky
        if: env.FLAKY_TESTS_FOUND == 'true'
        uses: actions/github-script@v6
        with:
          script: |
            const fs = require('fs');
            const flaky = JSON.parse(fs.readFileSync('flaky_tests.json'));

            let body = '## 🚨 Tests Flaky Detectados\n\n';
            flaky.forEach(test => {
              body += `### ${test.test_id}\n`;
              body += `- Tasa de paso: ${(test.pass_rate * 100).toFixed(1)}%\n`;
              body += `- Causa probable: ${test.root_cause}\n\n`;
            });

            github.rest.issues.create({
              owner: context.repo.owner,
              repo: context.repo.repo,
              title: 'Tests Flaky Detectados',
              body: body,
              labels: ['flaky-test', 'quality']
            });

Estrategias para Corregir Tests Flaky

Sugerencias de Corrección Automatizadas

def suggest_fixes(test_code, failure_patterns):
    suggestions = []

    if 'time.sleep' in test_code:
        suggestions.append({
            'issue': 'Usa time.sleep (no determinista)',
            'fix': 'Reemplazar con WebDriverWait explícito',
            'example': 'WebDriverWait(driver, 10).until(EC.element_to_be_clickable((By.ID, "submit")))'
        })

    if 'click()' in test_code and 'wait' not in test_code.lower():
        suggestions.append({
            'issue': 'Click sin espera explícita',
            'fix': 'Agregar espera antes de hacer click',
            'example': 'wait.until(EC.element_to_be_clickable(element)).click()'
        })

    if 'requests.get' in test_code and 'mock' not in test_code:
        suggestions.append({
            'issue': 'Dependencia de API externa',
            'fix': 'Mockear llamadas de API externas',
            'example': '@mock.patch("requests.get")\ndef test_api(mock_get): ...'
        })

    return suggestions

Midiendo el Éxito

Métricas Clave

MétricaAntes de MLDespués de MLObjetivo
Tiempo de identificación de test flaky4-8 horas/test5 minutos< 10 min
Tasa de falsos positivos en CI25%8%< 5%
Estabilidad del test suite75%95%> 98%
Tiempo de investigación por fallo30 min10 min< 15 min
Tests flaky en producción15%3%< 2%

Cálculo de ROI

Equipo de 10 ingenieros, 5000 tests, 15% tasa flaky

Costo de detección manual:
750 tests flaky × 4 horas investigación = 3,000 horas
3,000 horas × $75/hora = $225,000

Costo de detección ML:
Setup: 80 horas × $75 = $6,000
Mantenimiento: 2 horas/semana × 52 semanas × $75 = $7,800
Total: $13,800

Ahorro: $225,000 - $13,800 = $211,200/año
ROI: 1,530%

Conclusión

Los tests flaky socavan la confianza en la automatización y desperdician recursos significativos de ingeniería. Machine Learning transforma la detección de tests flaky de un proceso reactivo y manual a un sistema proactivo y automatizado que predice flakiness, identifica causas raíz y sugiere correcciones.

Comienza con detección estadística simple, agrega clasificación ML a medida que recopilas datos e integra detección de anomalías para cobertura completa. Rastrea métricas, itera en tus modelos y mejora continuamente la estabilidad de tests.

Recuerda: El objetivo no es solo detectar tests flaky—es prevenirlos. Usa insights de ML para mejorar patrones de diseño de tests, hacer cumplir mejores prácticas y construir una cultura de confiabilidad de tests.

Recursos

  • Herramientas: Flaky Test Tracker (Google), DeFlaker, NonDex, FlakeFlagger
  • Investigación: “An Empirical Analysis of Flaky Tests” (IEEE), Investigación de Flaky Test de Google
  • Datasets: Datasets de test flaky en Zenodo, dataset IDoFT
  • Frameworks: pytest-flakefinder, Jest –detectFlakes

Tests estables, despliegues confiados. Deja que ML sea tu guardián de flakiness.