Testing nivel 3: ¡No se cómo hacer mi primer test!

Avatar
19 min lectura

¡Bueno!, ya tienes un bagaje bastante amplio del mundo del testing, además seguro que te ha dado por investigar más cosas de las que viste en el primer y segundo post. Pero el mundo real del desarrollador es duro, por lo tanto en este último post vamos a exponer conceptos clave para iniciarse en el mundo de testing con el menor margen de fracaso deseable. ¡No pierdas detalle!

¿Qué tengo que testar?

¿Qué es lo más importante?

En orden de importancia, vamos a enumerar lo aquello en lo que debemos centrar nuestra atención para empezar con nuestros primeros test:

Core de la lógica de negocio en general, en esto se incluyen las decisiones lógicas, como los if, switch/case, que van a derivar en un camino u otro dependiendo del estado de nuestro sistema. También englobamos aquí las operaciones matemáticas, o la algoritmia en general que tiene lugar en nuestro sistema. Las operaciones sobre conjuntos y colecciones también son candidato fiel a ser testadas en primer lugar.

Este gran grupo a priori puede ser difícil de abordar, pero si respetamos lo que hemos ido aprendiendo y los errores comunes que más tarde veremos, nos daremos cuenta de que los métodos que componen nuestro core de negocio son bastante escuetos y pueden ser abordados de una manera bastante asequible.

Haciendo este tipo de tests debemos prestar atención a los valores límite o no comunes, que con frecuencia pueden hacer que nuestro código se comporte de manera inestable. Ejemplos de esto son: números negativos, números excesivamente altos, null, cadenas vacías… Es una buena práctica intentar estresar nuestros test, hacer que los parámetros de entrada puedan ser una combinación de variables que la computadora ejecute por nosotros. Un ejemplo de esto son los parametrized de Junit. ¡Seguro que en tu lenguaje favorito también existe algo parecido!

Los valores límite con frecuencia harán que nuestro código se comporte de manera inestable Clic para tuitear

En segundo lugar tenemos la construcción de objetos. Esto puede ser un punto de fallo bastante habitual, ya que es muy probable construir objetos y estructuras de datos que no son del todo consistentes o no interactúan bien, o de la forma esperada, entre los elementos que las componen. Es habitual testar patrones de diseño creacionales como los Builder, Abstract Factory, etc. Verificar el comportamiento cuando varios objetos interactúan entre sí como vimos en el post anterior con test sociables es parte importante para comprobar el correcto funcionamiento de nuestro sistema.

Es habitual testar patrones de diseño creacionales como los Builder o Abstract Factory Clic para tuitear

Y por último, el trasiego de información entre capas, o entre diferentes ámbitos de nuestro sistema. Es decir aquello que requiere de mappers, adapters o elementos de este tipo que transforman un tipo de objeto en otro, son también un punto importante a tener en cuenta a la hora de hacer nuestros test. Un ejemplo de SUT para estos test también puede ser el parser de turno que implementemos para recoger los datos de red que esperamos.

¡Recuerda que hemos respetado un estricto orden de importancia!, no deberías hacer test de tus mappers sin haber testado nada de la lógica de negocio.

¿Y qué es lo que no es importante?

No tiene sentido testar un SDK de terceros, porque no es parte de nuestro código, y porque tendrá sus propios test, y aún así, si no los tuviera, tampoco podemos hacer mucho porque es probable que sea una caja negra y ni siquiera tengamos acceso al código. Además las librerías que usamos son susceptibles de ser cambiadas o actualizadas con el tiempo, por lo que nuestros test probablemente tendrían fecha de caducidad.

No tiene sentido testar un SDK de terceros, porque no es parte de nuestro código. Clic para tuitear

Algo parecido pasa con tests que se hacen al framework o al lenguaje usados. Por ejemplo, no tiene sentido comprobar que java hace bien un remove en una colección. Puede parecer muy claro que es una estupidez hacer este tipo de test, pero más de una vez podemos acabar testando el framework o el lenguaje sin querer, por lo que no viene mal mirarse de vez en cuando si caemos en este típico fallo.

