StackPractices
advanced Por Mathias Paulenko

Golden Master Testing: Caracteriza Legacy Code

Cómo usar golden master testing para caracterizar comportamiento de legacy code antes de refactorizar. Cubre captura de output, comparación de baselines, e incremental refactoring.

Temas: testing

Overview

Heredaste un servicio Python de 10 años con 40.000 líneas de código y cero tests. Tu jefe quiere que lo refactorices para usar async/await, pero ¿cómo sabes que no rompiste nada? No puedes escribir unit tests para código que no entiendes del todo.

El golden master testing (también llamado characterization testing, un término acuñado por Michael Feathers en Working Effectively with Legacy Code) captura el comportamiento actual de un sistema como línea base, después verifica que cambios futuros no alteren ese comportamiento. No afirmas lo que el código debería hacer. Afirmas lo que actualmente hace. El “golden master” es un output de referencia que sirve como verdad. Cuando refactorizas, cualquier desviación del golden master señala una regresión.

When to Use

  • Legacy code sin tests que necesitas refactorizar con seguridad
  • Código donde las reglas de negocio son poco claras o sin documentar
  • Sistemas donde el output es determinista pero complejo (reportes, cálculos, transformaciones)
  • Antes de migrar un sistema a un nuevo lenguaje o framework
  • Cuando necesitas confianza para cambiar código que no entiendes completamente

When NOT to Use

  • Código nuevo con requisitos claros: escribe tests primero (TDD) en lugar de caracterizar comportamiento que ya entiendes.
  • Cuando el output es no determinista (valores aleatorios, timestamps, llamadas de red) a menos que los normalices primero.
  • Para funciones simples donde las aserciones explícitas son más claras y baratas de mantener.
  • Cuando entiendes el código lo suficiente para escribir tests dirigidos contra unidades específicas.
  • Para código que está a punto de ser reemplazado por completo: no caracterices lo que vas a borrar.

Solution

Enfoque básico de golden master

# Python — capturar output como golden master
import json
import subprocess
import hashlib

def run_legacy_system(inputs):
    """Correr el sistema legacy con los inputs dados y capturar el output."""
    result = subprocess.run(
        ['python', 'legacy_calculator.py'],
        input=json.dumps(inputs),
        capture_output=True,
        text=True,
    )
    return result.stdout

def generate_golden_master(input_file):
    """Generar golden master desde input file."""
    with open(input_file) as f:
        inputs = json.load(f)

    outputs = []
    for input_data in inputs:
        output = run_legacy_system(input_data)
        outputs.append({
            'input': input_data,
            'output': output,
            'hash': hashlib.sha256(output.encode()).hexdigest(),
        })

    with open('golden_master.json', 'w') as f:
        json.dump(outputs, f, indent=2)

    print(f"Golden master generated with {len(outputs)} cases")

# Paso 1: Generar golden master
generate_golden_master('test_inputs.json')

Comparar contra golden master

# Python — verificar que el código refactorizado coincide con el golden master
import json
import subprocess
import hashlib
import pytest

def load_golden_master():
    with open('golden_master.json') as f:
        return json.load(f)

def run_refactored_system(input_data):
    """Correr el sistema refactorizado."""
    result = subprocess.run(
        ['python', 'refactored_calculator.py'],
        input=json.dumps(input_data),
        capture_output=True,
        text=True,
    )
    return result.stdout

@pytest.mark.parametrize("case", load_golden_master())
def test_matches_golden_master(case):
    input_data = case['input']
    expected_output = case['output']
    expected_hash = case['hash']

    actual_output = run_refactored_system(input_data)
    actual_hash = hashlib.sha256(actual_output.encode()).hexdigest()

    assert actual_hash == expected_hash, (
        f"Output mismatch for input: {input_data}\n"
        f"Expected: {expected_output[:200]}\n"
        f"Actual:   {actual_output[:200]}"
    )

Golden master con output basado en archivos

# Python — comparar output basado en archivos (ej.: reportes generados)
import os
import filecmp
import subprocess
import pytest

