(¿Por qué) es importante que una prueba unitaria no pruebe dependencias?

103

Entiendo el valor de las pruebas automatizadas y lo uso siempre que el problema esté lo suficientemente bien especificado como para poder encontrar buenos casos de prueba. Sin embargo, he notado que algunas personas aquí y en StackOverflow enfatizan probar solo una unidad, no sus dependencias. Aquí no veo el beneficio.

Mockear / stubbing para evitar probar dependencias agrega complejidad a las pruebas. Agrega flexibilidad artificial / requisitos de desacoplamiento a su código de producción para soportar la burla. (No estoy de acuerdo con nadie que diga que esto promueve un buen diseño. Escribir código adicional, introducir cosas como marcos de inyección de dependencias o agregar complejidad a su base de código para hacer que las cosas sean más flexibles / conectables / extensibles / desacopladas sin un caso de uso real es una ingeniería excesiva, no buen diseño.)

En segundo lugar, probar dependencias significa que el código crítico de bajo nivel que se usa en todas partes se prueba con entradas que no sean aquellas en las que quien escribió sus pruebas pensó explícitamente. He encontrado muchos errores en la funcionalidad de bajo nivel al ejecutar pruebas unitarias de funcionalidad de alto nivel sin burlarnos de la funcionalidad de bajo nivel de la que dependía. Lo ideal sería que las pruebas unitarias encontraran la funcionalidad de bajo nivel, pero siempre ocurren casos perdidos.

¿Cuál es el otro lado de esto? ¿Es realmente importante que una prueba unitaria no pruebe también sus dependencias? Si es así, ¿por qué?

Editar: puedo entender el valor de burlarse de dependencias externas como bases de datos, redes, servicios web, etc. (Gracias a Anna Lear por motivarme para aclarar esto). Me refería a dependencias internas , es decir, otras clases, funciones estáticas, etc. que no tienen dependencias externas directas.

dsimcha
fuente
14
"ingeniería excesiva, no buen diseño". Tendrás que proporcionar más evidencia que eso. Mucha gente no lo llamaría "sobre ingeniería", sino "mejor práctica".
S.Lott
99
@ S.Lott: Por supuesto, esto es subjetivo. Simplemente no quería un montón de respuestas para esquivar el problema y decir que burlarse es bueno porque hacer que su código sea burlable promueve un buen diseño. En general, sin embargo, odio lidiar con el código que está desacoplado de manera que no tiene ningún beneficio obvio ahora o en el futuro previsible. Si no tiene implementaciones múltiples ahora y no anticipa tenerlas en el futuro previsible, en mi humilde opinión, debe codificarlo. Es más simple y no aburre al cliente con los detalles de las dependencias de un objeto.
dsimcha
55
Sin embargo, en general, odio lidiar con el código que está acoplado de maneras que no tienen una lógica obvia, excepto un diseño deficiente. Es más simple y no aburre al cliente con la flexibilidad de probar cosas de forma aislada.
S.Lott
3
@ S.Lott: Aclaración: me refería a un código que hace todo lo posible para desacoplar las cosas sin ningún caso de uso claro, especialmente cuando hace que el código o el código de su cliente sean significativamente más detallados, introduce otra clase / interfaz, etc. Por supuesto, no estoy pidiendo que el código esté más estrechamente acoplado que el diseño más simple y conciso. Además, cuando crea líneas de abstracción prematuramente, generalmente terminan en lugares equivocados.
dsimcha
Considere actualizar su pregunta para aclarar cualquier distinción que esté haciendo. Integrar una secuencia de comentarios es difícil. Por favor, actualice la cuestión de aclarar y enfocarla.
S.Lott

Respuestas:

116

Es una cuestión de definición. Una prueba con dependencias es una prueba de integración, no una prueba unitaria. También debe tener un conjunto de pruebas de integración. La diferencia es que el conjunto de pruebas de integración se puede ejecutar en un marco de prueba diferente y probablemente no como parte de la compilación porque demoran más.

Para nuestro producto: nuestras pruebas unitarias se ejecutan con cada compilación, lo que lleva segundos. Un subconjunto de nuestras pruebas de integración se ejecuta con cada registro, lo que lleva 10 minutos. Nuestra suite de integración completa se ejecuta todas las noches y demora 4 horas.