Los getters o setters en general, o código que no hace nada más que devolver o asignar valores. Los POJO’s en Java por ejemplo y clases de este tipo… No tiene sentido probar algo que no controla nada ni tiene comportamiento.

No tiene sentido probar algo que no controla nada ni tiene comportamiento. Clic para tuitear

¿Cómo tengo que testar?

Concepto de Assert y su aplicación

Diferencia entre assert lógico y assert físico: un assert físico es una llamada al método assert de tu jUnit o el framework de testing usado. Sin embargo, más importante es el concepto de assert lógico: este es el mero hecho de comprobar que el estado de nuestro sistema es el que queremos que sea y para ello se pueden estar utilizando varios assert físicos. Este es el concepto clave para comprobar que nuestro test se adapta a lo que buscábamos.

Personalmente soy partidario de usar un assert lógico por Test, aunque se puede leer mucha literatura sobre este tema, yo pienso que lo ideal es que nuestros test contengan sólo un assert lógico y que el nombre de nuestro test vaya acorde al assert y el escenario como mencionamos en el post anterior. Con esto ganaremos mucho en legibilidad en nuestros test y documentaremos así nuestro código de una manera mejor y más limpia.

Hemos estado hablando de assert  como concepto lógico, pero como podrás intuir, el asunto del “Verify” o la verificación de comportamiento de nuestro sistema se puede tratar de una manera idéntica. Sin embargo ten en cuenta que no es conveniente mezclar comprobaciones de estado y de comportamiento dentro de un mismo test.

No es conveniente mezclar comprobaciones de estado y de comportamiento dentro de un mismo test. Clic para tuitear

¿Cómo puedo empezar con mis test?

Bien, esta es una de las preguntas claves que te habrás hecho a lo largo de tu vida como desarrollador y quizás incluso mientras leías este post. Más si cabe si has intentado hacer tests al software que has desarrollado desde tus inicios como programador. ¡Pues vamos a ver cómo!

Al principio cuando entramos en el mundo del testing e intentamos escribir los test de algo que ya está implementado por nosotros mismos, por nuestro compañeros o por quién sabe qué desarrollador,  es muy posible que nos venga a la cabeza esta típica frase:

“No tengo in idea de cómo empezar a meter mano para hacer un test de esta clase”

Pero principalmente el fallo no está en que no tengamos ni idea de como hacer un test. De hecho es bastante frustrante leer un montón de documentación, ejemplos de testing y ponerse manos a la obra para no tener idea de cómo hacer la primera línea. Aunque para aliviar esa sensación de frustración aquí tengo la clave: muy probablemente la clase está mal diseñada de cara al testing, es por eso que no vemos claro como empezar.

Si no sabes cómo empezar a testar, es muy probable que la clase no esté diseñada de cara al testing. Clic para tuitear

Entonces estarás de acuerdo conmigo en que es más que interesante ver los principales errores de programación que crean impedimentos a la hora de hacer test unitarios, ¿verdad?, vamos allá:

⁃ Estáticos: no podemos aislar una porción de código que hace uso de un estático. El test de una funcionalidad que usa un estático está condenado a ser sociable. Piensa y razona esta afirmación en base a lo que sabes y el significado del modificador static. 

Un problema frecuente es que la inicialización y declaración de variables estáticas o de clase sólo plantean dificultades a la hora de aislar nuestro código testado.

⁃ Los métodos que hacen más de una cosa: la creación de colaboradores, preparación de escenarios o la cantidad de verificaciones que tenemos que hacer cuando los métodos no respetan el hacer una y solo una cosa, crecen de manera exponencial.

⁃ Los métodos que tienen complejidades ciclomáticas elevadas: también hacen que crezcan de manera exponencial los escenarios, colaboradores a preparar y la cantidad de resultados de estado y comportamiento en los cuales desembocan normalmente. Un nivel de bucle anidado mayor a dos empieza a ser demasiado para que el SUT sea testable.

Intenta evitar para no generar ruido indeseable e incomodidades de cara al testing:

⁃ Los métodos privados.

⁃ Los métodos y clases finales.