def generate_report(input_file, output_dir, system='legacy'):
    """Generar un reporte usando el sistema legacy o refactorizado."""
    script = f'{system}_report_generator.py'
    subprocess.run([
        'python', script,
        '--input', input_file,
        '--output', output_dir,
    ], check=True)

@pytest.fixture(scope="session")
def golden_master_reports(tmp_path_factory):
    """Generar reportes golden master una vez por sesión."""
    golden_dir = tmp_path_factory.mktemp("golden")
    test_inputs = [
        'tests/data/report_input_1.json',
        'tests/data/report_input_2.json',
        'tests/data/report_input_3.json',
    ]

    for i, input_file in enumerate(test_inputs):
        output_file = golden_dir / f'report_{i}.html'
        generate_report(input_file, str(output_file), system='legacy')

    return golden_dir, test_inputs

def test_refactored_matches_golden(golden_master_reports, tmp_path):
    golden_dir, test_inputs = golden_master_reports

    for i, input_file in enumerate(test_inputs):
        # Generar con el sistema refactorizado
        refactored_output = tmp_path / f'report_{i}.html'
        generate_report(input_file, str(refactored_output), system='refactored')

        # Comparar
        golden_file = golden_dir / f'report_{i}.html'
        assert filecmp.cmp(golden_file, refactored_output, shallow=False), (
            f"Report mismatch for {input_file}"
        )

Golden master con conjuntos grandes de inputs

# Python — generar conjuntos grandes de inputs para characterization exhaustiva
import random
import json

