Código de aplicación de interfaz con pruebas unitarias

8

Estoy trabajando en un proyecto en el que tenemos que implementar y probar un módulo nuevo. Tenía una arquitectura bastante clara en mente, así que rápidamente escribí las clases y métodos principales y luego comenzamos a escribir pruebas unitarias.

Mientras escribíamos las pruebas, tuvimos que hacer bastantes modificaciones al código original, como

  • Hacer públicos los métodos privados para probarlos
  • Agregar métodos adicionales para acceder a variables privadas
  • Agregar métodos adicionales para inyectar objetos simulados que deben usarse cuando el código se ejecuta dentro de una prueba unitaria.

De alguna manera tengo la sensación de que estos son síntomas de que estamos haciendo algo mal, por ejemplo

  1. el diseño inicial era incorrecto (algunas funcionalidades deberían haber sido públicas desde el principio),
  2. el código no se diseñó correctamente para interactuar con las pruebas unitarias (quizás debido al hecho de que comenzamos a diseñar las pruebas unitarias cuando ya se habían diseñado bastantes clases),
  3. estamos implementando pruebas unitarias de forma incorrecta (por ejemplo, las pruebas unitarias solo deberían probar / abordar directamente los métodos públicos de una API, no los privados),
  4. una mezcla de los tres puntos anteriores, y quizás algunos problemas adicionales en los que no he pensado.

Como tengo algo de experiencia con las pruebas unitarias pero estoy lejos de ser un gurú, me interesaría mucho leer sus pensamientos sobre estos temas.

Además de las preguntas generales anteriores, tengo algunas preguntas técnicas más específicas:

Pregunta 1. ¿Tiene sentido probar directamente un método privado m de una clase A e incluso hacerlo público para probarlo? ¿O debería suponer que m se prueba indirectamente mediante pruebas unitarias que cubren otros métodos públicos que llaman m?

Pregunta 2. Si una instancia de clase A contiene una instancia de clase B (agregación compuesta), ¿tiene sentido burlarse de B para probar A? Mi primera idea fue que no debería burlarme de B porque la instancia B es parte de la instancia A, pero luego comencé a dudar sobre esto. Mi argumento en contra de burlarse de B es el mismo que para 1: B es privado wrt A y solo se usa para su implementación, por lo tanto, burlarse de B parece que estoy exponiendo detalles privados de A como en (1). Pero tal vez estos problemas indiquen una falla de diseño: tal vez no deberíamos usar la agregación compuesta sino una asociación simple de A a B.

Pregunta 3. En el ejemplo anterior, si decidimos burlarnos de B, ¿cómo inyectamos la instancia de B en A? Aquí hay algunas ideas que tuvimos:

  • Inyecte la instancia B como argumento para el constructor A en lugar de crear la instancia B en el constructor A.
  • Pase una interfaz BFactory como argumento al constructor A y deje que A use la fábrica para crear su instancia privada de B.
  • Use un singleton de BFactory que sea privado para A. Use un método estático A :: setBFactory () para establecer el singleton. Cuando A quiere crear la instancia B, usa el singleton de fábrica si está configurado (el escenario de prueba), crea B directamente si el singleton no está configurado (el escenario del código de producción).

Las dos primeras alternativas me parecen más limpias, pero requieren cambiar la firma del constructor A: cambiar una API solo para que sea más comprobable me parece incómodo, ¿es esta una práctica común?

El tercero tiene la ventaja de que no requiere cambiar la firma del constructor (el cambio a la API es menos invasivo), pero requiere llamar al método estático setBFactory () antes de comenzar la prueba, que es propenso a errores de IMO ( la dependencia implícita de un método requiere que las pruebas funcionen correctamente). Entonces no sé cuál deberíamos elegir.

Giorgio
fuente
Creo que la función de clase / función amiga de C ++ podría ser útil. ¿Has intentado eso?
Mert Akcakaya
@Mert: No lo hemos probado. Pregunta: Al usar friend tendremos que declarar las clases de código de prueba como amigo de las clases de código principal. ¿Esta bien? Tendríamos código de producción dependiendo del código de prueba. ¿Es esta una buena idea? ¿O era otra solución que tenía en mente?
Giorgio
No soy un experto en C ++, simplemente me vino a la mente como una solución simple.
Mert Akcakaya

Respuestas:

8

Creo que probar los métodos públicos es suficiente la mayor parte del tiempo.

Si tiene una gran complejidad en sus métodos privados, considere colocarlos en otra clase como métodos públicos y úselos como llamadas privadas a esos métodos en su clase original. De esta manera, puede asegurarse de que ambos métodos en sus clases originales y de utilidad funcionen correctamente.

Confiar en gran medida en los métodos privados es algo a considerar sobre las desiciones de diseño.