Jeffrey Faust
fuente
44
No se olvide de las pruebas de regresión para cubrir errores descubiertos y reparados para evitar su reintroducción en un sistema durante el mantenimiento futuro.
RBerteig
2
No insistiría en la definición, incluso si estoy de acuerdo con ella. Algunos códigos pueden tener dependencias aceptables, como StringFormatter, y la mayoría de las veces lo considera una prueba unitaria.
danidacar
2
danip: definiciones claras son importantes, y se insistirá en ellos. Pero también es importante darse cuenta de que esas definiciones son un objetivo. Vale la pena apuntar, pero no siempre necesitas una diana. Las pruebas unitarias a menudo tendrán dependencias en las bibliotecas de nivel inferior.
Jeffrey Faust
2
También es importante comprender el concepto de las pruebas de fachada, que no prueban cómo los componentes encajan entre sí (como las pruebas de integración) sino que prueban un solo componente de forma aislada. (es decir, un gráfico de objeto)
Ricardo Rodrigues
1
no se trata solo de ser más rápido sino más bien de ser extremadamente tedioso o inmanejable, imagínese, si no lo hace, su árbol de ejecución de burlas podría estar creciendo exponencialmente. Imagine que se burla de 10 dependencias en su unidad si empuja más la burla, digamos que 1 de esas 10 dependencias se usa en muchos lugares y tiene 20 de sus propias dependencias, por lo que debe burlarse + 20 otras dependencias, potencialmente por duplicado en muchos lugares. en el punto final de la API, tienes que burlarte, por ejemplo, la base de datos, para que no tengas que restablecerla y es más rápido como lo dijiste
FantomX1
39

La prueba con todas las dependencias en su lugar sigue siendo importante, pero está más en el ámbito de las pruebas de integración como dijo Jeffrey Faust.

Uno de los aspectos más importantes de las pruebas unitarias es hacer que sus pruebas sean confiables. Si no confía en que una prueba aprobada realmente significa que las cosas están bien y que una prueba reprobada realmente significa un problema en el código de producción, sus pruebas no son tan útiles como pueden ser.

Para que sus pruebas sean confiables, debe hacer algunas cosas, pero me voy a centrar en solo una para esta respuesta. Debe asegurarse de que sean fáciles de ejecutar, de modo que todos los desarrolladores puedan ejecutarlos fácilmente antes de ingresar el código. "Fácil de ejecutar" significa que sus pruebas se ejecutan rápidamente y no se necesita una configuración o configuración extensa para hacerlas funcionar. Idealmente, cualquiera debería poder consultar la última versión del código, ejecutar las pruebas de inmediato y verlas pasar.

Abstraer las dependencias de otras cosas (sistema de archivos, base de datos, servicios web, etc.) le permite evitar la necesidad de configuración y lo hace a usted y a otros desarrolladores menos susceptibles a situaciones en las que se siente tentado a decir "Oh, las pruebas fallaron porque no lo hice" no tengo configurado el recurso compartido de red. Bueno, los ejecutaré más tarde ".

Si desea probar lo que hace con algunos datos, las pruebas unitarias para ese código de lógica de negocios no deberían importarle cómo obtiene esos datos. Poder probar la lógica central de su aplicación sin depender de cosas de soporte como bases de datos es increíble. Si no lo estás haciendo, te lo estás perdiendo.

PD: Debo agregar que definitivamente es posible hacer ingeniería excesiva en nombre de la capacidad de prueba. Probar su aplicación ayuda a mitigar eso. Pero, en cualquier caso, las implementaciones deficientes de un enfoque no hacen que el enfoque sea menos válido. Cualquier cosa puede ser mal utilizada y exagerada si uno no sigue preguntando "¿por qué estoy haciendo esto?" mientras se desarrolla


En cuanto a las dependencias internas, las cosas se ponen un poco turbias. La forma en que me gusta pensar es que quiero proteger a mi clase tanto como sea posible del cambio por las razones equivocadas. Si tengo una configuración como esta ...