def generate_test_inputs(count=1000):
    """Generar inputs diversos para ejercitar el sistema legacy."""
    inputs = []

    # Casos normales
    for i in range(count // 2):
        inputs.append({
            'operation': random.choice(['add', 'subtract', 'multiply', 'divide']),
            'a': random.uniform(-1000, 1000),
            'b': random.uniform(-1000, 1000),
        })

    # Casos límite
    inputs.extend([
        {'operation': 'add', 'a': 0, 'b': 0},
        {'operation': 'divide', 'a': 1, 'b': 0},  # División por cero
        {'operation': 'multiply', 'a': -1, 'b': -1},
        {'operation': 'add', 'a': 1e10, 'b': -1e10},
        {'operation': 'subtract', 'a': 0.1, 'b': 0.3},
    ])

    # Valores de frontera
    inputs.extend([
        {'operation': 'add', 'a': float('inf'), 'b': 1},
        {'operation': 'add', 'a': float('-inf'), 'b': 1},
        {'operation': 'add', 'a': float('nan'), 'b': 1},
    ])

    with open('test_inputs.json', 'w') as f:
        json.dump(inputs, f, indent=2)

    print(f"Generated {len(inputs)} test inputs")

Enfoque de approval testing

# Python — approval testing con approvaltests library
from approvaltests import Approvals
from approvaltests.reporters import PythonNativeReporter

def test_legacy_formatter_output():
    # Correr el formatter legacy con el input de prueba
    input_data = [
        {"name": "Alice", "age": 30, "role": "admin"},
        {"name": "Bob", "age": 25, "role": "member"},
        {"name": "Charlie", "age": 35, "role": "guest"},
    ]

    output = legacy_formatter.format_table(input_data)

    # Primera ejecución: crea el archivo aprobado (golden master)
    # Ejecuciones siguientes: compara contra el archivo aprobado
    # Si hay mismatch: abre una herramienta de diff para revisión
    Approvals.verify(output, reporter=PythonNativeReporter())

def test_legacy_calculator_with_multiple_inputs():
    results = []
    for a, b, op in [(1, 2, 'add'), (10, 5, 'subtract'), (3, 4, 'multiply')]:
        result = legacy_calculator.calculate(a, b, op)
        results.append(f"{a} {op} {b} = {result}")

    output = '\n'.join(results)
    Approvals.verify(output)

JavaScript golden master

// JavaScript — golden master con Jest snapshot
const { execSync } = require('child_process');

function runSystem(input, system = 'legacy') {
  const result = execSync(`node ${system}_calculator.js`, {
    input: JSON.stringify(input),
    encoding: 'utf-8',
  });
  return result;
}

describe('Golden Master: legacy vs refactored', () => {
  const testCases = require('./test_inputs.json');

  test.each(testCases)('matches golden master for %j', (input) => {
    const legacyOutput = runSystem(input, 'legacy');
    const refactoredOutput = runSystem(input, 'refactored');

    expect(refactoredOutput).toBe(legacyOutput);
  });
});

// Alternativa: usar Jest snapshots como golden master
describe('Golden Master with snapshots', () => {
  const testCases = require('./test_inputs.json');

  test.each(testCases)('output matches snapshot for %j', (input) => {
    const output = runSystem(input, 'legacy');
    expect(output).toMatchSnapshot();
  });
});

Refactoring incremental con golden master

# Python — refactoring paso a paso con red de seguridad golden master

# Paso 1: Generar golden master (antes de cualquier cambio)
# python generate_golden_master.py

# Paso 2: Escribir characterization tests
class TestCharacterization:
    @pytest.mark.parametrize("case", load_golden_master())
    def test_legacy_behavior_preserved(self, case):
        output = run_current_system(case['input'])
        assert output == case['output']

# Paso 3: Refactorizar en pasos pequeños, corriendo tests después de cada cambio
# - Extraer método
# - Renombrar variable
# - Reemplazar condicional con polimorfismo
# - Cada paso: correr golden master tests

# Paso 4: Una vez refactorizado, escribir unit tests propios para la nueva estructura
# - Borrar golden master tests una vez que los unit tests den confianza equivalente

Manejar output no determinista

# Python — normalizar valores no deterministas antes de comparar
import re
from datetime import datetime

def normalize_output(output):
    """Remover elementos no deterministas del output."""
    # Reemplazar timestamps
    output = re.sub(
        r'\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d+Z?',
        '<TIMESTAMP>',
        output,
    )
    # Reemplazar UUIDs
    output = re.sub(
        r'[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}',
        '<UUID>',
        output,
    )
    # Reemplazar random IDs
    output = re.sub(r'"id": \d+', '"id": <ID>', output)
    # Reemplazar rutas de archivos
    output = re.sub(r'/tmp/[^\s"]+', '<TMP_PATH>', output)
    return output

def test_with_normalization():
    input_data = {'action': 'create', 'name': 'Alice'}
    legacy_output = normalize_output(run_legacy_system(input_data))
    refactored_output = normalize_output(run_refactored_system(input_data))

    assert refactored_output == legacy_output

Explanation

Piensa en el sistema legacy como una caja negra. Le das inputs, capturas lo que sale y lo guardas como línea base. Después de refactorizar, pasas los mismos inputs por el código nuevo y comparas. Si los outputs coinciden, el refactor preservó el comportamiento. Si no, encontraste una regresión antes de que llegue a producción.

flowchart diagram: Inputs de Test

Trade-offs

El tiempo de setup es el primer coste. Generar un golden master para un sistema complejo puede tomar días: necesitas identificar todos los puntos de entrada, manejar output no determinista (timestamps, UUIDs, IDs aleatorios) y generar suficientes inputs para cubrir casos límite. Para un servicio de 40k líneas, espera 2-3 días de setup antes de escribir un solo commit de refactor. La línea base también crece linealmente con la cantidad de inputs — 10.000 inputs producen un archivo JSON de 2MB. Para líneas base grandes, guarda hashes SHA-256 en lugar del output completo: una comparación de hash es O(1) mientras que la comparación de strings es O(n) en el tamaño del output.

Estos tests no son permanentes. Los golden master tests son una red de seguridad temporal. Una vez que refactorizaste el código y escribiste unit tests propios (usando el Patrón Test Pyramid como guía), el golden master pasa a ser redundante. Mantenlo solo mientras lo necesites.

Cuando el golden master tiene un bug

El golden master captura el comportamiento actual, bugs incluidos. Si descubres un bug en el código legacy, arréglalo y regenera el golden master. Documenta que el comportamiento cambió intencionalmente. Nunca actualices el golden master a ciegas: un diff puede ser una regresión, no una mejora. Revisa cada cambio con el mismo cuidado que un pull request.

Comparación con Snapshot Testing

Snapshot testing es una implementación específica de golden master testing. Golden master es el concepto; snapshots son una herramienta más. Los snapshots de Jest 29 serializan el output del componente a un archivo y comparan en cada ejecución. Otras herramientas incluyen filecmp en Python, comparación de hash SHA-256 y approval testing con la librería approvaltests de Python o ApprovalTests.Net para .NET.

Ejecución paralela en producción

# Python — correr legacy y refactored en paralelo, comparar resultados
import logging

def parallel_run(input_data):
    """Correr ambos sistemas y registrar las diferencias."""
    try:
        legacy_result = run_legacy_system(input_data)
        refactored_result = run_refactored_system(input_data)

        if legacy_result != refactored_result:
            logging.warning(
                f"Output mismatch detected!\n"
                f"Input: {input_data}\n"
                f"Legacy: {legacy_result[:200]}\n"
                f"Refactored: {refactored_result[:200]}"
            )
            return False
        return True
    except Exception as e:
        logging.error(f"Error in parallel run: {e}")
        return False

# Correr en producción con shadow traffic
# Registrar mismatches pero servir desde el sistema legacy

Golden master con estado de base de datos

# Python — capturar estado de base de datos como golden master
import json
import hashlib

def capture_db_state(db_connection):
    """Capturar el estado actual de la base de datos como hash."""
    tables = ['users', 'orders', 'products']
    state = {}

    for table in tables:
        rows = db_connection.execute(f'SELECT * FROM {table} ORDER BY id')
        state[table] = [dict(row) for row in rows]

    state_json = json.dumps(state, sort_keys=True, default=str)
    return hashlib.sha256(state_json.encode()).hexdigest()

def test_database_state_unchanged_after_refactor():
    # Setup: poblar la base de datos con datos de prueba
    setup_test_data(db)

    # Capturar el estado golden master
    golden_hash = capture_db_state(db)

    # Correr la operación refactorizada
    refactored_service.process_orders()

    # Verificar que el estado de la base de datos coincide
    current_hash = capture_db_state(db)
    assert current_hash == golden_hash, "Database state changed unexpectedly"

Golden master con API responses

// JavaScript — capturar API responses como golden master
const axios = require('axios');

async function captureApiResponses(baseUrl) {
  const endpoints = [
    { method: 'GET', path: '/api/users' },
    { method: 'GET', path: '/api/users/1' },
    { method: 'GET', path: '/api/orders' },
    { method: 'POST', path: '/api/orders', body: { userId: 1, total: 100 } },
  ];

  const responses = [];
  for (const endpoint of endpoints) {
    const res = await axios({
      method: endpoint.method,
      url: `${baseUrl}${endpoint.path}`,
      data: endpoint.body,
    });
    responses.push({
      endpoint,
      status: res.status,
      body: res.data,
    });
  }

  return responses;
}

test('refactored API matches golden master', async () => {
  const goldenMaster = require('./api_golden_master.json');
  const refactoredResponses = await captureApiResponses('http://localhost:3001');

  expect(refactoredResponses).toEqual(goldenMaster);
});

Best Practices

  • Para una guía más profunda, ver Contract Testing: Verify Consumer-Producer API Contracts.

  • Genera inputs diversos: casos normales, límite, frontera e inválidos. Usa Hypothesis 6.100+ para generación basada en propiedades cuando el espacio de inputs es grande.

  • Normaliza valores no deterministas (timestamps, UUIDs, IDs aleatorios) antes de comparar. Un solo timestamp sin normalizar causa un falso fallo que erosiona la confianza en la suite de tests.

  • Usa hashes SHA-256 para outputs mayores a 1KB. La comparación de hash es O(1) y mantiene pequeño el archivo golden master. Guarda el output completo solo para los primeros 50 casos para debugging.

  • Corre golden master tests en CI con pytest 8.3 y --json-report para obtener output legible por máquina y analizar diffs cuando dispare una regresión.

  • Mantén el golden master bajo control de versiones. Etiqueta el commit que lo generó para poder trazar qué comportamiento caracterizaste y cuándo.

  • Refactoriza en pasos pequeños (extraer método, renombrar, reemplazar condicional) y corre el golden master después de cada cambio. Si haces 5 cambios a la vez y el test falla, no sabrás cuál lo rompió.

  • Borra los golden master tests una vez que tengas unit tests propios que den confianza equivalente. El golden master es una red de seguridad, no una estrategia de testing permanente.

  • Documenta qué cubre el golden master en un archivo GOLDEN_MASTER.md: qué puntos de entrada, qué inputs, qué normalizaciones. Los devs futuros necesitan este contexto para confiar en la línea base.

Código complementario: El repo companion de golden-master-testing-pattern tiene un ejemplo ejecutable en Python con 258 tests pasando, una calculadora legacy, una versión refactorizada y el script de generación del golden master.

Common Mistakes

  • Test inputs insuficientes: generar solo happy-path inputs pierde casos límite. Incluye inputs de frontera e inválidos.
  • No normalizar el output: timestamps y valores aleatorios causan falsos fallos. Siempre normaliza antes de comparar.
  • Actualizar el golden master a ciegas: cuando el golden master cambia, revisa el diff con cuidado. Un cambio puede ser una regresión, no una mejora.
  • Mantener el golden master para siempre: los golden master tests son una red de seguridad para refactorizar. Una vez que tengas unit tests propios, bórralos.
  • Testear demasiado output: comparar páginas HTML enteras o JSON grandes hace los diffs ilegibles. Enfócate en las partes relevantes.
  • Saltarse la documentación: si no documentas qué puntos de entrada, inputs y normalizaciones cubre el golden master, el próximo dev no confiará en la línea base.

Preguntas frecuentes

¿Qué es un golden master test?

Es un test que captura el output actual de un sistema como línea base (el "golden master") y verifica que cambios futuros produzcan el mismo output. Estás caracterizando lo que hace el código, no lo que debería hacer. Michael Feathers introdujo la técnica en Working Effectively with Legacy Code (2004).

¿En qué se diferencia de snapshot testing?

Snapshot testing es una implementación específica de golden master testing. Golden master es el concepto; snapshots son una herramienta más. Los snapshots de Jest 29 serializan el output del componente a un archivo. Otras herramientas incluyen filecmp en Python, comparación de hash SHA-256 y la librería approvaltests que abre una herramienta de diff al haber mismatch.

¿Cuándo debería borrar los golden master tests?

Cuando hayas escrito unit tests propios que den confianza igual o mejor. Los golden master tests son una red de seguridad temporal para refactorizar legacy code. Si refactorizaste un servicio de 40k líneas en unidades testeables con 80%+ de cobertura, el golden master suma coste de mantenimiento sin sumar seguridad.

¿Qué pasa si el golden master tiene un bug?

El golden master captura el comportamiento actual, bugs incluidos. Si descubres un bug, arréglalo y regenera el golden master. Documenta que el comportamiento cambió intencionalmente en un CHANGELOG o mensaje de commit. Nunca actualices el golden master a ciegas: un diff puede ser una regresión, no una mejora.

¿Cuántos test inputs necesito?

Suficientes para ejercitar todos los code paths. Empieza con 100-1000 inputs cubriendo casos normales, límite y frontera. Para sistemas complejos, usa Hypothesis 6.100+ para generación basada en propiedades o pytest 8.3 @pytest.mark.parametrize con casos generados. Para un servicio de 40k líneas, 5000 inputs es un punto de partida razonable.