Mert Akcakaya
fuente
Estoy de acuerdo con usted: si uno siente la necesidad de probar un método privado, entonces tal vez no debería haber sido privado en primer lugar y debería colocarse en una clase de utilidad separada que debería probarse por separado.
Giorgio
Probar todos los métodos públicos ya debería probar (leer: cubrir ) todos los métodos privados. De lo contrario, ¿qué están haciendo allí? :)
Amadeus Hein
1
@Amadeus Heing, probar métodos públicos solo puede llamar métodos privados, no probarlos.
Mert Akcakaya
2
@Mert Sí, pero en general, querer probar un método privado es una señal de que algo más está mal en el código. Más detalles: enlace
Amadeus Hein
1
"Depender en gran medida de los métodos privados es algo a tener en cuenta sobre las desiciones de diseño". Pero luego nos preguntamos si sería más robusto probarlos también. Pero probablemente, como muchos señalaron, tendría sentido trasladar estos métodos a una clase de utilidad y hacerlos públicos si son tan críticos que uno quiere probarlos.
Giorgio
5

A la pregunta 1: Eso depende. Por lo general, comienza con pruebas unitarias para métodos públicos. A veces se encuentra con un método que desea mantener en privado con A, pero cree que tiene sentido probarlo de forma aislada. Si ese es el caso, debe hacer público m o hacer que la clase de prueba sea TestAuna clase amiga A. Pero cuidado, agregar una prueba unitaria para probar m hace que sea un poco más difícil cambiar la firma o el comportamiento de m después; si desea mantener ese "detalle de implementación" de A, puede ser mejor no agregar una prueba de unidad directa.

Pregunta 2: La agregación compuesta (C ++ incorporado) no funciona bien cuando se trata de simular una instancia. De hecho, dado que la construcción de B ocurre implícitamente en el constructor de A, no tiene la posibilidad de inyectar la dependencia desde afuera. Si ese es un problema, depende de la forma en que desea probar A: si cree que tiene más sentido probar A de forma aislada, con una simulación de B en lugar de B, mejor utilice una asociación simple. Si cree que puede escribir todas las pruebas unitarias necesarias para A sin burlarse de B, entonces un compuesto probablemente estará bien.

Pregunta 3: cambiar una API para hacer que las cosas sean más comprobables es común siempre que no tenga mucho código hasta ahora confiando en esa API. Cuando estás haciendo TDD, no tienes que cambiar tu API después para hacer las cosas más comprobables, comienzas inicialmente con una API diseñada para la comprobabilidad. Si desea cambiar una API más adelante para hacer las cosas más comprobables, puede encontrar problemas, eso es cierto. Así que iría con la primera o segunda de las alternativas que describiste, siempre y cuando puedas cambiar tu API sin problemas, y usar algo como tu tercera alternativa (observación: esto también funciona sin el patrón singleton) solo como último recurso si debo No cambie la API bajo ninguna circunstancia.

Sobre sus preocupaciones de que podría "hacerlo mal": cada gran motor o máquina tiene aperturas de mantenimiento, por lo que en mi humilde opinión, la necesidad de agregar algo así al software no es demasiado sorprendente.

Doc Brown
fuente
2
+1 Para el último párrafo. Para un buen ejemplo de estudio, ¿cómo prueba el mundo de la electrónica?
mattnz
+1: Gracias por una respuesta muy motivada. Una de mis principales preocupaciones es que el código de la aplicación debe proporcionar funcionalidad al código de la aplicación, no al código de prueba: el código de prueba debe observar el código de la aplicación sin imponer requisitos. Por supuesto, es posible que tenga algunos requisitos para hacer que el código sea más observable, pero estos deberían ser realmente mínimos. Vea el ejemplo compuesto: la OMI debe elegir la asociación simple de wrt compuesta según los requisitos del dominio de la aplicación, no según la capacidad de prueba. Los requisitos de aplicación de flexión de la OMI para probar los requisitos deberían ser el último recurso.
Giorgio
1
@Giorgio: su idea errónea aquí es que el uso de una asociación frente a un compuesto tiene algo que ver con los requisitos de dominio: puede cumplir con cualquier requisito de dominio con ambos tipos de diseño. Hacer que el software sea más comprobable no es algo que se pueda lograr simplemente haciendo cambios mínimos. Si lo hace bien, definitivamente influirá en su software a nivel de diseño.
Doc Brown
@ Doc Brown: Bueno, si A <> - B es un compuesto, las instancias de B solo pueden existir si son administradas por instancias de A y su vida útil está controlada por la vida útil de A. Esto puede ser un requisito de dominio. Por otro lado, una asociación simple de "uso" A -> B no impone que una instancia de A administre una instancia de B. Tal vez en nuestro caso realmente hubo una falla en el análisis del dominio de la aplicación (deberíamos usar una asociación en lugar de composición).
Giorgio
@Giorgio: es posible que tenga un requisito para que las instancias de A y B tengan la misma vida útil. Pero la forma en que la cumpla es suya, no existe un requisito de dominio que lo obligue a resolver esto utilizando la forma de composición incorporada de C ++. Si desea poder probar A y B de forma aislada, entonces, al menos para su prueba, debe tener una instancia de A sin B y viceversa. Entonces, para este caso, es mejor usar un mecanismo de tiempo de ejecución para controlar la vida útil de esos objetos (por ejemplo, usando punteros inteligentes) en lugar de un mecanismo de tiempo de compilación.
Doc Brown
1

Debe buscar Inyección de dependencia e Inversión de control. Misko Hevery explica mucho en su blog. DI e IoC es efectivamente un patrón de diseño para hacer código que sea fácil de probar y simular.

Pregunta 1: No, no haga públicos los métodos privados para probarlos. Si el método es suficientemente complejo, puede crear una clase de colaborador que contenga solo ese método e inyectarlo (pasar al constructor) en su otra clase. 1

Pregunta 2: ¿Quién construye A en este caso? Si tiene una clase de fábrica / constructor que construye A, entonces no hay ningún daño en pasar al colaborador B al constructor. Si A está en un paquete / espacio de nombres separado del código que lo usa, incluso puede hacer que el paquete constructor sea privado y hacerlo de modo que la fábrica / generador sea la única clase que pueda construirlo.

Pregunta 3: Respondí esto en la Pregunta 2. Sin embargo, algunas notas adicionales:

  • El uso de un patrón de construcción / fábrica le permite realizar la inyección de dependencia tanto como desee, sin tener que preocuparse por hacer que el código que utiliza su clase sea difícil de usar.
  • Separa el tiempo de construcción del objeto del tiempo de uso del objeto, lo que significa que el código que usa su API puede ser más simple.

1 Esta es la respuesta C # / Java: C ++ puede tener características adicionales que faciliten esto

Como respuesta a sus comentarios:

Lo que quise decir es que su código de producción se cambiaría de (por favor, perdone mi pseudo-C ++):

void MyClass::MyUseOfA()
{
  A* a = new A();
  a->SomeMethod();
}

A::A()
{
  m_b = new B();
}

a:

void MyClass::MyUseOfA()
{
  A* a = AFactory.NewA();
  a->SomeMethod();
}

A* AFactory::NewA()
{
  // Construct dependencies
  B* b = new B();
  return new A(b);
}

A::A(B* b)
{
  m_b = b;
}

Entonces su prueba puede ser:

void MyTest::TestA()
{
  MockB* b = new MockB();
  b->SetSomethingInteresting(somethingInteresting);

  A* a = new A(b);

  a->DoSomethingInteresting();

  b->DidSomethingInterestingHappen();
}

De esta manera, no necesita pasar la fábrica, el código que llama a A no necesita saber cómo construir A, y la prueba puede construir A a medida para permitir que se pruebe el comportamiento.

En su otro comentario, preguntó sobre dependencias anidadas. Entonces, por ejemplo, si sus dependencias fueran:

A -> C -> D -> B

La primera pregunta es si A usa C y D. Si no lo están, ¿por qué están incluidos en A? Suponiendo que se usan, entonces quizás sea necesario pasar C en su fábrica y hacer que su prueba construya un MockC que devuelva un MockB, lo que le permite probar todas las posibles interacciones.

Si esto comienza a complicarse, puede ser una señal de que su diseño quizás esté demasiado ajustado. Si puede aflojar el acoplamiento y mantener la cohesión alta, entonces este tipo de DI se vuelve más fácil de implementar.

Bringer128
fuente
Con respecto a la respuesta 2: ¿Quiere decir que el código de producción y el código de prueba usarían dos implementaciones de fábrica diferentes para construir A, donde (1) la fábrica de códigos de producción inyectaría la instancia de producción B y (2) la fábrica de códigos de prueba inyectaría el B simulacro de instancia. En realidad, la instancia B está bastante anidada en un árbol de composición. Tendría que pasar la fábrica por varios niveles del árbol de composición. Las instancias de A son construidas por su objeto padre (alguna otra clase)
Giorgio
Con respecto a la pregunta 3, uno de nuestros problemas es cómo se debe inyectar la fábrica B en A: usando un argumento de constructor en el constructor A para establecer una referencia local a la fábrica, o como un singleton al que A accede cuando necesita usar la fábrica .
Giorgio
@Giorgio Echa un vistazo a mi actualización de tus comentarios. Sin conocer su ejemplo específico, mis ejemplos genéricos pueden no aplicarse, pero este es el tipo de enfoque que tomaría para ver si puedo simplificar el problema de la prueba.
Bringer128
Muchas gracias por su ejemplo (el pseudocódigo me parece correcto). Dos observaciones: (1) ¿Por qué usar una fábrica en el código de producción y un constructor simple en el código de prueba? (2) La jerarquía de composición es C -> D -> A -> B, y el usuario de C debe proporcionar la instancia MockB que debe inyectarse de C a A.
Giorgio
(1) La fábrica es para ocultar el aspecto DI del código que usa A. Tiene la intención de evitar que el uso del código sea más complicado para agregar DI. Para ser más precisos, permite abstraer la gestión de dependencias lejos de A, B, incluso C y D. (2) El ejemplo que está dando no es realmente una prueba unitaria per se. Si invoca métodos solo en C, será mucho más difícil obtener una cobertura de prueba alta en A. Depende de usted lo importante que es esto, pero una prueba unitaria solo debe probar A, sus interacciones con B y sus valores de retorno.
Bringer128