⁃ El uso de “new” o la creación de dependencias en forma de nuevos objetos.

Que incluyamos los métodos privados en la lista anterior, no quiere decir que tengamos que exponer todos nuestros métodos para poder testarlos, de hecho mientras menos métodos se expongan mejor. Sin embargo puede ser un fallo de diseño si tenemos una clase que hace demasiadas cosas y tiene métodos públicos y privados en abundancia. En lugar de eso podrás usar varias clases que tienen una labor concreta e interactúan entre sí usando métodos públicos que son su interfaz, estas clases tras la división también tendrán esos métodos privados para lo que realmente no deben dar a conocer, pero en este caso el número de métodos privados no será tan elevado y será mucho más fácil hacer uso de los métodos públicos en nuesro test para dar cobertura al 100% de la clase. Al fin y al cabo esto no es más que hacer caso al principio de responsabilidad única. 

También podemos ver en este listado el uso de la palabra “new”, o al fin y al cabo la creación de una nueva instancia dentro de nuestro código que será testado. En otras palabras y haciendo referencia al primer post, estamos creando una dependencia bastante fuerte. Para evitar esto, como bien sabemos hemos de usar la inversión de dependencias.

“Evitando todos estos fallos comunes conseguiremos una cantidad considerable de código testable”

De lo contrario, nuestro código no va a poder darnos facilidad para hacer nuestros test y puede incluso que la cantidad de código testable, sea inferior de lo deseable. Por ejemplo, no podemos llegar a una cobertura de código de 100% porque el 100% de nuestro código no es testable desde un punto de vista unitario.

Tener consciencia de las limitaciones

Ten en cuenta que no podemos volvernos expertos de testing y querer abarcar todo nuestro software en un día ni en un mes. Es bueno tener consciencia de ciertas limitaciones…

Sobre el mito de la cobertura 100%: es un mito a la vez que una locura pretender tener testado el 100% de nuestro código, sería una pérdida de tiempo porque como hemos visto hay muchas partes que no merece la pena testar. Además hay partes de código que ya tendrán su cobertura con otro tipo de test que no sean unitarios, ex: UI, integración…

Sobre el legacy code: Es posible que incluir test en un proyecto legacy requieran un refactor anterior del código, pero las nuevas piezas no lo requieren y son candidatas a programarse con consciencia de ser SUT. Con esto, de nuevo desde mi opinión, no merece la pena intentar hacer test sobre un código no testable, nos puede traer muchos quebraderos de cabeza. Habría que entrar en una fase de refactor para acometer dichos cambios. Sin embargo los desarrollos que vengan como extensión del sistema sí son fieles candidatos a tener test.

Mantenimiento de los test

“Es importante que tengas en cuenta que el mantenimiento de los test es tu responsabilidad y no debes huir de ella”

El mantenimiento es una parte muy, muy importante de los test. Esto es debido a que los test son código también y viven por y para el código de producción. Los test cambian con el código y esto quiere decir que:

Si y sólo si una funcionalidad o comportamiento del sistema cambia, los test deben cambiar y ser mantenidos con ella.

Si una funcionalidad del sistema cambia, los test deben cambiar y ser mantenidos con ella Clic para tuitear

Esto es importante porque los test no han de cambiar bajo un refactor, hacer que los test cambien tras un refactor desencadenarían en esos test falsos positivos que dejan pasar una funcionalidad que está mal refactorizada. Presta atención a este punto porque es un error bastante recurrente, es una tentación presente en nuestro día a día que debemos evitar.

Como puedes ver en cierto modo, “nuestros tests también respetan el principio OPEN / CLOSE”, respetan el mismo ciclo de vida que nuestro software.

TDD: Test Driven Development

Se ha hablado mucho de este concepto y hay un libro muy bueno sobre “Test driven Development” de Kent Beck. Aquí vamos a hacer sólo una pequeña introducción.

Es una herramienta que se usa para el diseño y el desarrollo de software, y no es una herramienta para Testing como tal. Como su nombre indica, en esta herramienta son los test los que guían al desarrollo, por lo tanto es es requisito indispensable escribir los test antes de desarrollar la funcionalidad.

Por lo tanto si no vamos a escribir los test antes que el código real no estaremos usando TDD. Ojo: no hay problema ninguno en no usar TDD, se pueden conseguir magníficos resulados sin su uso. Sin embargo sí nos conviene pensar en cierto modo como se piensa en TDD. En otras palabras, tenemos que pensar en desarrollar un código que será testado, por lo tanto tiene que ser testable, esto no es más que evitar cometer los fallos que se han descrito anteriormente.

Al principio escribiremos código que creemos que es testable que vamos a tener que refactorizar. Esto es normal, de hecho es el proceso habitual que vamos a seguir en nuestro aprendizaje y es bueno. Así iremos dándonos cuenta de los fallos de la mejor manera: con la experiencia que nos van aportando los sucesivos casos de test que desarrollamos.

Lo que podemos llegar a conseguir

Como no podía ser menos, el incluir test nos aporta muchas ventajas, te dejo algunas de ellas:

Código funcionalmente correcto, o casi… esto es algo que se consigue con la práctica. Es posible que nuestros test no validen bien y de forma completa nuestros requisitos, sobre todo cuando estemos empezando, como te comentaba. Pero aún así, es mejor que no tenerlos porque entrenaremos nuestra habilidad para implementar casos de test. Este entrenamiento nos traerá de la mano que poco a poco vayamos detectando fallos, al principio de programación, como referencias nulas o arrays fuera de rango quizás, pero poco a poco iremos descubriendo más y más errores de funcionamiento. Esta detección nos ayudará a mejorar el entregable definitivo de nuestro sistema y a reducir su número de “bugs”. Al fin y al cabo estaremos validando de mejor manera lo que tiene que hacer nuestro código.

Como hemos podido ver, el centrarnos en un desarrollo que admita tests de una manera sencilla guía nuestro código por el camino Clean y SOLID, como ya bien sabes. No hace falta volver a nombrar las ventajas de esto ¿verdad?. El ciclo, como puedes ver se retroalimenta.

Centrarnos en un desarrollo que admita tests guía nuestro código por el camino Clean y SOLID. Clic para tuitear

Los test también nos van a ayudar a perder el miedo al “refactoring”. Esto es una gozada, sin duda cambiar código que está testado es mucho más fácil, porque podemos pasar nuestros test al finalizar el “refactor” y comprobar que todo ha quedado en el mismo estado en el que estaba antes de “meter mano” a ese código.

Dormir más tranquilos porque la integración continua pasa los test cada noche por nosotros. ¡Sí señor!, hay una herramientas como Jenkins o Travis, entre muchas, que pueden automatizar el paso de nuestros test, para una hora concreta, para cada vez que se sube al repositorio, e infinidad de cosas más. Con lo cuál no tenemos que vivir preocupados por haber pasado o no los test cada vez que hacemos un cambio por mínimo que sea, cosa que es recomendable al 100%, porque sabemos que en algún momento el CI lo pasará y nos puede avisar de que algo ha fallado.

Hay muchas más ventajas que irás descubriendo por tu cuenta, así que ¡¡dale caña a tus tests!!.

Ejemplo

Como lo prometido es deuda aquí dejamos un ejemplo del test. Iré comentando por cada trozo de código. Primero podemos encontrar nuestro SUT:

Lo que ha de hacer el software es comprobar en base a todos los datos del usuario que no sean el email si se trata de un usuario duplicado o no. Esto es así porque el sistema no permite usuarios duplicados pero si permite usuarios que usen el mismo mail. Es un poco tonto el ejemplo pero nos servirá.

public class UserDuplicityChecker {

    ApiService apiService;

    UserDuplicityChecker(ApiService apiService) {
        this.apiService = apiService;
    }

    public boolean areSameUsers(User newUser) {

        User oldUser = apiService.getUserWithEmail(newUser.getEmail());

        return oldUser.getDocumentId().equals(newUser.getDocumentId()) &
            amp; & amp;
        oldUser.getName().equals(newUser.getName());

    }
}

Puedes ver que es una clase muy sencilla con sólo método público, pero es suficiente para un test, hace una comprobación de dos campos con un if, nada complicado.

public interface ApiService {
    User getUserWithEmail(String s);
}

Hemos creado esta interfaz para no acoplarnos a una dependencia concreta, como puedes ver, nuestro SUT hace uso de ella. ¡Estamos usando inversión de dependencias!

public class MyFakeApiService implements ApiService {
    @Override public User getUserWithEmail(String email) {
        return new UserTestBuilder()
            .name("Jose")
            .documentId("myTestId")
            .email(email)
            .build();
    }
}

Si pensabas que esto de arriba tiene forma de “Fake” – “Test Double” ¡es exactamente eso!, además como puedes ver hace uso de un Builder que hemos creado para nuestros tests y puedes verlo aquí abajo.

public class UserTestBuilder {

    private String name;
    private String documentId;
    private String email;

    public UserTestBuilder name(String name) {
        this.name = name;
        return this;
    }

    public UserTestBuilder documentId(String documentId) {
        this.documentId = documentId;
        return this;
    }

    public UserTestBuilder email(String email) {
        this.email = email;
        return this;
    }

    public User build() {
        return new User(name, documentId, email);
    }

}

La clase “User” es una clase cualquiera de nuestro dominio.

public class User {

    private final String name;
    private final String email;
    private final String documentId;

    public User(String name, String documentId, String email) {
        this.name = name;
        this.email = email;
        this.documentId = documentId;
    }

    public String getName() {
        return name;
    }

    public String getDocumentId() {
        return documentId;
    }

    public String getEmail() {
        return email;
    }

}

Y por último en el test puedes ver cómo todos los elementos que hemos ido implementando van interactuando.

@Test
public void shouldBeSameUsersWhenCheckingUserTakesFakeSameValues() {

    ApiService apiService = new MyFakeApiService();
    User user = new User("Jose", "myTestId", "testEmail@test.com");

    UserDuplicityChecker userChecker = new UserDuplicityChecker(apiService);
    boolean sameUsers = userChecker.areSameUsers(user);

    assertTrue(sameUsers);
}

En la primera línea puedes ver cómo instanciamos el Fake que implementa la interfaz que nuestro SUT espera. A continuación, preparamos nuestros datos de entrada para el test. Como puedes suponer las dos siguientes líneas son el “Arrange”. Luego tenemos dos líneas en las cuales instanciamos nuestro SUT y ejecutamos el método que entra en el “scope” de este test, esto es el “Act”. Por último en la última línea hacemos el “Assert”, es decir, comprobamos que el estado es lo que esperábamos. Como último detalle fíjate en el método y en lo que hace todo el test. Tiene sentido el nombre que hemos puesto ¿verdad?.

Aún así, la forma de implementar el test es mejorable porque podríamos haber usado un “Factory” para pasarle el valor al “Fake” de ApiService y así poder crear varios escenarios en nuestros siguientes métodos de “@Test”.

En este caso hemos creado el Test double a mano, pero hay herramientas como “Mockito” que ayudan en estas labores, tienes la referencia al final del artículo.

Conclusión

Pues tengo poco más que decir, sobre este tema, creo que ya he dicho bastante, ahora es tu turno, te toca actuar y recuerda esta frase, que aplica no solo al mundo del testing sino a muchos más: “La práctica hace la perfección”, así que una vez empieces con el mundo del testing, no lo dejes de lado, mantente practicando test.

No quería acabar sin antes dar las gracias a la comunidad Android en general que me ha ayudado bastante a introducirme en el mundo de testing y entre ellos en particular a Pablo Guardiola, ex-compi de curro y un grande en esto de hacer tests y muchas más cosas. A José Benito, que curra conmigo en Gigigo y está haciendo una gran labor para enseñarnos a todos mucho sobre test unitarios. Por supuesto también gracias a Antonio Leiva por dejarme poner mi granito de arena en este blog.

Sin más me despido, pero no tengáis duda que volveré por aquí pronto a hacer alguna que otra aportación más así que si no quieres perder detalle ¡únete a la lista de correo!, saludos.