public class MyClass 
{
    private SomeClass someClass;
    public MyClass()
    {
        someClass = new SomeClass();
    }

    // use someClass in some way
}

Generalmente no me importa cómo SomeClassse crea. Solo quiero usarlo. Si SomeClass cambia y ahora requiere parámetros para el constructor ... ese no es mi problema. No debería tener que cambiar MyClass para acomodar eso.

Ahora, eso es solo tocar la parte de diseño. En lo que respecta a las pruebas unitarias, también quiero protegerme de otras clases. Si estoy probando MyClass, me gusta saber que no hay dependencias externas, que SomeClass en algún momento no introdujo una conexión de base de datos o algún otro enlace externo.

Pero un problema aún mayor es que también sé que algunos de los resultados de mis métodos dependen de la salida de algún método en SomeClass. Sin burlarme de SomeClass, podría no tener forma de variar esa entrada en la demanda. Si tengo suerte, podría componer mi entorno dentro de la prueba de tal manera que provoque la respuesta correcta de SomeClass, pero ir de esa manera introduce complejidad en mis pruebas y las hace frágiles.

Reescribir MyClass para aceptar una instancia de SomeClass en el constructor me permite crear una instancia falsa de SomeClass que devuelve el valor que quiero (ya sea a través de un marco de imitación o con una simulación manual). Generalmente no tengo que introducir una interfaz en este caso. Si hacerlo o no es, en muchos sentidos, una elección personal que puede ser dictada por su idioma de elección (por ejemplo, las interfaces son más probables en C #, pero definitivamente no necesitaría una en Ruby).

Adam Lear
fuente
+1 Gran respuesta, porque las dependencias externas son un caso en el que no estaba pensando cuando escribí la pregunta original. He aclarado mi pregunta en respuesta. Ver la última edición.
dsimcha
@dsimcha Expandí mi respuesta para entrar en más detalles sobre eso. Espero eso ayude.
Adam Lear
Adam, ¿puedes sugerir un lenguaje donde "Si SomeClass cambia y ahora requiere parámetros para el constructor ... no debería tener que cambiar MyClass" es cierto? Lo siento, eso me llamó la atención como un requisito inusual. Puedo ver que no tengo que cambiar las pruebas unitarias para MyClass, pero que no tengo que cambiar MyClass ... wow.
1
@moz Lo que quise decir es que si se crea SomeClass, MyClass no tiene que cambiar si se inyecta SomeClass en lugar de crearse internamente. En circunstancias normales, MyClass no debería preocuparse por los detalles de configuración de SomeClass. Si la interfaz de SomeClass cambia, entonces sí ... MyClass aún tendría que modificarse si alguno de los métodos que utiliza se ve afectado.
Adam Lear
1
Si se burla de SomeClass cuando prueba MyClass, ¿cómo detectará si MyClass está usando SomeClass de manera incorrecta o si se basa en una peculiaridad no probada de SomeClass que puede cambiar?
nombre
22

Aparte del problema de la unidad frente a la prueba de integración, considere lo siguiente.

Class Widget tiene dependencias en las clases Thingamajig y WhatsIt.

Una prueba unitaria para Widget falla.

¿En qué clase se encuentra el problema?

Si respondió "iniciar el depurador" o "leer el código hasta que lo encuentre", comprenderá la importancia de probar solo la unidad, no las dependencias.

Arroyo
fuente
3
@Brook: ¿Qué tal ver cuáles son los resultados para todas las dependencias de Widget? Si todos pasan, es un problema con Widget hasta que se demuestre lo contrario.
dsimcha
44
@dsimcha, pero ahora está agregando complejidad nuevamente en el juego para verificar los pasos intermedios. ¿Por qué no simplificar y simplemente hacer pruebas unitarias simples primero? Luego haz tu prueba de integración.
asoundmove
1
@dsimcha, eso suena razonable hasta que entras en un gráfico de dependencia no trivial. Digamos que tiene un objeto complejo que tiene más de 3 capas de profundidad con dependencias, se convierte en una búsqueda O (N ^ 2) para el problema, en lugar de O (1)
Brook
1
Además, mi interpretación del SRP es que una clase debería tener una sola responsabilidad a nivel de dominio conceptual / problema. No importa si cumplir con esa responsabilidad requiere que haga muchas cosas diferentes cuando se ve en un nivel inferior de abstracción. Si se lleva al extremo, el SRP sería contrario a OO porque la mayoría de las clases contienen datos y realizan operaciones en él (dos cosas cuando se ven a un nivel suficientemente bajo).
dsimcha
44
@Brook: Más tipos no aumentan la complejidad per se. Más tipos está bien si esos tipos tienen sentido conceptual en el dominio del problema y hacen que el código sea más fácil, no más difícil de entender. El problema es desacoplar artificialmente las cosas que están conceptualmente acopladas en el nivel del dominio del problema (es decir, solo tiene una implementación, probablemente nunca tendrá más de una, etc.) y el desacoplamiento no se correlaciona bien con los conceptos del dominio del problema. En estos casos, es tonto, burocrático y detallado crear serias abstracciones en torno a esta implementación.
dsimcha
14

Imagina que programar es como cocinar. Luego, las pruebas unitarias son lo mismo que asegurarse de que sus ingredientes sean frescos, sabrosos, etc. Mientras que las pruebas de integración son como asegurarse de que su comida sea deliciosa.

En última instancia, asegurarse de que su comida sea deliciosa (o que su sistema funcione) es lo más importante, el objetivo final. Pero si sus ingredientes, quiero decir, unidades, funcionan, tendrá una forma más barata de averiguarlo.

De hecho, si puede garantizar que sus unidades / métodos funcionen, es más probable que tenga un sistema que funcione. Destaco "más probable", en lugar de "cierto". Todavía necesita sus pruebas de integración, al igual que necesita que alguien pruebe la comida que cocinó y le diga que el producto final es bueno. Te será más fácil llegar con ingredientes frescos, eso es todo.

Julio
fuente
77
Y cuando sus pruebas de integración fallan, la prueba unitaria puede concentrarse en el problema.
Tim Williscroft
99
Para estirar una analogía: cuando descubres que tu comida tiene un sabor terrible, puede tomar algo de investigación para determinar que fue porque tu pan está mohoso. Sin embargo, el resultado es que agrega una prueba unitaria para verificar si hay pan mohoso antes de cocinar, de modo que ese problema en particular no pueda volver a ocurrir. Luego, si tiene otra falla de comida más tarde, ha eliminado el pan mohoso como la causa.
Kyralessa
Me encanta esta analogía!
thehowler
6

Probar las unidades aislándolas completamente permitirá probar TODAS las posibles variaciones de datos y situaciones a las que se puede enviar esta unidad. Como está aislado del resto, puede ignorar las variaciones que no tienen un efecto directo en la unidad que se va a probar. Esto a su vez disminuirá en gran medida la complejidad de las pruebas.

Las pruebas con varios niveles de dependencias integradas le permitirán probar escenarios específicos que podrían no haberse probado en las pruebas unitarias.

Ambos son importantes Simplemente haciendo una prueba unitaria, invariablemente se producirá un error sutil más complejo al integrar componentes. Simplemente haciendo pruebas de integración significa que está probando un sistema sin la confianza de que las partes individuales de la máquina no han sido probadas. Pensar que puede lograr una prueba completa mejor simplemente haciendo pruebas de integración es casi imposible, ya que a medida que aumenta la cantidad de componentes, la combinación de entradas crece MUY rápido (piense en factorial) y crear una prueba con suficiente cobertura se vuelve muy rápidamente imposible.

En resumen, los tres niveles de "pruebas unitarias" que suelo usar en casi todos mis proyectos:

  • Pruebas unitarias, aisladas con dependencias simuladas o tropezadas para probar el infierno de un solo componente. Lo ideal sería intentar una cobertura completa.
  • Pruebas de integración para probar los errores más sutiles. Verificaciones puntuales cuidadosamente elaboradas que mostrarían casos límite y condiciones típicas. La cobertura completa a menudo no es posible, el esfuerzo se centró en lo que haría fallar el sistema.
  • Pruebas de especificación para inyectar varios datos de la vida real en el sistema, instrumentar los datos (si es posible) y observar la salida para asegurar que se ajusta a las especificaciones y las reglas comerciales.

La inyección de dependencia es un medio muy eficiente para lograr esto, ya que permite aislar componentes para pruebas unitarias muy fácilmente sin agregar complejidad al sistema. Es posible que su escenario empresarial no garantice el uso del mecanismo de inyección, pero su escenario de prueba casi lo hará. Esto para mí es suficiente para que sea indispensable. También puede usarlos para probar los diferentes niveles de abstracción de forma independiente a través de pruebas de integración parcial.

Newtopian
fuente
4

¿Cuál es el otro lado de esto? ¿Es realmente importante que una prueba unitaria no pruebe también sus dependencias? Si es así, ¿por qué?

Unidad. Significa singular.

Probar 2 cosas significa que tiene las dos cosas y todas las dependencias funcionales.

Si agrega una tercera cosa, aumentará las dependencias funcionales en las pruebas más allá de forma lineal. Las interconexiones entre las cosas crecen más rápidamente que la cantidad de cosas.

Hay n (n-1) / 2 dependencias potenciales entre n elementos que se están probando.

Esa es la gran razón.

La simplicidad tiene valor.

S.Lott
fuente
2

¿Recuerdas cómo aprendiste a hacer recursividad por primera vez? Mi profesor dijo "Suponga que tiene un método que hace x" (por ejemplo, resuelve fibbonacci para cualquier x). "Para resolver x tienes que llamar a ese método para x-1 y x-2". Del mismo modo, eliminar las dependencias le permite fingir que existen y probar que la unidad actual hace lo que debería hacer. Por supuesto, se supone que está probando las dependencias con la misma rigurosidad.

Este es, en esencia, el SRP en el trabajo. Centrarse en una única responsabilidad, incluso para sus exámenes, elimina la cantidad de malabarismo mental que tiene que hacer.

Michael Brown
fuente
2

(Esta es una respuesta menor. Gracias @TimWilliscroft por la pista).

Las fallas son más fáciles de localizar si:

  • Tanto la unidad bajo prueba como cada una de sus dependencias se prueban de forma independiente.
  • Cada prueba falla si y solo si hay una falla en el código cubierto por la prueba.

Esto funciona muy bien en papel. Sin embargo, como se ilustra en la descripción de OP (las dependencias tienen errores), si las dependencias no se están probando, sería difícil determinar la ubicación de la falla.

rwong
fuente
¿Por qué no se probarían las dependencias? Presumiblemente son de nivel más bajo y más fáciles de probar por unidad. Además, con las pruebas unitarias que cubren códigos específicos, es más fácil determinar la ubicación de la falla.
David Thornley
2

Muchas buenas respuestas. También agregaría un par de otros puntos:

Las pruebas unitarias también le permiten probar su código cuando sus dependencias no existen . Por ejemplo, usted o su equipo, aún no han escrito las otras capas, o tal vez están esperando en una interfaz entregada por otra compañía.

Las pruebas unitarias también significan que no tiene que tener un entorno completo en su máquina de desarrollo (por ejemplo, una base de datos, un servidor web, etc.). Recomendaría fuerte que todos los desarrolladores hacer tener un entorno de este tipo, sin embargo cortado es, con el fin de errores de reprografía, etc. Sin embargo, si por alguna razón no es posible imitar el entorno de producción a continuación, la unidad de pruebas, al menos, yu da un cierto nivel de confianza en su código antes de que entre en un sistema de prueba más grande.

Steve
fuente
0

Acerca de los aspectos de diseño: creo que incluso los proyectos pequeños se benefician al hacer que el código sea comprobable. No necesariamente tiene que introducir algo como Guice (a menudo lo hará una clase simple de fábrica), pero separar el proceso de construcción de la lógica de programación da como resultado

  • documentar claramente las dependencias de cada clase a través de su interfaz (muy útil para las personas que son nuevas en el equipo)
  • las clases se vuelven mucho más claras y fáciles de mantener (una vez que pones la creación del gráfico de objetos feos en una clase separada)
  • acoplamiento flojo (hacer los cambios mucho más fáciles)
Oliver Weiler
fuente
2
Método: Sí, pero debe agregar código adicional y clases adicionales para hacer esto. El código debe ser lo más conciso posible sin dejar de ser legible. En mi humilde opinión, agregar fábricas y todo lo demás es ingeniería excesiva a menos que pueda demostrar que necesita o es muy probable que necesite la flexibilidad que proporciona.
dsimcha
0

Uh ... ¡ hay buenos puntos en las pruebas de unidad e integración escritas en estas respuestas aquí!

Echo de menos las opiniones prácticas y relacionadas con los costos aquí. Dicho esto, veo claramente el beneficio de las pruebas unitarias muy aisladas / atómicas (tal vez altamente independientes entre sí y con la opción de ejecutarlas en paralelo y sin dependencias, como bases de datos, sistema de archivos, etc.) y pruebas de integración (nivel superior), pero ... también es una cuestión de costos (tiempo, dinero, ...) y riesgos .

Por lo tanto, hay otros factores, que son mucho más importantes (como "qué probar" ) antes de pensar en "cómo probar" desde mi experiencia ...

¿Paga mi cliente (implícitamente) la cantidad adicional de escritura y mantenimiento de pruebas? ¿Es un enfoque más basado en pruebas (escribir las pruebas antes de escribir el código) realmente rentable en mi entorno (análisis de riesgo / costo de fallas del código, personas, especificaciones de diseño, configuración del entorno de prueba)? El código siempre tiene errores, pero ¿podría ser más rentable mover las pruebas al uso de producción (en el peor de los casos)?

También depende mucho de la calidad de su código (estándares) o de los marcos, IDE, principios de diseño, etc. que usted y su equipo sigan y cuán experimentados sean. Un código bien escrito, fácilmente comprensible, suficientemente documentado (idealmente autodocumentado), modular, ... introduce probablemente menos errores que lo contrario. Por lo tanto, la "necesidad" real, la presión o los costos / riesgos generales de mantenimiento / corrección de errores para pruebas extensas pueden no ser altos.

Llevemos al extremo donde sugirió un compañero de trabajo de mi equipo, tenemos que intentar probar nuestro código de capa de modelo Java EE puro con una cobertura deseada del 100% para todas las clases dentro y burlándose de la base de datos. O el administrador que desea que las pruebas de integración se cubran con un 100% de todos los posibles casos de uso del mundo real y flujos de trabajo de interfaz de usuario web porque no queremos arriesgarnos a que falle ningún caso de uso. Pero tenemos un presupuesto ajustado de aproximadamente 1 millón de euros, un plan bastante ajustado para codificar todo. Un entorno de cliente, donde los posibles errores de la aplicación no serían un gran peligro para los humanos o las empresas. Nuestra aplicación se probará internamente con (algunas) pruebas unitarias importantes, pruebas de integración, pruebas clave de clientes con planes de prueba diseñados, una fase de prueba, etc. ¡No desarrollamos una aplicación para alguna fábrica nuclear o la producción farmacéutica!

Yo mismo intento escribir una prueba si es posible y desarrollarla mientras la unidad prueba mi código. Pero a menudo lo hago desde un enfoque de arriba hacia abajo (prueba de integración) y trato de encontrar el buen punto, dónde hacer el "corte de la capa de la aplicación" para las pruebas importantes (a menudo en la capa del modelo). (porque a menudo se trata mucho de las "capas")

Además, el código de prueba de unidad e integración no tiene un impacto negativo en el tiempo, el dinero, el mantenimiento, la codificación, etc. Es genial y debe aplicarse, pero con cuidado y teniendo en cuenta las implicaciones al comenzar o después de 5 años de muchas pruebas desarrolladas. código.

Entonces, diría que realmente depende mucho de la evaluación de la confianza y el costo / riesgo / beneficio ... como en la vida real, donde no puedes y no quieres correr con muchos o 100% mecanismos de seguridad.

El niño puede y debe subir a algún lugar y puede caerse y lastimarse. El automóvil puede dejar de funcionar porque llené el combustible incorrecto (entrada no válida :)). La tostada puede quemarse si el botón de tiempo deja de funcionar después de 3 años. Pero, nunca, quiero conducir en la carretera y sostener mi volante separado en mis manos :)

Andreas Dietrich
fuente