Hoy vas a aprender mucho con este sencillo ejemplo práctico de TDD (Test-Driven Development). Es una metodología de desarrollo de software que consiste, básicamente, en escribir antes los tests que la funcionalidad. De esta forma, conseguimos numerosas ventajas. Para empezar, una gran covertura de código. Y esque, en este caso, la covertura de código es una consecuencia de la metodología, no una simple meta a alcanzar (¿te suenan los assert(true)?). Además, conseguimos un diseño totalmente enfocado a las necesidades y requisitos el proyecto, ya que cada test sería (más o menos) reprensentativo de un caso de uso.
Antes de nada, permíteme avisarte de que este extenso artículo está hecho para gente con una experiencia moderada desarrollando. Si no tienes experiencia con Spring Boot o un framework similar, no sabes lo que es una API rest o cómo funciona por dentro, o no sabes distinguir entre controlardores, servicios, DAOS y POJOS, entonces primero aprende todo eso, coge experiencia, y con todo eso puedes empezar a mejorar tu forma de desarrollar apliando TDD.
Pero no, no voy a seguir explicando lo que es TDD ni sus ventajas o desventajas, que esto ya se lleva muchos años discutiendo y yo no aportaría nada nuevo. Hoy, aprovechando un proyecto personal que estoy desarrollando, os voy a guiar en un sencillo ejemplo para ver cómo funciona TDD. Para ello, cuento con una API Rest desarrollada con Spring Boot.
Controlador
A ver, vamos a permitirnos pecar un poco: tengo el controlador ya «implementado». Me explico: no hace nada, pero ya está creado. Esto sería hacer trampa porque, repito, TDD se basa en que lo primero de todo (TODO) son los tests. Pero bueno, aún así nos vale para una primera impresión.
El código base sería algo así:
@RestController public class CategoryController { @GetMapping(value = "/hello") @ResponseBody public String sayHello() { return "Hello"; } @GetMapping(value = "/categories") @ResponseBody public String getAllCategories() { return "Goodby"; } }
Si hacemos una petición get (con Postman, o con el navegador, por ejemplo) obtendríamos os resultados que vemos (Hello y Goodby). Vamos a implementar un test unitario que pruebe esto:
class CategoryControllerTest { CategoryuController sut = new CategoryController(); @Test void whenSayHello_shouldReturnStringHello() { String result = sut.sayHello(); assertEquals("Hello", result); } @Test void whenSayGoodby_shouldReturnStringGoodby() { String result = sut.getAllCategories(); assertEquals("Goodby", result); } }
Lo que acabamos de hacer no es TDD porque primero hemos desarrollado la funcionalidad que devuelve «Hello» y «Goodby». Pero nos permite instanciar una base para que ahora sí funcione. El test de «sayHello» lo vamos a dejar así. Pero el test de getAllCategories, ovbiamente, no está testeando la funcionalidad real. Así que vamos a ver qué podemos hacer. Vayamos paso por paso. Lo que vamos a hacer es, en un inicio, algo con poco sentido. Pero veréis como al final desarrollamos todas las capas y, usando TDD, todo queda bien testeado.
Mejorando las pruebas de funcionalidad
Vamos a hacer nuestro negocio un poco más complejo. El método CategoryController.getAllCategories deberá devolver una lista de categorías. Vamos a implementar, ahora sí, primero el test:
class CategoryControllerTest { CategoryController sut = new CategoryController(); @Test void whenSayHello_shouldReturnStringHello() { String result = sut.sayHello(); assertEquals("Hello", result); } @Test void whenGetAllCategories_shoulReturnListOfCategories() { ArrayList<Category> expected = this.getListOfCategories(); ArrayList<Category> result = sut.getAllCategories(); assertEquals(result, expected); } private ArrayList<Category> getListOfCategories(){ ArrayList<Category> categories = new ArrayList<>(); Category category = new Category(1L, "Category 1", "Description 1"); categories.add(category); category = new Category(2L, "Category 2", "Description 2"); categories.add(category); category = new Category(3L, "Category 3", "Description 3"); categories.add(category); return categories; } }
Ahora nuestro test es un poco más real: espera que nuestro «getAllCategories» devuelva una lista concreta de categorías. Como es de esperar, nuestro test no funciona. Es más, ni siguiera compila, porque ahora nuestro método debe devolver un ArrayList de categorías y devuelve un String.
Comenzamos a ver ahora un poco cómo funciona TDD: el test con la nueva funcionalidad (que en este caso decidimos nosotros, pero puede ser un nuevo requisito del cliente) nos lleva a modificar la implementación de nuestro método. Quedando algo así:
@GetMapping(value = "/categories") @ResponseBody public ArrayList<Category> getAllCategories() { ArrayList<Category> categories = new ArrayList<>(); Category category = new Category(1L, "Category 1", "Description 1"); categories.add(category); category = new Category(2L, "Category 2", "Description 2"); categories.add(category); category = new Category(3L, "Category 3", "Description 3"); categories.add(category); return categories; }
Como podemos ver, ahora nuestro método testeado ya devuelve un ArrayList de categorías. Y, además, devuelve la misma lista que espera nuestro test. Por tanto, nuestro test ya debería funcionar, ¿verdad?. Pues no amigos. Esta prueba dará error porque, aunque son dos listas idénticas en cuanto al contenido, realmente no son las mismas listas. Es decir, tienen objetos con las mismas propiedades pero no son los mismos objetos. ¿Como podemos hacer un assert de esto? Lo más sencillo para salver este paso sin complicarnos mucho la vida es hacer varios asserts:
@Test void whenGetAllCategories_shoulReturnListOfCategories() { ArrayList<Category> expected = this.getListOfCategories(); ArrayList<Category> result = sut.getAllCategories(); assertEquals(expected.size(), result.size()); for (int i = 0; i < 3; i++) { assertEquals(expected.get(i).getName().toString(), result.get(i).getName().toString()); } }
De esta forma estaríamos compromando estrictamente el contenido de los objetos. Y deberíamos, para hacerlo realmente bien, hacer los asserts del id y descripción, aparte del nombre. Pero hemos entendido el concepto, ¿no?
Pero claro, estarás pensando: esto tampoco es útil. Ahora ya nuestro método nos devuelve una lista, pero no es la lista correcta. ¿Por qué? Porque no hace nada, devuelve siempre la misma lista (pero que listo soy!). Ahora vamos a ver al cien por cien la magia de TDD: vamos a crear el servicio guiándonos con los tests.
Servicio
Nuestra clase CategoryController no tiene un servicio. De hecho, el hipotético objeto «CategoryService» no existe. Pero, como estamos con TDD, vamos a hacer primero nuestro test que nos va a guiar en cómo debemos modificar nuestro controlador para que haga lo que tenga que hacer:
Primero, en nuestro test, declaramos nuestro CategoryService:
CategoryService categoryService = new CategoryService;
Nos dará error de sintaxis porque, obviamente, nuestro objeto NO EXISTE TODAVÍA. Pero no te preocupes, los IDEs son muy buenos. IntelliJ nos permite hacer esto, observa:
Nos preguntará en qué paquete queremos crear nuestra clase (tampoco existe el paquete service, así que también lo crearemos). Y ahora ya tenemos, gracias a los tests, la case CategoryService. Todavía no vamos a implementarla, vamos a dejar que los tests nos lleven.
Nuestra clase vacía pinta tal que así (como pinta cualquier clase vacía):
package com.urbanojvr.mon3x.service; public class CategoryService { }
Lo siguiente que nuestro test nos dicta es que, para poder usar el servicio desde el controlador, este debe tenerlo como dependencia, así que también nos va a obligar a cambiar de nuevo la implementación de nuestra clase CategoryController:
Primero, en el test dejamos la parte de la declaración de objetos tal que así:
@Mock CategoryService categoryService; CategoryController sut = new CategoryController(categoryService);
Una vez más, el IDE nos dice que eso no está bien, pues el constructor de CategoryController no recibe ningún objeto CategoryService como dependencia. Nuevamente, el IDE nos ayudará:
Nos crea el constructor vacío, y nosotros completamos un poquito para hacer que funcione, quedando así:
@RestController public class CategoryController { private CategoryService categoryService; @Autowired public CategoryController(CategoryService categoryService) { this.categoryService = categoryService; }
El IDE se queja porque, obviamente, no encuentra ningún bean de CategoryService. Lo solucionamos con su anotación correspondiente @Service en la clase CategoryService.
Insisto: fíjate en que, otra vez, el simple hecho de ir desarrollando un test nos está llevando a crear todas las capas adecuadas. Piensa que es el test el que nos hace preguntarnos ¿Qué funcionalidad debe tener y cómo la llevará a cabo? Y, por supuesto, como desarrollador debes saber qué respuestas dar a esas preguntas siguiendo los estándares propios del lenguaje y principios básicos de código limpio y buenas prácticas. Si no resuelves correctamente esto, de poco sirve TDD.
Volvamos al test. Una vez que ya hemos solventado las clases, constructores e inyección de dependencias inexistentes, podemos implementar lo que a partir de ahora va a ser, a falta de refactorizar, la verdadera funcionalidad de nuestro método.
Antes de enseñarte el código, debemos tener en cuenta que la respuesta correcta a la pregunta ¿qué debe hacer el método CategoryController.getAllCategories()? es, simplemente, que debe hacer estas dos cosas:
- Llamar al servicio
- Devolver la misma lista de categorías que le ha dado el servicio
Y nada más. Ya lo he dicho, pero vuelvo a insistir. Si, como desarrollador, empiezas a complicarte la vida metiendo funcionalidad al controlador que no debes, entonces TDD servirá de poco y te remito al aviso que he dado al principio: sigue cogiendo experiencia programando y testeando hasta que compredas y estes familiarizado con todas las demás buenas prácticas.
Así que, el código del test sería algo así:
@Test void whenGetAllCategories_shoulReturnListOfCategories() { ArrayList<Category> expected = this.getListOfCategories(); doReturn(expected).when(categoryService).getAll(); ArrayList<Category> result = sut.getAllCategories(); verify(categoryService).getAll(); assertEquals(expected, result); }
Y sí: el método getAll() no existe en CategoryService. De nuevo, usamos el IDE para crearlo:
Por ahora, lo creamos vacío y no lo implementamos.
@Service public class CategoryService { public ArrayList<Category> getAll() { return new ArrayList<>(); } }
Bueno vale, tiene un poco de implementación porque devuelve un ArrayList vacío, no había otra para poder compilar.
Volviendo al código del test, lo que hacemos es:
- Guardar la lista de categorías esperadas en una variable (la lista de categorías esperadas la crea un método específico)
- Con doReturn indicamos que, cuando el método del servicio sea llamado, queremos que devuelva la lista que hemos especificado (vamos, la misma)
- Ejecutamos la llamada al método del sut que estamos testeando. Como el servicio es un mock y le hemos especificado qué queremos que nos devuelva, no hace falta que el método del servicio esté implementado: lo implementaremos en un futuro con TDD cuando lo testeemos. ¿Ves como comienzan a unirse los eslabones de la cadena?
- Por último, verificamos que se llama al método del servicio. Esto último da error porque todavía nuestro controlador no llama al servicio. ¿Ves? De nuevo los tests nos dicen lo que tenemos que hacer (que pesado soy eh).
Así que manos a la obra. Implementamos la llamada al servicio en nuestro método testeado:
@GetMapping(value = "/categories") @ResponseBody public ArrayList<Category> getAllCategories() { ArrayList<Category> categories = categoryService.getAll(); return categories; }
¡Cada vez estamos más cerca! Ahora nuestro test ya funciona porque cumple las dos premisas: llama al servicio y devuelve lo mismo que el servicio le da, sin cambiarlo. Podemos comprobar también las veces que el método llama al servicio, de forma que (en nuestro caso) si lo llamara dos veces, por ejemplo, algo iría mal (o hemos cambiado la funcionalidad). Además de meter la verificación de número de llamadas, le cambiamos el nombre para que indique mejor qué hace:
@Test void whenGetAllCategories_shouldCallServiceAndReturnTheCorrectList() { ArrayList<Category> expected = this.getListOfCategories(); doReturn(expected).when(categoryService).getAll(); ArrayList<Category> result = sut.getAllCategories(); verify(categoryService, times(1)).getAll(); assertEquals(expected, result); }
Refactorización
La etapa final de TDD incluye refactorizar la implementación. En nuestro caso, he ido dejando cosas que se pueden mejorar.
El método getListOfCategories de nuestra clase de test se puede refactorizar mucho:
private ArrayList<Category> generateCategoriesList(int wantedSize) { ArrayList<Category> categories = new ArrayList<>(); for (int i = 0; i < wantedSize; i ++){ Category category = new Category((long) i, "Category " + i, "Description " + i); categories.add(category); } return categories; }
Gracias a un bucle creamos tantas categorías como queramos, de forma que reducimos las líneas de código y, además, podremos reutilizar este método en otras ocasiones. A futuro, según vayamos desarrollando, si usamos ese mismo trozo de código en otras clases, lo refactorizaremos extrayéndolo fuera de esta clase. Por ahora, ahí se queda.
Conclusión
Al principio puede parecer algo complicado o incluso una pérdida de tiempo. Sin embargo, un código con muy buena covertura supone, entre otras ventajas, una gran red de seguridad a la hora de aplicar cualquier cambio en el sistema. Sin tests, ¿cómo sabemos que un cambio en un archivo no tira el sistema por otro lado? Todo esto conlleva una muy baja deuda técnica, aparte de que los tests ayudan a entender el comportamiento del software a nuevos miembros. Sí estoy de acuerdo en que el tiempo de desarrollo con tests es, ovbiamente, mayor a cualquier desarrollo sin tests. Pero, ¿y después que? Además, TDD nos ayuda a hacernos aquellas preguntas que nos permitirán dotar de mayor modularidad y limpieza a nuestro código.
Deja una respuesta