¿Vale la pena crear pruebas unitarias? Mis reflexiones

Ante la expectación creada en twitter y en los comentarios decidí responder al post de Lucas http://geeks.ms/blogs/lontivero/archive/2011/03/31/191-vale-la-pena-crear-pruebas-unitarias.aspx pero el comentario se fué alargando tanto que dije… mejor lo salvo para por si acaso, y ya se alargó tanto y tanto que dije… mejor escribo un post al respecto. Os advierto que es un post LARGO LARGO LARGO así que suerte leyéndolo.

Yo he de decir que entiendo tanto la postura de Lucas como la de Ibon. Así que aquí va mi visión:

1º La parte de Strategic Design de DDD que cuenta Eric Evans, viene a decir que debes implementar con más cuidado el core de tu dominio, es decir, lo que te da más valor de negocio, y que puedes implementar «peor» las partes periféricas que no son críticas para tu negocio. De igual manera, en las pruebas no tienes que hacer todo tu código super testeable, debes poner más énfasis en las partes que son core de tu dominio y que te van a dar la mayor parte del valor de negocio del sistema. No deberías poner énfasis en realizar un testing perfecto de todo el sistema en sí, sino basarte en el negocio y en la complejidad ciclomática para priorizar pruebas o evaluar grados de calidad. Es decir, prueba mejor lo que es más crítico y complejo.

2º Probar lo obvio, sí o no. Depende del tiempo y del factor calidad. Caso extremo: Testear que un setter asigna el valor correcto. ¿Aporta valor? Poco, ya que el código es obvio. Por contra, testear algo complejo como, por poner, la planificación automática de turnos de trabajo. ¿Aporta valor? Sí, porque es un código que no es trivial verificar, y entre otras cosas las pruebas unitarias son más sencillas de realizar que cualquier otro tipo de prueba (manual por ejemplo) ya que tienes un entorno más controlable e influyen menos componentes. La gracia de una prueba unitaria es que controlas el comportamiento de las dependencias, lo que facilita aislar errores en la implementación de estas al sustituirlas por algo que te permite indicar la respuesta.

3º La dificultad al escribir pruebas unitarias. ¿Es síntoma de mal código? Sí y no. Puede ser un síntoma de mal código o un síntoma de que no has hecho una buena prueba. Como dices en el primer punto, «masterizar» un framework de pruebas es sencillo, por el contrario hacer una buena prueba unitaria es muy jodido. Es algo que no aprendes solo por saberte de memoria PEX, Nunit, MbUnit o RitaLaCantaoraUnit. Hay muchas cosas que plantearse a la hora de hacer una prueba unitaria como qué verifico, qué pruebas hago, etc. Las pruebas sirven, como dice Ibon con otras palabras, para hacer afirmaciones sobre el código. Esto nos permite comprobar que cierto hecho o propiedad se cumple y que esto sigue siendo así tras refactorizar o realizar modificaciones. No demuestran la ausencia de errores, solo que determinados hechos se cumplen. Mucha gente, (yo incluido) más de una vez ha escrito y escribe pruebas para «validar» su implementación, más que para «especificar» el comportamiento. Esto es un factor a tener muy en cuenta ya que cuando «validamos» una implementación estamos en el ámbito del «como» mientras que cuando «especificamos» estamos en el ámbito del qué, es decir, estamos más arriba en el nivel de abstracción. Lo que quiero decir es que muchas veces ocurre que se hacen las pruebas unitarias mal, tras realizar una implementación, lo que favorece que se tienda a «validar una implementación» más que a “especificar una interfaz”. Por el contrario, una prueba que de verdad «especifica» se centra solo en lo que un cliente de una determinada interfaz o clase espera tras realizar una determinada acción, por ejemplo. La interaz IPuerta con void Abrir(), void Cerrar(), bool Abierta(), bool Cerrada(). Típica prueba de interfaz, “especificando”:

//Arrange – Con la puerta inicialmente cerrada…

puerta.Abrir();

Assert.IsTrue(puerta.Abierta());

//Típica prueba de “validación de implementación”

//Arrange – Con la puerta cerrada…

puerta.Abrir();

Assert.IsTrue(puerta.Abierta());

Assert.IsTrue(puerta.Pomo.Girado);

Assert.IsTrue(puerta.Angulo == 90);

(Larga lista de asserts)

