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.
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.
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-reportpara 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.
Recursos Relacionados
Snapshot Testing: Captura y Compara la Salida Serializada
Cómo usar el snapshot testing para detectar cambios no deseados en la salida serializada. Cubre snapshots de Jest, pytest-snapshot, syrupy y snapshots inline vs externos.
PatternPatrón Test Double: Reemplazar Dependencias con Stubs,
Cómo usar test doubles para aislar unidades bajo test. Cubre stubs, spies, fakes, mocks y dummy objects con ejemplos en Python, JavaScript y Java.
PatternPirámide de Tests: Unit, Integration y E2E
Cómo estructurar una suite de tests con la pirámide de testing. Cubre las proporciones de tests unitarios, de integración y E2E, el testing trophy y el anti-patrón ice cream cone.