Cómo modificar con seguridad una aplicación de negocio legada sin pruebas — la práctica de las pruebas de caracterización y la refactorización
· Actualizado el: · Go Komura · Tecnología legada, Aprovechamiento de activos existentes, Refactorización, Diseño de pruebas, Pruebas de caracterización, C#, .NET, Mantenimiento, Tabla de decisión, Consultoría técnica
«Sé exactamente qué parte quiero corregir. Pero si toco algo y rompo otra cosa, no puedo ni empezar» — es una frase que escucho a menudo de quienes heredan una aplicación de negocio sin pruebas automatizadas.
Muchas aplicaciones de negocio escritas en VB6, .NET Framework o Access carecen de pruebas automatizadas. La documentación de especificaciones tampoco se actualiza, de modo que «el código es la única especificación». Aun así, el negocio sigue funcionando, y solicitudes de modificación como cambios en la tasa del impuesto al consumo, ajustes en el diseño de los informes o la incorporación de nuevos clientes no esperan.
En este blog presentamos en «¿Hasta cuándo funcionará una aplicación VB6? — El estado del soporte del runtime y cómo avanzar de forma realista hacia .NET» la idea de avanzar en una migración tratando el sistema antiguo como una «especificación viva», cotejando sus salidas. Este artículo aplica esa misma idea a un escenario distinto: no una migración, sino modificar directamente el código que está funcionando ahora mismo. La herramienta central es la prueba de caracterización (characterization test). Aunque el código no tenga pruebas, si primero se fija con una prueba el comportamiento que se está a punto de tocar, tanto la refactorización como la adición de funcionalidades resultan mucho más seguras.
El lector al que se dirige este artículo y los requisitos previos son los siguientes. No hace falta experiencia con pruebas automatizadas: está pensado para empezar aunque no haya escrito ni una sola prueba. Los únicos dos requisitos previos son (1) poder compilar usted mismo la aplicación en cuestión (disponer del código fuente y de un entorno de desarrollo que compile correctamente) y (2) estar en una posición desde la que se pueda modificar el código fuente. Los ejemplos de código se muestran en C# (con una sintaxis válida tanto para .NET Framework como para .NET), pero la idea es independiente del lenguaje. Por el contrario, si no dispone del código fuente o no puede compilarlo, las técnicas de este artículo no son aplicables tal cual, y primero necesitará resolver ese paso previo.
1. Antes que nada, la conclusión
- No corrija el código de inmediato. Primero fije el comportamiento actual con una prueba. Aunque no haya documentación de especificaciones, la salida del código que funciona ahora mismo es, de hecho, la especificación.
- La herramienta para lograrlo es la prueba de caracterización. Es una prueba que registra el «comportamiento actual», no el «comportamiento correcto»: guarda tal cual, como valor esperado, salidas como informes, archivos CSV o resultados de cálculo, y compara las diferencias antes y después de cada cambio (método golden master).
- En estructuras donde no se puede insertar una prueba (lógica escrita directamente en un controlador de eventos de la interfaz, referencias directas a
DateTime.Nowo a rutas de archivo), se crea una «continuidad» (seam) mediante el cambio mínimo que suponen la extracción de métodos y la inserción de interfaces. No hace falta una reestructuración a gran escala. - No mezcle refactorización y adición de funcionalidades en el mismo commit. El criterio de aceptación de la refactorización es «diferencia cero», mientras que el de la adición de funcionalidades es «solo la diferencia prevista»; si se mezclan, deja de poder determinarse qué significa cada diferencia.
- Hasta dónde llegar con las pruebas se decide en función de el alcance de la modificación × los años de vida útil restantes del sistema × el impacto de un fallo. Cubrir todo con pruebas unitarias no siempre es la respuesta correcta; en algunos casos lo acertado es «solo pruebas de caracterización» o incluso «no tocarlo».
- Puede empezar aunque no tenga integración continua (CI). Con un único proyecto de pruebas y una carpeta de archivos de valores esperados, simplemente ejecutarlas de forma local ya mejora enormemente la seguridad.
2. Por qué el código legado «se rompe al tocarlo»
Modificar código legado da miedo, y no porque el código sea antiguo, sino porque no existe forma de comprobar si el resultado del cambio es correcto.
Michael Feathers, en su libro Working Effectively with Legacy Code (traducido al español como Trabajando efectivamente con código legado), define el código legado no como «simplemente código antiguo», sino como «código sin pruebas».1 El motivo es que, sin pruebas, no hay forma rápida de comprobar en cada cambio si el código está mejorando o empeorando. Según esta definición, incluso código escrito ayer es código legado si no tiene pruebas.
En el código sin pruebas se pone en marcha el siguiente círculo vicioso.
- Al no haber pruebas, no se conoce el alcance del impacto de un cambio, y eso da miedo.
- Por miedo, en lugar de corregir la estructura existente, se recurre a copiar y pegar lo mínimo y añadir condicionales.
- Los parches improvisados se acumulan y el código se vuelve aún más difícil de leer y más frágil.
- Al volverse más frágil, da todavía más miedo (se vuelve al punto 1).
La puerta de salida de este círculo vicioso no es «armarse de valor y hacer una gran refactorización». El orden es el inverso: primero se tiende la red de seguridad (las pruebas), se elimina la causa del miedo y solo entonces se corrige. Sin embargo, aquí aparece un problema del huevo y la gallina. Para escribir pruebas hace falta una estructura que se pueda probar. Pero para lograr una estructura que se pueda probar hay que modificar el código (refactorizar). Eso termina significando modificar sin pruebas un código que no tiene pruebas.
Para resolver esta contradicción, la modificación de código legado avanza en el siguiente orden.1
- Fijar desde fuera el comportamiento actual, únicamente en torno al punto que se va a modificar (prueba de caracterización).
- Dentro de esa red de seguridad, aplicar un cambio mínimo con un riesgo de rotura extremadamente bajo (como la extracción de métodos) para crear un punto donde insertar pruebas.
- Una vez que la estructura permite escribir pruebas detalladas, abordar el cambio que en realidad se quería hacer (refactorización o adición de funcionalidades).
En los siguientes apartados se examinan en detalle los puntos 1 y 2.
3. Pruebas de caracterización: registrar el «comportamiento actual»
3.1 En qué se diferencian de las pruebas habituales
Las pruebas habituales verifican el comportamiento correcto, es decir, «cómo debería comportarse según la especificación». Las pruebas de caracterización son distintas: registran cómo se comporta realmente el código actual, dejando en suspenso el juicio sobre si eso es correcto o no.
Supongamos, por ejemplo, que la especificación no dice si el redondeo de decimales se hace por redondeo normal o por truncamiento. Si el código actual trunca y el negocio ha funcionado así durante diez años, «trunca» es, de hecho, la especificación vigente, al menos en la práctica. La prueba de caracterización fija esto tal cual, en la forma «la salida actual es tal cosa». Aunque resulte ser un error, primero se fija. Cambiar el comportamiento (corregir el error) es un trabajo aparte que se hace después, una vez que existe la red de seguridad, como un cambio deliberado.
3.2 Procedimiento del método golden master
Para código legado cuya unidad de salida es grande, el método golden master es la prueba de caracterización con mejor relación costo-beneficio. El procedimiento es sencillo.
- Identificar la salida que genera la función que se va a modificar (texto de un informe, un CSV, una lista de resultados de cálculo, etc.).
- Preparar datos de entrada representativos, ejecutar el código actual y obtener la salida.
- Guardar esa salida tal cual como archivo de valor esperado (el golden master) e incorporarlo al repositorio.
- A partir de ahí, ejecutar la prueba cada vez que se modifique el código y comprobar que la diferencia entre la salida y el archivo de valor esperado es cero.
En C#, basta con una implementación tan sencilla como la siguiente, sin depender de ninguna biblioteca en particular.
[Fact]
public void 月次請求一覧_ゴールデンマスター()
{
// 1. Leer entradas representativas (por ejemplo, datos extraídos de producción y enmascarados)
var input = File.ReadAllLines(TestDataPath("billing-input-202606.csv"));
// 2. Invocar la lógica existente tal cual y obtener la cadena de salida
string actual = BillingReport.Generate(input);
// 3. Si no existe el archivo de valor esperado, es porque "el entorno de pruebas está roto"
// o porque es la "primera vez". En cualquier caso, no dejar pasar la prueba en silencio:
// dejar constancia y forzar siempre un fallo
string expectedPath = TestDataPath("billing-expected-202606.txt");
if (!File.Exists(expectedPath))
{
File.WriteAllText(expectedPath + ".candidate", actual);
Assert.Fail("No existe el archivo de valor esperado. Revise el contenido del .candidate y, " +
"si no hay problema, haga commit como valor esperado.");
}
// 4. Verificar que coincide exactamente con el comportamiento guardado
string expected = File.ReadAllText(expectedPath);
Assert.Equal(expected, actual);
}
Evite implementaciones que, cuando no encuentran el archivo de valor esperado, guarden la salida actual tal cual como valor esperado y dejen pasar la prueba. Si hay un olvido al hacer commit del valor esperado o un error al colocarlo en su sitio, la CI pasará en verde sin detectar la regresión. El registro inicial debe, como en el ejemplo anterior, generar un archivo candidato (.candidate) y forzar un fallo explícito, para que sea una persona quien lo revise antes de hacer commit como valor esperado, en un flujo de un solo sentido.
Como el mensaje de Assert.Equal por sí solo es difícil de seguir cuando aparece una diferencia, en la práctica conviene volcar la salida real, cuando la prueba falla, a otro archivo como billing-actual-202606.txt, para poder compararlo con el valor esperado usando una herramienta de diferencias como WinMerge; así la investigación es más rápida.
Cabe señalar que este ejemplo de código da por sentado que «se puede invocar tal cual, desde el proyecto de pruebas, la lógica existente BillingReport.Generate». Este suele ser el primer obstáculo en un entorno legado, así que la forma de conectarlo se explica en el apartado 3.4.
Esta técnica recibe distintos nombres según la fuente: además de prueba golden master, también se la llama prueba de aprobación (approval testing) y prueba de instantánea (snapshot testing). Existen bibliotecas que empaquetan la misma idea; en .NET, las más representativas son ApprovalTests.Net y Verify. Se encargan de partes que en el código anterior se implementaron a mano, como la convención de nombres de los archivos de valor esperado, el lanzamiento automático de una herramienta de diferencias o la operación de aprobar un valor esperado. Basta con empezar con una implementación sencilla como la de arriba y considerar adoptar una biblioteca cuando el número de archivos de valor esperado crezca y su gestión se vuelva incómoda. Al buscar información, «approval testing» o «snapshot testing» suelen dar mejores resultados que «golden master».
3.3 Cómo elegir las entradas y normalizar las salidas
Las entradas se eligen bajo el criterio «representativas + límites». Tome uno o dos casos normales y añada entradas que recorran las ramas que haya encontrado al leer el código: cierre de fin de mes, cero registros, valores negativos, el tratamiento excepcional de un cliente concreto, etc. Si puede usar datos de producción enmascarados, esos son los que mejor recorren las ramas reales.
Los valores no deterministas mezclados en la salida se normalizan antes de compararlos. La fecha y hora de impresión, el tiempo de procesamiento, los GUID o la numeración automática cambian en cada ejecución, así que tal cual producirían una diferencia cada vez. Después de generar la salida, aplique un preprocesamiento — por ejemplo, sustituir con una expresión regular «Fecha de impresión: 2026/07/17 16:00» por «Fecha de impresión:
A continuación se resume una guía orientativa sobre qué tipos de salida se prestan bien al golden master.
| Tipo de salida | Idoneidad | Notas |
|---|---|---|
| CSV o archivos de ancho fijo | ◎ | Se pueden guardar y comparar tal cual. El primer objetivo a atacar |
| Informes (texto o los datos previos a la vista previa de impresión) | ◎ | Capture la cadena de texto justo antes de convertirla a PDF. Evite comparar binarios PDF |
| Listas de resultados de cálculo (importes, existencias, etc.) | ◎ | Puede añadir un método exclusivo para pruebas que vuelque los resultados a CSV u otro formato |
| Contenido escrito en la base de datos | ◯ | Ejecute un SELECT sobre la tabla después de la escritura, conviértalo a CSV y compárelo |
| La propia pantalla | △ | Aceptable si se puede reducir a texto. Automatizar la interacción con la pantalla requiere el conjunto de herramientas distinto que se trató en «Pruebas de UI automatizadas para aplicaciones de escritorio de Windows» |
| Envío a un sistema externo | △ | Se necesita una continuidad (seam, ver el próximo capítulo) que capture los datos justo antes del envío |
3.4 Cómo hacer que el proyecto de pruebas pueda invocar la aplicación existente
Las aplicaciones WinForms o WPF existentes son proyectos EXE. El primer obstáculo habitual al modificar código legado es quedarse atascado en «añadí un proyecto de pruebas, pero desde ahí no veo las clases del proyecto principal», así que a continuación se explica cómo conectarlos.
1. Añada un proyecto de pruebas. Agregue un nuevo proyecto de pruebas a la solución existente. Aunque siga en .NET Framework, puede usar MSTest, NUnit o xUnit indistintamente. Como regla general, el framework de destino del proyecto de pruebas debe coincidir con el del proyecto principal (si el proyecto principal usa .NET Framework 4.8, el proyecto de pruebas también debe usar 4.8). Si no coinciden, aparecerán advertencias o errores de carga en cuanto añada la referencia.
2. Añada una referencia al proyecto principal. Hay dos formas de conectar ambos proyectos; como norma general, debe preferirse la primera.
| Forma de conexión | Cuándo usarla | Cómo hacerlo |
|---|---|---|
| Referencia de proyecto (recomendada) | Cuando existe el código fuente del proyecto principal y se puede compilar dentro de la misma solución | Clic derecho en el proyecto de pruebas → Agregar referencia → Proyectos → seleccione el proyecto EXE principal. Un proyecto EXE también es un ensamblado y, por tanto, se puede referenciar (la idea de que «no se puede referenciar porque es un EXE» es un malentendido) |
| Referencia a un archivo DLL / EXE | Cuando no se puede incorporar el proyecto principal a la solución, o solo se dispone del binario ya compilado | Agregar referencia → Examinar → indique directamente el archivo EXE o DLL que está en la carpeta bin del proyecto principal. Tenga cuidado de que la referencia no quede desactualizada cada vez que se recompile el proyecto principal |
La dirección de la referencia es únicamente pruebas → principal, en un solo sentido. Si el proyecto principal referenciara al de pruebas, se produciría una referencia circular.
3. Si quiere probar código que sigue siendo internal, use InternalsVisibleTo. Como se verá en el capítulo 4, al extraer lógica mediante extracción de métodos, con frecuencia conviene dejarla como internal (así se puede probar sin ampliar la API pública). En ese caso, añada la siguiente línea de atributo al ensamblado del lado del proyecto principal.2
// Colóquelo en el AssemblyInfo.cs del proyecto principal, o al inicio de cualquier archivo fuente
[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("MyApp.Tests")]
Hay dos puntos a tener en cuenta.2
- Iguale el estado de firma del proyecto principal y el de pruebas. Ambos deben estar sin firmar, o ambos deben tener nombre seguro (strong name). Si el proyecto principal tiene nombre seguro, debe escribir la clave pública completa (no el token de clave pública) como en
InternalsVisibleTo("MyApp.Tests, PublicKey=0024..."). La clave pública se puede extraer consn -pysn -tp. - Los miembros
privateno se hacen visibles.InternalsVisibleTosolo afecta ainternal,protected internalyprivate protected. Si siente la necesidad de probar un métodoprivate, es una señal de que esa clase es demasiado grande, así que lo más natural es extraerlo con la técnica de extracción de métodos del capítulo 4.
4. Verifique dónde se colocan los archivos de datos. Al ejecutar las pruebas, el directorio actual es la carpeta de salida de las pruebas (bin\Debug\...). Coloque los archivos de valores esperados y los CSV de entrada en una carpeta TestData, y configure la propiedad «Copiar en el directorio de salida» como «Copiar si es más reciente»; así el TestDataPath del apartado 3.2 se puede escribir sin complicaciones. Si el proyecto principal lee un app.config u otro archivo de configuración, es posible que el proyecto de pruebas necesite una configuración equivalente.
Una vez completado esto, ya puede escribir tal cual la prueba golden master del apartado 3.2.
4. Cómo crear «continuidades (seams)» para insertar pruebas
Al intentar escribir un golden master, en muchos códigos legados se choca con un muro: la lógica está escrita directamente en un controlador de eventos de la interfaz y no se puede ejecutar sin arrancar la pantalla. Aquí es donde hace falta un punto desde el que el código de prueba pueda sustituir u observar el comportamiento: lo que Feathers llama una continuidad (seam).1
4.1 Separar la lógica de la interfaz con extracción de métodos
Un caso típico de «antes» es este: el cálculo, el acceso a la base de datos, la dependencia de la hora actual y la actualización de la pantalla conviven todos en un mismo controlador de eventos.
// Antes: todo escrito directamente en el controlador de eventos
private void btnCalc_Click(object sender, EventArgs e)
{
var rows = LoadRowsFromDb(); // Acceso directo a la base de datos
var now = DateTime.Now; // Depende de la hora actual
decimal total = 0;
foreach (var row in rows)
{
if (row.SalesDate.Year == now.Year &&
row.SalesDate.Month == now.Month) // Solo se suma el mes en curso
{
total += Math.Floor(row.Amount * 1.1m); // Regla de negocio: redondeo por truncamiento
}
}
lblTotal.Text = total.ToString("N0"); // Se refleja directamente en la pantalla
}
Tal como está, para probar la lógica de suma del mes en curso hacen falta la pantalla, la base de datos y «la fecha de hoy». La técnica estándar para hacerlo comprobable con el cambio mínimo es extraer solo la parte de cálculo a un método y convertir las dependencias externas (el resultado de la base de datos y la hora actual) en parámetros. Usar la refactorización de extracción de métodos de Visual Studio (Ctrl+R, M) también reduce los errores de reescritura manual.3
// Después: se extrae solo el cálculo y se reciben como parámetros "el resultado de la base de datos" y "la hora actual"
internal static decimal CalcMonthlyTotal(IEnumerable<SalesRow> rows, DateTime now)
{
decimal total = 0;
foreach (var row in rows)
{
if (row.SalesDate.Year == now.Year &&
row.SalesDate.Month == now.Month)
{
total += Math.Floor(row.Amount * 1.1m);
}
}
return total;
}
private void btnCalc_Click(object sender, EventArgs e)
{
var rows = LoadRowsFromDb();
lblTotal.Text = CalcMonthlyTotal(rows, DateTime.Now).ToString("N0");
}
El controlador de eventos queda reducido a tres líneas: «leer → calcular → mostrar», y el método extraído se puede probar con cualquier conjunto de filas y cualquier fecha. Incluso los casos límite relacionados con el tiempo, como el fin o el inicio de mes o un año bisiesto, se reproducen simplemente pasando una fecha como new DateTime(2028, 2, 29).
4.2 Hacer intercambiables las dependencias mediante inserción de interfaces
Para dependencias cuya magnitud excede lo que se puede resolver simplemente parametrizando (por ejemplo, referencias a DateTime.Now repartidas por todo el código, o rutas de archivo escritas directamente), la dependencia se envuelve en una interfaz y se inserta de ese modo. Las prácticas recomendadas de pruebas unitarias de .NET en Microsoft Learn también señalan la dependencia directa de DateTime.Now como un ejemplo típico de algo que no se puede controlar desde una prueba, y presentan el método de envolverlo en una interfaz para introducir una continuidad (seam).4
public interface IClock
{
DateTime Now { get; }
}
public sealed class SystemClock : IClock
{
public DateTime Now => DateTime.Now;
}
// En las pruebas se inserta una implementación que devuelve una hora fija
public sealed class FixedClock : IClock
{
private readonly DateTime _fixed;
public FixedClock(DateTime value) => _fixed = value;
public DateTime Now => _fixed;
}
Añadir IClock al constructor de una clase existente obliga a corregir todos los puntos donde se la invoca, así que durante el período de transición es más realista mantener también un constructor con un valor por defecto («el constructor sin argumentos usa SystemClock») e ir corrigiendo los puntos de llamada de forma gradual. Las rutas de archivo o las cadenas de conexión a la base de datos escritas directamente se tratan de la misma manera: se envuelven en una interfaz pequeña que solo expone las operaciones de «leer» y «escribir».
Cabe señalar que Visual Studio incorpora una refactorización para extraer una interfaz de una clase existente (Extract Interface), con la que este tipo de cambio se puede hacer de forma mecánica.3
Al crear continuidades hay un único principio que respetar: el cambio que crea la continuidad no debe alterar el comportamiento ni un milímetro. Tanto la extracción de métodos como la inserción de interfaces son operaciones que preservan muy bien el comportamiento y que se pueden realizar de forma mecánica con la ayuda del compilador y el IDE. En esta etapa surge la tentación de corregir la lógica «de paso», pero eso es trabajo para después de tender la red de seguridad.
5. Tabla de decisión: hasta dónde llegar
Las pruebas de caracterización y la creación de continuidades también consumen esfuerzo. Mantener el mismo nivel de pruebas en todo el código legado no es realista en un proyecto de tamaño pequeño o mediano, ni tampoco es necesario. Hay tres ejes de decisión.
- El alcance de la modificación: si es la corrección de un error de pocas líneas, una adición de funcionalidad o si implica un cambio estructural.
- Los años de vida útil restantes del sistema: si está previsto migrarlo o retirarlo en uno o dos años, o si se seguirá usando durante cinco años o más.
- El impacto en caso de fallo: si se limita a que un informe se vea mal, o si puede llevar a errores en el importe facturado o en las existencias.
| Alcance de la modificación | Años restantes | Impacto en caso de fallo | Nivel recomendado |
|---|---|---|---|
| Leve (pocas líneas, cambio de valores de configuración) | Corto (hasta 2 años) | Pequeño (problemas de visualización) | Solo pruebas de caracterización. Fijar la salida correspondiente, aplicar el cambio y confirmar diferencias; con eso basta |
| Leve a medio | Corto | Grande (afecta a importes o existencias) | Solo pruebas de caracterización, pero reforzadas: ampliar los patrones de entrada, incluyendo los casos límite |
| Medio (adición de funcionalidad, cambio de lógica) | Largo (5 años o más) | Pequeño a medio | Pruebas de caracterización + pruebas unitarias solo alrededor del punto modificado (crear continuidades) |
| Medio a grande | Largo | Grande | Pruebas de caracterización + pruebas unitarias + dividir las versiones en unidades más pequeñas |
| Grande (requiere renovar la estructura) | Corto | ─ | No tocar. No modificar, sortear el problema operativamente y dedicar el esfuerzo a la migración o la sustitución |
| ─ (no hay solicitud de modificación) | ─ | ─ | No tocar. No refactorizar de forma preventiva código que ya funciona |
El «no tocar» de las dos últimas filas no es una opción pasiva, sino una decisión activa. Invertir en la calidad interna de un sistema con pocos años de vida útil restantes no se recupera. Ese esfuerzo debería dedicarse, en cambio, a la decisión de migración y al diseño del sistema de destino que se organizó en «Tabla de decisión para prolongar la vida o migrar aplicaciones de negocio en VB6 / Access».
Además, cuando se llega hasta el punto de «preparar pruebas unitarias», también hace falta trazar la línea entre qué se escribe como prueba unitaria y qué se deja como prueba de integración (pruebas que usan una base de datos o archivos reales). Esa línea divisoria se organiza como una tabla de decisión en «Cómo trazar el límite entre pruebas unitarias y pruebas de integración»; consúltelo también. Dado que una prueba unitaria debe ser fast / isolated / repeatable (rápida, aislada y repetible),4 lo más prudente es mantener las pruebas de caracterización que tocan la base de datos o archivos en un proyecto y una unidad de ejecución separados de las pruebas unitarias.
6. Reglas operativas — para no romper la red de seguridad
Una prueba de caracterización pierde fácilmente su sentido si se gestiona mal después de escribirla. A continuación, las tres reglas mínimas.
6.1 No mezclar refactorización y adición de funcionalidad en el mismo commit
La refactorización es un cambio que hace el código más fácil de entender y de mantener sin alterar su comportamiento.5 En otras palabras, su criterio de aceptación es diferencia cero frente al golden master. La adición de funcionalidad y la corrección de errores, en cambio, tienen como criterio de aceptación que aparezca únicamente la diferencia prevista. Si se mezclan ambas cosas en un solo commit, cuando aparece una diferencia deja de poder determinarse si es «el cambio deseado» o «algo que se rompió».
| Tipo de cambio | Tratamiento del golden master | Criterio de aceptación |
|---|---|---|
| Refactorización (cambio estructural) | No se actualiza | Diferencia cero |
| Corrección de errores / adición de funcionalidad (cambio de comportamiento) | Se actualiza tras la revisión de diferencias | Solo la diferencia prevista |
| Creación de continuidades (extracción de métodos, inserción de interfaces) | No se actualiza | Diferencia cero |
| Cambio en las reglas de normalización de los valores esperados | Se regenera | El motivo del cambio debe constar explícitamente en el mensaje del commit |
Lo mismo se aplica a nivel de versión. En una «versión que solo contiene refactorización» el comportamiento no debería cambiar, así que si surge un incidente se puede sospechar de inmediato de la refactorización. Si se mezclan ambos tipos de cambio, esta forma de acotar el problema deja de funcionar.
6.2 Actualizar los valores esperados en el orden «revisión de diferencias → sobrescritura»
Cuando se cambia el comportamiento de forma deliberada, también hay que actualizar el golden master. Fije el procedimiento de la siguiente manera.
- Generar la salida tras el cambio y revisar visualmente la diferencia con el valor esperado actual.
- Confirmar que la diferencia contiene únicamente el cambio previsto (si una sola línea no prevista ha cambiado, investigarla).
- Sobrescribir el archivo de valor esperado con la nueva salida e incluirlo en el mismo commit que el código, dejando constancia en el historial.
Lo peligroso es la práctica de «como la prueba se puso en rojo, sobrescribo el valor esperado para que vuelva a estar en verde». Si se hace esto, una regresión queda registrada tal cual como algo «correcto», y la red de seguridad deja de serlo.
La forma más rápida de aprender a juzgar esto es viendo un diferencial real, así que pongamos un ejemplo. Supongamos que se hizo la modificación «añadir el tipo reducido del 8 % a los productos que hasta ahora tenían el 10 % del impuesto al consumo» y que la diferencia con el valor esperado resulta así.
2026/06/30,Comercial A,Artículos de oficina, 10000, 1000, 11000
- 2026/06/30,Comercial A,Bebidas (reducido), 5000, 500, 5500
+ 2026/06/30,Comercial A,Bebidas (reducido), 5000, 400, 5400
2026/06/30,Comercial A,Subtotal, 15000, 1500, 16500
- 2026/06/30,Industrial B,Piezas de maquinaria, 200000, 20000, 220000
+ 2026/06/30,Industrial B,Piezas de maquinaria, 200000, 20001, 220001
Las dos primeras líneas (donde el importe del impuesto de la fila con tipo reducido cambia de 500 a 400) son el cambio previsto: es precisamente el objetivo de la modificación, así que se puede actualizar el valor esperado sin problema. Pero las dos últimas líneas, donde el impuesto de Industrial B —que no debería tener nada que ver con el tipo reducido— se desvía en 1 yen, es una regresión. Probablemente se tocó por error alguna función común de redondeo. Si en este punto se sobrescribe el valor esperado pensando «bueno, es solo 1 yen», a partir de entonces esa desviación de 1 yen quedará fijada como el «comportamiento correcto».
La regla operativa se resume en una frase: si no puede explicar «por qué cambió esta línea» para cada línea del diferencial, no debe actualizar el valor esperado. Si hay una sola línea que no puede explicar, investigue hasta encontrar la causa. Por cierto, en el ejemplo anterior también se puede notar que la fila del subtotal no se actualizó (si cambia la fila del tipo reducido, el subtotal también debería cambiar). Las líneas que deberían haber cambiado y no lo hicieron también son algo que la revisión de diferencias debe detectar.
6.3 Crear una configuración mínima que funcione en local, incluso sin CI
Incluso en un proyecto sin servidor de integración continua (CI), se puede empezar hoy mismo con la siguiente configuración mínima.
- Añadir un proyecto de pruebas a la solución (funciona con MSTest, NUnit o xUnit incluso en .NET Framework; la forma de conectarlo está en el apartado 3.4).
- Colocar los archivos de valores esperados y los datos de entrada en una carpeta
TestDatay someterlos a control de versiones junto con el código. - Hacer que ejecutar las pruebas manualmente antes de cada commit sea un compromiso del equipo.
- Añadir una línea en el procedimiento de publicación —«ejecutar las pruebas y confirmar diferencia cero»— para no olvidar comprobar el resultado.
El medio de ejecución no se limita a dotnet test. En soluciones donde se mezclan archivos csproj en el formato antiguo (no estilo SDK), dotnet test puede no funcionar como se espera. En ese caso hay dos alternativas.
- El Explorador de pruebas de Visual Studio. Al compilar, las pruebas se detectan automáticamente y se pueden ejecutar desde la interfaz gráfica. Para los miembros del equipo sin experiencia en pruebas, esta opción es más fácil de adoptar.
vstest.console.exe. Es una herramienta de línea de comandos que ejecuta directamente el DLL de pruebas ya compilado, y se puede usar desde el Developer Command Prompt.6
vstest.console.exe MyApp.Tests\bin\Debug\MyApp.Tests.dll /logger:trx
Puede elegir cualquiera de las opciones según su entorno. Lo importante es el compromiso de «ejecutarlas siempre al menos una vez antes de hacer commit».
Al cotejar los resultados de las pruebas con la salida de la aplicación, cuanto mejor organizados estén los registros (logs), más rápida será la investigación de la causa. Qué se debe dejar registrado se trata en «Requisitos mínimos de un logger propio y lista de verificación de pruebas de integración».
7. Resumen
- El código legado es «código sin pruebas»,1 y la verdadera razón por la que se rompe al tocarlo es que no existe forma de comprobar el resultado del cambio. Antes de corregirlo, fije el comportamiento actual con una prueba.
- La prueba de caracterización registra el «comportamiento actual», no el «comportamiento correcto». Con el método golden master (prueba de aprobación / prueba de instantánea), que guarda tal cual informes, CSV o resultados de cálculo en un archivo de valor esperado y compara las diferencias, se puede empezar con código C# sencillo, sin más.
- El primer obstáculo es lograr que el proyecto de pruebas pueda invocar el código del EXE existente. Un proyecto EXE también admite referencias de proyecto, y si quiere seguir probando código
internal, basta con añadir una línea deInternalsVisibleToen el proyecto principal (apartado 3.4).2 - En estructuras donde no se puede insertar una prueba, cree una continuidad (seam) mediante extracción de métodos e inserción de interfaces. La técnica de envolver dependencias como
DateTime.Nowes una práctica estándar recogida también en las directrices de pruebas unitarias de Microsoft.43 - Hasta dónde llegar se decide en función de el alcance de la modificación × los años de vida útil restantes × el impacto en caso de fallo. «Solo pruebas de caracterización» o «no tocar» son también decisiones perfectamente válidas.
- En la operación diaria, respete que la refactorización (aprobada con diferencia cero) y la adición de funcionalidad (aprobada solo con la diferencia prevista) no se mezclen,5 y que la actualización de los valores esperados pase siempre por una revisión de diferencias. Incluso sin CI, el simple compromiso de ejecutar las pruebas en local ya mejora enormemente la seguridad.
Artículos relacionados
- Cómo trazar el límite entre pruebas unitarias y pruebas de integración
- ¿Hasta cuándo funcionará una aplicación VB6? — El estado del soporte del runtime y cómo avanzar de forma realista hacia .NET
- Prolongar la vida o migrar aplicaciones de negocio en VB6 / Access — tabla de decisión entre mantener, encapsular y sustituir
- Requisitos mínimos de un logger propio y lista de verificación de pruebas de integración
- Pruebas de UI automatizadas para aplicaciones de escritorio de Windows
Áreas de consultoría relacionadas
KomuraSoft LLC se ocupa de introducir pruebas de caracterización en aplicaciones de negocio existentes que no tienen pruebas, de la refactorización gradual hacia estructuras comprobables, y de ayudar a decidir si conviene invertir en la modificación o en la migración.
- Modificación y mantenimiento de software Windows existente
- Aprovechamiento de activos existentes y soporte a la migración
- Consultoría técnica y revisión de diseño
- Contacto
Referencias
-
Michael C. Feathers, Working Effectively with Legacy Code (Prentice Hall, 2004). Sobre la definición del código legado como «código sin pruebas», el procedimiento de registrar el comportamiento actual mediante pruebas de caracterización (characterization test) antes de abordar un cambio, y el concepto de continuidad (seam) para insertar pruebas. ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, InternalsVisibleToAttribute Class. Sobre este atributo, que hace visibles desde un ensamblado amigo especificado los tipos y miembros que normalmente solo son visibles dentro del mismo ensamblado; sobre que afecta a
internal/protected internal/private protected, sin incluirprivate; sobre que el ensamblado actual y el ensamblado amigo deben estar ambos sin firmar o ambos con nombre seguro; y sobre que, si tienen nombre seguro, hay que especificar la clave pública completa —no el token de clave pública—, la cual se puede obtener consn -pysn -tp. ↩ ↩2 ↩3 -
Microsoft Learn, Extract and inline refactorings (Visual Studio). Sobre el procedimiento de las refactorizaciones de extracción de métodos (Ctrl+R, M) y de extracción de interfaces (Extract Interface) para C# / Visual Basic en Visual Studio. ↩ ↩2 ↩3
-
Microsoft Learn, Unit testing best practices for .NET. Sobre las propiedades de una buena prueba unitaria (fast / isolated / repeatable / self-checking / timely — rápida, aislada, repetible, autoverificable y oportuna), la técnica de envolver en una interfaz dependencias incontrolables como
DateTime.Nowpara introducir una continuidad (seam), y la conveniencia de no incorporar dependencias de infraestructura en las pruebas unitarias, dejándolas para las pruebas de integración. ↩ ↩2 ↩3 -
Microsoft Learn, Refactor code (Visual Studio). Sobre la definición de la refactorización como el proceso de modificar el código para que sea más fácil de mantener, comprender y ampliar, sin alterar su comportamiento. ↩ ↩2
-
Microsoft Learn, VSTest.Console.exe command-line options. Sobre VSTest.Console.exe como herramienta de línea de comandos para ejecutar pruebas, la posibilidad de ejecutar especificando directamente el archivo de pruebas (DLL), y su disponibilidad desde el Developer Command Prompt. ↩
Artículos relacionados
Artículos recientes con las mismas etiquetas para profundizar en temas cercanos.
Compatibilidad retroactiva de interfaces DLL y COM — Tabla de decisión sobre qué cambios rompen al lado que llama
Qué cambios de DLL o COM rompen al lado que llama: los tres niveles de compatibilidad, la tabla de decisión por cambio y la regla de inmu...
Cómo versionar el esquema de la base de datos de una aplicación empresarial — Migraciones que evitan que «cada cliente tenga una base de datos distinta»
Guía práctica para versionar el esquema de bases de datos de aplicaciones empresariales dispersas entre clientes: PRAGMA user_version, mi...
¿Hasta cuándo seguirán funcionando las aplicaciones VB6? — Estado del soporte del runtime y un camino práctico hacia la migración a .NET
¿Hasta cuándo seguirán funcionando las aplicaciones VB6? Este artículo organiza la situación actual: el runtime sigue dentro del soporte ...
Cómo elegir la comunicación entre procesos en Windows — canalizaciones con nombre / TCP / gRPC / memoria compartida / COM: tabla de decisión
Cómo elegir la comunicación entre procesos en Windows: comparamos en una tabla de decisión las canalizaciones con nombre, el TCP local, g...
Cómo crear y operar un servicio de Windows — de la diferencia con el Programador de tareas a la creación de servicios con BackgroundService
¿Un proceso en segundo plano debe ser servicio de Windows o basta con el Programador de tareas? Tabla de decisión, creación con .NET Work...
Temas relacionados
Estas páginas sitúan el tema en un contexto más amplio de servicios y decisiones.
Temas técnicos de Windows
Portal sobre desarrollo de Windows, investigación de fallos y aprovechamiento de activos existentes.
Servicios relacionados con este tema
El artículo está directamente relacionado con los siguientes servicios.
Desarrollo de aplicaciones para Windows
Aplicaciones empresariales, integración de dispositivos y herramientas de comunicación, de los requisitos al desarrollo.
Preguntas frecuentes
Preguntas habituales en las consultas sobre el tema del artículo.
- ¿Qué es una prueba de caracterización (characterization test)?
- Es una prueba que registra tal cual el «comportamiento actual», no el «comportamiento correcto». En código legado del que no queda documentación de especificaciones, a menudo no hay forma de comprobar qué es lo correcto, así que primero se guarda como valor esperado la salida del código que funciona ahora mismo (informes, CSV, resultados de cálculo, etc.) y se comprueba de forma mecánica que la salida no cambia antes y después de una modificación. El uso básico consiste en tender esta red de seguridad que fija el comportamiento y, a partir de ahí, avanzar hacia la refactorización o la adición de funcionalidades.
- ¿Por dónde se debe empezar con código legado que no tiene ninguna prueba?
- Lo más realista es escribir pruebas de caracterización limitadas únicamente al entorno del punto que se va a modificar. Cubrir todo el sistema con pruebas casi nunca es viable en términos de esfuerzo, y tampoco es necesario. Primero identifique la salida que genera la función que se va a modificar (informes, CSV, contenido escrito en la base de datos, etc.) y fije en un archivo la salida obtenida con entradas representativas. Dentro de esa red de seguridad, aplique una refactorización pequeña como la extracción de métodos para separar la lógica en una forma comprobable, y solo entonces aborde el cambio que realmente quería hacer.
- ¿Por qué no se debe mezclar la refactorización y la adición de funcionalidades en el mismo commit?
- Porque, cuando aparece una diferencia en la salida, deja de poder aislarse la causa. La refactorización es un trabajo que verifica que «el comportamiento no cambia», y la adición de funcionalidad verifica que «el comportamiento solo cambia en el punto previsto»; sus criterios de aceptación son opuestos. Si se mezclan, no se puede determinar si una diferencia frente al golden master es «un cambio deseado» o «algo que se rompió». Es más seguro comprobar por separado: diferencia cero en los commits de refactorización, y solo la diferencia prevista en los commits de adición de funcionalidad.
- ¿Cuándo se debe actualizar el golden master (el archivo de valores esperados)?
- Únicamente cuando se cambia el comportamiento de forma deliberada, es decir, en el momento del commit de una adición de funcionalidad o de una corrección de errores. Al actualizarlo, revise visualmente la diferencia de salida entre antes y después del cambio, confirme que solo contiene el cambio previsto y solo entonces reemplace el valor esperado con la nueva salida. Sobrescribir el valor esperado de forma mecánica solo porque la prueba se puso en rojo incorpora la regresión (un cambio de comportamiento no previsto) tal cual como algo «correcto», y la red de seguridad deja de tener sentido.
Perfil del autor
Página de presentación del autor del artículo.
Go Komura
Representante de KomuraSoft LLC
Especializado en desarrollo de software para Windows, consultoría técnica e investigación de fallos, sobre todo en proyectos con sistemas existentes y errores difíciles de reproducir.