La diferencia radica en que la prueba que “especifica” tiene en cuenta solo el componente que estás probando. Es realmente una prueba “unitaria” ya que solo está especificando/definiendo el comportamiento del sujeto de la prueba. Por el contrario, la prueba que “valida” no es del todo una prueba unitaria, ya que no se limita a comprobar/definir el componente puerta, sino que además realiza afirmaciones sobre otros componentes. Hay que tener una cosa muy clara, cuando haces una prueba unitaria realizas una especificación en dos puntos de la misma. En los asertos de la prueba, ya que especificas el comportamiento del componente bajo prueba, y también al realizar mocks ya que básicamente dices, ante esta entrada quiero esta respuesta y eso es una especificación (por extensión). Por lo tanto, cuando realizas un mock, estás haciendo una afirmación que deberías comprobar posteriormente. Lo que genera una nueva prueba para la dependencia. Por último queda el tema de “este componente llama a esta dependencia” luego tengo que comprobar que lo hace con los argumentos adecuados. En una comunicación solo debo probar los argumentos de las llamadas ya que comprobar que ante un argumento una dependencia devuelve un determinado valor y queda en un estado determinado es una prueba unitaria de la dependencia, no del propio componente. Comprobar que un componente llama a una dependencia se sale del ámbito de las pruebas unitarias ya que lo que esto hace es verificar una determinada comunicación entre dos objetos, y esto es una prueba de integración. Además cuando se añaden argumentos la cosa se complica más. Nuestro componente puede manipular los argumentos antes de llamar a la dependencia y hacerlo de forma no trivial. Esto implica que tenemos que entrar “dentro” del componente para reproducir esa transformación de forma que podamos hacer la comprobación en la prueba unitaria, por lo que entramos de nuevo en el cómo, y en una prueba buscamos el qué. Probar la comunicación con pruebas unitarias es algo ciertamente complicado, principalmente por la posible transformación que pueden sufrir los argumentos antes de llamar internamente a la dependencia. ¿Cómo resolvemos esto? Sencillamente escribiendo buen código. Una cosa que se nos olvida siempre es que un componente tiene la responsabilidad de usar sus dependencias de la forma adecuada. Lo que nos falta siempre es introducir asertos justo antes de la llamada que comprueben que se va a llamar con los argumentos adecuados, es algo que siempre o casi siempre asumimos. Si pensamos en términos de Design by contract, cada método tiene precondiciones y postcondiciones. Cumplir con las precondiciones es responsabilidad del componente que llama (luego tiene que comprobarlas) y las postcondiciones son responsabilidad del propio método (por lo que el método que llama puede “creer en ellas” (creer significa escribir una prueba unitaria para el método que verifique la postcondición). Al usar un framework de mocking con verificación (moq, rhino, etc) mezclamos la prueba unitaria en sí con la prueba de la comunicación entre componentes. La prueba de las comunicaciones no importa a la hora de especificar el comportamiento de un componente/abstracción (visto como una caja negra) sino solo al comprobar el comportamiento de una implementación específica. Estos dos elementos se mezclan a menudo por el hecho de que muchas veces solo se realiza una implementación para una determinada interfaz y se realiza la prueba sobre la implementación cuando debería realizarse sobre la interfaz. En definitiva y para concluir este punto, pongo un ejemplo donde intento explicarlo. Supongamos dos objetos componente y dependencia. La estructura de una prueba unitaria de componente.metodo(args) y de las implementaciones (en lo que se refiere a pruebas) debería ser algo así.

Arrange (Creo componente y dependencias)

Act

componente.metodo(args);

Assert

Asert.IsTrue(componente.propiedad == valor); //Con propiedad me refiero a llamada a método, propiedad, o engeneral cualquier tipo de consulta sobre el estado, pero siempre del propio objeto.

 

estructura de componente.método(args)

//precondiciones

//comprobación de la precondición del método de la dependencia antes de llamar y de que los argumentos son los correctos.

dependencia.metodo(depArgs) //Llamada con los argumentos de la dependencia obtenidos a partir de los valores accesibles desde el método (argumentos, variables locales, de instancia, clase, constantes, etc).

//resto del método siguiendo el mismo esquema

//postcondiciones

 

estructura de dependencia.método(depArgs)

//precondiciones

//cuerpo siguiendo el mismo esquema

//postcondiciones

 

Una clase debe ofrecer mecanismos para validar las precondiciones de los métodos que expone públicamente. Por ejemplo, la un objeto Stream dispone de propiedades como CanRead, CanWrite, etc. que indican si se pueden llamar a ciertos métodos como read, write, etc. El llamante es responsable de validar los argumentos y de utilizar estas propiedades para comprobar que la operación se puede hacer sin problemas. El método Read, Write, etc. comprobará los argumentos y lanzará una excepción en caso de que no sean válidos, pero es responsabilidad del llamante hacer esa comprobación y además comprobar que los argumentos que va a usar son exactamente los que desea (el invariante en ese punto del programa). Pondré un ejemplo más concreto con un programa que escribe la potencia de un número en un fichero.

void cuadrado(int arg, Stream stream){

   int cuadrado = arg*arg;

   Assert((arg == 0 || cuadrado/arg == arg) && stream.CanWrite); //Suponiendo que eliminamos el factor “entorno” (espacio en disco, etc.) queda claro que lo que se comprueba es el estado del programa antes de la llamada y la precondición del método de la dependencia.

   stream.Write(cuadrado);

En conclusión para este punto, una prueba unitaria debe definir el estado antes de una acción y comprobar/realizar afirmaciones sobre el resultado después de la acción. No debe en ningún caso pronunciarse sobre qué ocurre en medio, que es lo que ocurre cuando utilizamos mocks.

4º Estructura/arquitectura/complejidad en las pruebas. Una prueba unitaria es algo muy elemental. Un simple método, sin parámetros y con estructura Arrange-Act-Assert. Típicamente creamos datos de prueba, hacemos la llamada y comprobamos. Repetimos el proceso para cada una de las casuísticas de lo que estamos probando. Cantidad de veces cogemos una prueba, la copiamos/pegamos y cambiamos los datos del arrange. Las pruebas unitarias suponen un gran esfuerzo porque muchas veces no aplicamos la misma “inteligencia” que aplicamos para el diseño del código. Aplicar principios de diseño es tan importante en las pruebas como en la propia aplicación, y hay técnicas que permiten subsanar el problema del copy/paste de pruebas. Esto se ve mucho más cuando se crean pruebas complejas. Los pasos son los siguientes.

Crear un método que realice la “prueba con los datos que le pasamos” (que se encargue de hacer todo el arrange, el act y el assert en base a argumentos).

Dentro de este método extraer el arrange y el assert a métodos aparte.

Escribir en el método del arrange los pasos para hacer un setup de todas las dependencias y hacer este método virtual de forma que se puedan ajustar detalles.

Escribir la postcondición del método (validando solo elementos del propio componente).

Escribir pruebas siguiendo la misma estructura (o añadir casos concretos si la base para la prueba ya está creada)

Escribir métodos que creen argumentos concretos y realicen la prueba usando el método definido en el punto 1.

Realizar herencia de la clase de pruebas para cada una de las implementaciones sustituyendo el arrange correspondiente.

 

Llegados a este punto las comunicaciones (el objeto x llama a la dependencia y haciendo el pino mientras salta a la pata coja y la dependencia y le devuelve el valor adecuado y se queda en el estado esperado) se prueban mediante los asertos que introducimos antes de realizar las llamadas (que comprueban que los argumentos son correctos y los esperados) y mediante las pruebas unitarias de la dependencia y, que comprueban que dado ese argumento la postcondición es la esperada. En principio no se debería hacer, o por lo menos no me gusta, una comprobación del nº/orden de las llamadas, pero es algo que un framework de mocking ayuda y permite hacer sin problemas.

 

5º El tema de las interfaces por testabilidad. Aquí creo rotundamente que una interfaz no se crea para que algo sea testable, sino para definir un comportamiento. Una interfaz no son solo signaturas de miembros, sino también precondiciones y postcondiciones de los mismos que es lo que define a la interfaz y que es lo que todos los implementadores de la misma deben respetar. De no ser así, la interfaz no sirve para nada ya que los implementadores estarían violando el principio de Liskov Substitution. Respecto a lo de que añades más complejidad… Añades más código, pero no más complejidad, de hecho creo que la cosa se simplifica, al pasar las dependencias en el constructor estás cambiando Afferent coupling por Efferent coupling. Las dependencias apuntan hacia fuera y son explícitas, no hay que buscarlas dentro del código y son mucho más visibles. Por otra parte, crear una interfaz para todo es un error. Las interfaces definen un protocolo/comportamiento para el que puede haber múltiples implementaciones, si solo vas a realizar en principio una implementación o puedes proporcionar una implementación base, lo correcto es usar una clase y definir métodos virtuales. Meter una interfaz cuando no se necesita es añadir complejidad por gusto. Construye lo más simple posible, siempre hay tiempo de refactorizar después y una buena arquitectura/diseño no se hace de una patada, sino que madura a partir de la comprensión de los requisitos del proyecto.

Finalmente, como conclusión, creo que en el estado actual de la industria, Lucas tiene cierta razón. Pero creo que es más un problema de acostumbrar a la gente a hacer pruebas y diseñar bien que del hecho de las pruebas unitarias en sí. Las pruebas son necesarias y valen la pena, pero requieren a su vez una planificación y una priorización. Mi visión sobre esto se resume en:

Prueba lo complejo, lo importante, y lo susceptible a cambios futuros.

Diseña las pruebas con el mismo cuidado con el que diseñas el sistema.

 

Bueno, esto empezó siendo un comentario en el blog de Lucas y ha pasado a ser un tocho largo infumable. Si habeis llegado hasta aquí, espero que os haya resultado lo más ameno posible, y espero vuestros comentarios.

3 comentarios sobre “¿Vale la pena crear pruebas unitarias? Mis reflexiones”

  1. Una puntualización que en cierta media apoya mi tesis:

    «Assert((arg == 0 || cuadrado/arg == arg) && stream.CanWrite);»

    No puedes hacer eso. Bueno, sí que puedes hacerlo, pero estás comprobando lo mismo con lo mismo. Es decir, estás comprobando que la operación multiplicación sobre dos enteros es válida asumiendo que la operación de la división es válida. Pero si la operación de la división es válida, también lo es la de la multiplicación, y por tanto el test no es necesario. Ambas están en el mismo dominio.

    Además, tampoco compruebas el caso especial en que arg sea cero y por tanto cuadrado también deba ser cero (y volvemos entonces al hecho de que estás asumiendo que las operaciones aritméticas son correctas); de hecho lo único que estás demostrando es que no haya habido desbordamiento en la multiplicación, asumiendo que la división sea correcta, pero ese caso es una pre-condición (o limitación) de la rutina, no un test.

    Es decir, para que la rutina sea formalmente válida, tiene que recibir dos enteros y devolver (o guardar) un valor de doble anchura al entero, porque si no es una rutina destinada al fracaso.

    Con esto no quiero criticarte a ti, quiero criticar la validez del dominio de los tests. Suponiendo que la operación de multiplicación sea una operación no trivial, la de división tampoco lo sería, por lo que tu rutina de verificación está basada en otras rutinas que no sabes si son correctas o no. Evidentemente tendrías que hacer en otro lado la verificación de esas rutinas, pero si ya se nos ha escapado la multiplicación de un valor por cero, y si se va a producir algún efecto lateral indeseado (por ejemplo un desbordamiento de buffer) al producirse un overflow, ¿qué se nos habrá escapado en el caso de una transformación determinista pero no reversible de la VidaReal(TM)?

    De ahí mi tesis: tests sí, pero ojo con ellos.

    Tampoco hemos entrado en el hecho de que no podemos verificar un ladrillo… con un ladrillo.

  2. A ver, he puesto un mal ejemplo porque son operaciones que se pueden definir de forma declarativa, eran las tres de la mañana y estaba cansado. Supon que en vez de hacer x*x haces:
    int cuadrado = 0;
    for(int i = 0;imax){
    max = vector[i];
    }
    }
    Assert(vector.All(v => v <= max) && vector.Contains(max) && stream.CanWrite); stream.Write(max); } Supón que no tienes la operación Max de Linq. La clave está en que el aserto es mucho más sencillo de escribir que el propio código. Igual que una prueba debe ser más sencilla de escribir que el código que está probando. Puedes “confiar” en los métodos que no son tuyos (como los de linq o Code Contracts) porque para ello tienen un contrato. El objetivo de hacer pruebas es escribir afirmaciones que sean claras y legibles, las pruebas unitarias deben verificar el estado del propio objeto al final de la llamada y no lo que ocurre entre el principio y el fin. (Te importa el resultado final, no qué hace de por medio). Si quieres probar que dos objetos se comunican de una forma determinada, estás haciendo una prueba de integración.

  3. Hola Javier: creo que es una entrada estupenda. Realmente estupenda.
    Dejame aclarar algo que considero importante: creo que gran parte de la confución viene por lo que planteas en el punto 5. En los comentarios muchos colegas apuntaron directa o indirectamente sobre eso, y con mucha razón, ya que a primera vista no parece necesaria esa interface. ¿Cuantos loggers vamos a tener? Es la pregunta evidente y que vos bien rescataste en esta entrada.
    Ahora, el logger en el ejemplo es anecdótico, podría haber sido un repositorio o cualquier otra cosa pero el caso es que *es común crear esas interfaces solo para probar*. Si no me crees, busca en google «smtpclient mock» por ejemplo y abre los 10 primeros… ¿que hacen todos? Sí, crean un ISmtpClient. ¿Para qué? Para probar.
    Esta me parece una gran entrada porque es correcta, desde mi punto de vista, en todo lo que dice y es balanceada (tiene buen criterio) pero tomemos en cuenta que lo que hice no es algo descabellado es muy común.
    En cuanto a los mocks, qué implican, si estamos probando implementación o no, etc, etc… también estoy contigo en un 100% (pero mi intención en el ejemplo era solo desabilitar el logeo y no verificar que logeó)
    Saludos.

Responder a rfog Cancelar respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *