Pruebas de unidades frágiles debido a la necesidad de una burla excesiva

21

He estado luchando con un problema cada vez más molesto con respecto a nuestras pruebas unitarias que estamos implementando en mi equipo. Estamos intentando agregar pruebas unitarias en el código heredado que no fue bien diseñado y, aunque no hemos tenido ninguna dificultad con la adición real de las pruebas, estamos empezando a tener dificultades con la forma en que se están desarrollando las pruebas.

Como ejemplo del problema, supongamos que tiene un método que llama a otros 5 métodos como parte de su ejecución. Una prueba para este método podría ser confirmar que se produce un comportamiento como resultado de uno de estos otros 5 métodos llamados. Entonces, debido a que una prueba unitaria debe fallar por una razón y solo por una, desea eliminar los problemas potenciales causados ​​al llamar a estos otros 4 métodos y simularlos. ¡Excelente! La prueba unitaria se ejecuta, los métodos simulados se ignoran (y su comportamiento puede confirmarse como parte de otras pruebas unitarias) y la verificación funciona.

Pero hay un nuevo problema: la prueba unitaria tiene un conocimiento íntimo de cómo confirmó que el comportamiento y cualquier cambio de firma a cualquiera de esos otros 4 métodos en el futuro, o cualquier método nuevo que deba agregarse al 'método principal', resultar en tener que cambiar la prueba de la unidad para evitar posibles fallas.

Naturalmente, el problema podría mitigarse un poco simplemente haciendo que más métodos logren menos comportamientos, pero esperaba que tal vez hubiera una solución más elegante disponible.

Aquí hay una prueba de unidad de ejemplo que captura el problema.

Como nota rápida, 'MergeTests' es una clase de prueba unitaria que hereda de la clase que estamos probando y anula el comportamiento según sea necesario. Este es un "patrón" que empleamos en nuestras pruebas para permitirnos anular llamadas a clases / dependencias externas.

[TestMethod]
public void VerifyMergeStopsSpinner()
{
    var mockViewModel = new Mock<MergeTests> { CallBase = true };
    var mockMergeInfo = new MergeInfo(Mock.Of<IClaim>(), Mock.Of<IClaim>(), It.IsAny<bool>());

    mockViewModel.Setup(m => m.ClaimView).Returns(Mock.Of<IClaimView>);
    mockViewModel.Setup(
        m =>
        m.TryMergeClaims(It.IsAny<Func<bool>>(), It.IsAny<IClaim>(), It.IsAny<IClaim>(), It.IsAny<bool>(),
                         It.IsAny<bool>()));
    mockViewModel.Setup(m => m.GetSourceClaimAndTargetClaimByMergeState(It.IsAny<MergeState>())).Returns(mockMergeInfo);
    mockViewModel.Setup(m => m.SwitchToOverviewTab());
    mockViewModel.Setup(m => m.IncrementSaveRequiredNotification());
    mockViewModel.Setup(m => m.OnValidateAndSaveAll(It.IsAny<object>()));
    mockViewModel.Setup(m => m.ProcessPendingActions(It.IsAny<string>()));

    mockViewModel.Object.OnMerge(It.IsAny<MergeState>());    

    mockViewModel.Verify(mvm => mvm.StopSpinner(), Times.Once());
}

¿Cómo han lidiado con esto el resto de ustedes o no hay una gran forma 'simple' de manejarlo?

Actualización: agradezco los comentarios de todos. Desafortunadamente, y no es una sorpresa realmente, no parece haber una gran solución, patrón o práctica que uno pueda seguir en las pruebas unitarias si el código que se prueba es deficiente. Marqué la respuesta que mejor capturó esta simple verdad.

PremiumTier
fuente
Wow, solo veo una configuración simulada, sin instancias SUT ni nada, ¿estás probando alguna implementación real aquí? ¿Quién se supone que debe llamar a StopSpinner? OnMerge? Usted debe burlarse de cualquier dependencia puede llamar a pero no la cosa misma ..
Joppe
Es un poco difícil de ver, pero el Mock <MergeTests> es el SUT. Configuramos el indicador CallBase para garantizar que el método 'OnMerge' se ejecute en el objeto real, pero simulamos métodos llamados por 'OnMerge' que podrían causar que la prueba falle debido a problemas de dependencia, etc. El objetivo de la prueba es la última línea - para verificar detuvimos la ruleta en este caso.
PremiumTier
MergeTests suena como otra clase instrumentada, no algo que vive en producción, de ahí la confusión.
Joppe
1
Completamente aparte de sus otros problemas, me parece incorrecto que su SUT sea un <MergeTests> simulado. ¿Por qué probarías un simulacro? ¿Por qué no estás probando la clase MergeTests en sí?
Eric King

Respuestas:

18
  1. Arregle el código para estar mejor diseñado. Si sus pruebas tienen estos problemas, su código tendrá problemas peores cuando intente cambiar las cosas.

  2. Si no puedes, entonces quizás debas ser menos ideal. Pruebe contra las condiciones previas y posteriores del método. ¿A quién le importa si estás usando los otros 5 métodos? Presumiblemente tienen sus propias pruebas unitarias, lo que deja en claro (er) qué causó la falla cuando fallan las pruebas.

"las pruebas unitarias deben tener una sola razón para fallar" es una buena guía, pero en mi experiencia, poco práctica. Las pruebas difíciles de escribir no se escriben. Las pruebas frágiles no se creen.

Telastyn
fuente
Estoy completamente de acuerdo con arreglar el diseño del código, pero en el mundo menos ideal de desarrollo para una gran empresa con plazos ajustados, puede ser difícil justificar la deuda técnica contraída por los equipos anteriores o las malas decisiones. una vez. Para su segundo punto, gran parte de la burla no es solo porque queremos que la prueba falle solo por una razón: es porque no se puede permitir que se ejecute el código que se está ejecutando sin primero manejar una gran cantidad de dependencias creadas dentro de ese código . Perdón por mover las publicaciones de objetivos en ese
PremiumTier
Si un mejor diseño no es realista, estoy de acuerdo con '¿A quién le importa si estás usando los otros 5 métodos?' Verifique que el método realice la función requerida, no cómo lo está haciendo.
Kwebble
@Kwebble - Entendido, sin embargo, el objetivo de la pregunta era determinar si había una manera simple de verificar el comportamiento de un método cuando también tenía que burlarse de otros comportamientos llamados dentro del método para ejecutar la prueba. Quiero eliminar el 'cómo', pero no sé cómo :)
PremiumTier
No hay una bala de plata mágica. No hay una "forma simple" de probar un código deficiente. O bien el código bajo prueba debe ser refactorizado, o el código de prueba, en sí mismo, también será deficiente. Ya sea la prueba va a ser pobre porque va a ser demasiado específico para los detalles internos, a medida que se han topado con, o como btilly sugerido, puede ejecutar las pruebas en contra de un entorno de trabajo, pero luego las pruebas será mucho más lento y más complejo. De cualquier manera, las pruebas serán más difíciles de escribir, más difíciles de mantener y propensas a falsos negativos.
Steven Doggart
8

Romper los métodos grandes en métodos pequeños más enfocados es definitivamente una mejor práctica. Lo ve como dolor al verificar el comportamiento de la prueba de unidad, pero también está experimentando el dolor de otras maneras.

Dicho esto, es una herejía, pero personalmente soy fanático de crear entornos de prueba temporales realistas. Es decir, en lugar de burlarse de todo lo que está oculto dentro de esos otros métodos, asegúrese de que haya un entorno temporal fácil de configurar (completo con bases de datos y esquemas privados; SQLite puede ayudar aquí) que le permite ejecutar todas esas cosas. La responsabilidad de saber cómo construir / destruir ese entorno de prueba reside en el código que lo requiere, de modo que cuando cambie, no tenga que cambiar todo el código de prueba de la unidad que dependía de su existencia.

Pero sí noto que esta es una herejía de mi parte. Las personas que están muy interesadas en las pruebas unitarias abogan por las pruebas unitarias "puras" y llaman a lo que describí "pruebas de integración". Personalmente no me preocupa esa distinción.

btilly
fuente
3

Consideraría simplificar los simulacros y simplemente formular pruebas que podrían incluir los métodos a los que llama.

No pruebes el cómo , prueba el qué . Lo que importa es el resultado, incluya los submétodos si es necesario.

Desde otro ángulo, podría formular una prueba, hacerla pasar con un método grande, refactorizar y terminar con un árbol de métodos después de la refactorización. No necesita probar todos y cada uno de ellos de forma aislada. Es el resultado final lo que cuenta.

Si los métodos secundarios hacen que sea difícil probar algunos aspectos, considere dividirlos en clases separadas para que pueda burlarse de ellos de manera más limpia sin que su clase bajo prueba esté muy instrumentada / ajustada. Es un poco difícil saber si realmente está probando alguna implementación concreta en su prueba de ejemplo.

Joppe
fuente
El problema es que tenemos que burlarnos del 'cómo' para probar el 'qué'. Es una limitación impuesta por el diseño del código. Ciertamente no deseo 'burlarme' del cómo, ya que eso es lo que hace que la prueba sea frágil.
PremiumTier
Mirando los nombres de los métodos, creo que su clase probada simplemente está asumiendo demasiadas responsabilidades. Lea sobre el principio de responsabilidad única. Pedir prestado de MVC puede ayudar un poco, su clase parece manejar las preocupaciones de UI, infraestructura y negocios.
Joppe
Sí :( Ese sería ese código heredado mal diseñado al que me refería. Estamos trabajando en el rediseño y refactorización, pero pensamos que sería mejor probar primero la fuente.
PremiumTier