Cómo generar tests automaticamente con Diffblue

0
1350
Diffblue-Logo-Blue-resized
Diffblue-Logo-Blue-resized

En anteriores artículos hemos hablado de la importancia de los test en el desarrollo de software y hemos intentado que una IA como copilot nos ayudase a realizarlos. En este artículo vamos a ver como podemos generar test automáticamente con Diffblue que es una herramienta pensada solo para eso.

0. Índice de contenidos.

  1. Introducción.
  2. Entorno.
  3. Caso básico de prueba.
  4. Primer refactor.
  5. Hombre vs maquina.
  6. Conclusiones.
  7. Referencias.

1. Introducción.

Diffblue es una empresa que surgió de la Universidad de Oxford en 2016. Desarrollan Diffblue Cover que es una herramienta basada en IA que automatiza el proceso de escribir pruebas en Java. Escribe las pruebas en un formato legible por humanos y que mantiene automáticamente a medida que el código evoluciona.

Diffblue Cover está disponible como un plugin para IntelliJ y también cuenta con una versión gratuita llamada Cover Community Edition, que puede ser utilizada tanto por organizaciones de código abierto como comerciales y será lo que usemos.

La lucha contra el código heredado, es una lucha que siempre estará presente en el desarrollo de software.

Michael Feathers en su libro Working Effectively with Legacy Code define el código heredado como «código sin tests».

Desde distintas ópticas se ha visto el problema como por ejemplo:

2. Entorno.

El tutorial está escrito usando el siguiente entorno:

  • Hardware: Portátil MacBook Pro 16′ (2,3 GHz 8-Core Intel Core i9 16 GB 2667 MHz DDR4).
  • Sistema Operativo: Mac OS Ventura 13.3.1
  • IntelliJ IDEA 2023.1.1 (Ultimate Edition)

3. Caso básico de prueba.

Para este tutorial, vamos a usar Cover Community Edition que es un plugin para intellij que se puede descargar e instalar del enlace anterior siguiendo las instrucciones.

En principio, se iban a usar los mismos ejemplos que en el anterior articulo, pero nos encontramos con el siguiente error al dar a generar los primeros tests (veremos como se hace a continuación).

version de spring boot muy reciente

Esto es porque usamos una version de spring boot que es muy reciente y seguramente diffblue no este aun entrenada con esa version de spring boot (al menos la version community).

El ejemplo que planteamos de Diffblue se basa en el siguiente código:

https://github.com/spring-guides/gs-spring-boot/tree/boot-2.7

Lo único que se le ha actualizado la versión a la v2.7.11 ya que el repositorio cuando se estaba escribiendo esto, estaba en la v2.7.6, y con la v3.0.6 nos daba error como hemos visto anteriormente.

Se ha elegido el repositorio de las guías de spring porque está actualizado y es conocido por todo el mundo. Lo importante aquí no es tanto el repositorio, sino las ideas y la ayuda que nos puede proporcionar la herramienta.

Dentro de ese repositorio tenemos varias carpetas, pero las más relevantes son initial y complete.

Initial nos da básicamente un único controlador rest que nos da un saludo.

@RestController
public class HelloController {

    @GetMapping("/")
    public String index() {
        return "Greetings from Spring Boot!";
    }

}

Aunque muy básico, es un ejemplo perfecto de un proyecto legado que no tiene test.

Como vemos, al lado de la clase o método, nos aparece un nuevo icono para que podamos generar los tests.

Icono que nos muestra diffblue para crear tests en un método

Icono que nos muestra diffblue para crear tests en una clase

Si le damos a generar tests, obtenemos el siguiente resultado (en la ruta correspondiente de test en el fichero HelloControllerTest.java):

package com.example.springboot;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.result.MockMvcResultMatchers;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;

@ContextConfiguration(classes = {HelloController.class})
@ExtendWith(SpringExtension.class)
class HelloControllerTest {
    @Autowired
    private HelloController helloController;

    /**
     * Method under test: {@link HelloController#index()}
     */
    @Test
    void testIndex() throws Exception {
        MockHttpServletRequestBuilder requestBuilder = MockMvcRequestBuilders.get("/");
        MockMvcBuilders.standaloneSetup(helloController)
                .build()
                .perform(requestBuilder)
                .andExpect(MockMvcResultMatchers.status().isOk())
                .andExpect(MockMvcResultMatchers.content().contentType("text/plain;charset=ISO-8859-1"))
                .andExpect(MockMvcResultMatchers.content().string("Greetings from Spring Boot!"));
    }

    /**
     * Method under test: {@link HelloController#index()}
     */
    @Test
    void testIndex2() throws Exception {
        MockHttpServletRequestBuilder requestBuilder = MockMvcRequestBuilders.get("/", "Uri Variables");
        MockMvcBuilders.standaloneSetup(helloController)
                .build()
                .perform(requestBuilder)
                .andExpect(MockMvcResultMatchers.status().isOk())
                .andExpect(MockMvcResultMatchers.content().contentType("text/plain;charset=ISO-8859-1"))
                .andExpect(MockMvcResultMatchers.content().string("Greetings from Spring Boot!"));
    }
}

Los nombres de los tests son secuenciales y habría que intentar cambiarlos para que fueran más descriptivos. También nos ha generado dos tests, uno con parámetros y otro sin parámetros.

Más allá de los nombres de los tests ya tenemos una red de seguridad para poder refactorizar el código. Los test prueban cosas que no se suelen probar como el contentType porque se suele asumir que la excepción es que no sea Json. El segundo test también es más rebuscado porque está probando parámetros opcionales que no se usan.

4. Primer refactor.

Algo que no es muy normal, es el content type que nos devuelve el ejemplo. Es un text/plain, cuando lo normal es que sea un application/json. Esto es algo que se puede mejorar y que el siguiente programador que entre en el proyecto, seguro que está tentando de cambiar. También devolver un ResponseEntity en vez de un String, para poder devolver un 404 o un 500 en caso de error. Manos a la obra.

@RestController
public class HelloController {

    @GetMapping(path = "/", produces = "application/json")
    public ResponseEntity index() {
        return new ResponseEntity("Greetings from Spring Boot!", HttpStatus.OK);
    }

}

¡Mucho mejor!

Ejecutamos los tests y nos fallan. Esto es normal, ya que hemos cambiado el comportamiento del método. Pero no pasa nada, porque tenemos los tests para asegurarnos que si cambiamos cosas y algo falla, saber que es. Acabamos de coger la primera potencial regresión.

Si volvemos a generar los tests, nos genera dos nuevos.

package com.example.springboot;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.result.MockMvcResultMatchers;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;

@ContextConfiguration(classes = {HelloController.class})
@ExtendWith(SpringExtension.class)
class HelloControllerTest {
    @Autowired
    private HelloController helloController;

    /**
     * Method under test: {@link HelloController#index()}
     */
    @Test
    void testIndex() throws Exception {
        MockHttpServletRequestBuilder requestBuilder = MockMvcRequestBuilders.get("/");
        MockMvcBuilders.standaloneSetup(helloController)
                .build()
                .perform(requestBuilder)
                .andExpect(MockMvcResultMatchers.status().isOk())
                .andExpect(MockMvcResultMatchers.content().contentType("text/plain;charset=ISO-8859-1"))
                .andExpect(MockMvcResultMatchers.content().string("Greetings from Spring Boot!"));
    }

    /**
     * Method under test: {@link HelloController#index()}
     */
    @Test
    void testIndex2() throws Exception {
        MockHttpServletRequestBuilder requestBuilder = MockMvcRequestBuilders.get("/", "Uri Variables");
        MockMvcBuilders.standaloneSetup(helloController)
                .build()
                .perform(requestBuilder)
                .andExpect(MockMvcResultMatchers.status().isOk())
                .andExpect(MockMvcResultMatchers.content().contentType("text/plain;charset=ISO-8859-1"))
                .andExpect(MockMvcResultMatchers.content().string("Greetings from Spring Boot!"));
    }

    /**
     * Method under test: {@link HelloController#index()}
     */
    @Test
    void testIndex3() throws Exception {
        MockHttpServletRequestBuilder requestBuilder = MockMvcRequestBuilders.get("/");
        MockMvcBuilders.standaloneSetup(helloController)
                .build()
                .perform(requestBuilder)
                .andExpect(MockMvcResultMatchers.status().isOk())
                .andExpect(MockMvcResultMatchers.content().contentType("application/json"))
                .andExpect(MockMvcResultMatchers.content().string("Greetings from Spring Boot!"));
    }

    /**
     * Method under test: {@link HelloController#index()}
     */
    @Test
    void testIndex4() throws Exception {
        MockHttpServletRequestBuilder requestBuilder = MockMvcRequestBuilders.get("/", "Uri Variables");
        MockMvcBuilders.standaloneSetup(helloController)
                .build()
                .perform(requestBuilder)
                .andExpect(MockMvcResultMatchers.status().isOk())
                .andExpect(MockMvcResultMatchers.content().contentType("application/json"))
                .andExpect(MockMvcResultMatchers.content().string("Greetings from Spring Boot!"));
    }
}

Nos ha añadido dos tests más, ya que los métodos han cambiado, los tests que ya existían los deja como estaban. Ahora nosotros veremos si hay que borrarlos o que hacemos con ellos. En este caso los vamos a borrar. Algo que está bien, es que es consistente con lo que genera, para ese controlador, siempre genera 2 tests. Muchas veces, más que la exactitud de las respuestas, lo que se busca es la consistencia para que no sea una sorpresa.

package com.example.springboot;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.result.MockMvcResultMatchers;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;

@ContextConfiguration(classes = {HelloController.class})
@ExtendWith(SpringExtension.class)
class HelloControllerTest {
    @Autowired
    private HelloController helloController;

    /**
     * Method under test: {@link HelloController#index()}
     */
    @Test
    void testIndex3() throws Exception {
        MockHttpServletRequestBuilder requestBuilder = MockMvcRequestBuilders.get("/");
        MockMvcBuilders.standaloneSetup(helloController)
                .build()
                .perform(requestBuilder)
                .andExpect(MockMvcResultMatchers.status().isOk())
                .andExpect(MockMvcResultMatchers.content().contentType("application/json"))
                .andExpect(MockMvcResultMatchers.content().string("Greetings from Spring Boot!"));
    }

    /**
     * Method under test: {@link HelloController#index()}
     */
    @Test
    void testIndex4() throws Exception {
        MockHttpServletRequestBuilder requestBuilder = MockMvcRequestBuilders.get("/", "Uri Variables");
        MockMvcBuilders.standaloneSetup(helloController)
                .build()
                .perform(requestBuilder)
                .andExpect(MockMvcResultMatchers.status().isOk())
                .andExpect(MockMvcResultMatchers.content().contentType("application/json"))
                .andExpect(MockMvcResultMatchers.content().string("Greetings from Spring Boot!"));
    }
}

5. Hombre vs maquina.

El proyecto venía con tests de integración y tests unitarios. Diffblue no genera tests de integración, solo tests unitarios. Esto es algo que hay que tener en cuenta, ya que además de los unitarios, es interesate tener tests de integración. Pero vamos a ver como son los tests del proyecto frente a los generados.

Generando los tests nos queda así

package com.example.springboot;

import static org.hamcrest.Matchers.equalTo;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.result.MockMvcResultMatchers;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;

@ExtendWith(SpringExtension.class)
@SpringBootTest
@AutoConfigureMockMvc
public class HelloControllerTest {

    @Autowired
    private HelloController helloController;

    @Autowired
    private MockMvc mvc;

    @Test
    public void getHello() throws Exception {
        mvc.perform(MockMvcRequestBuilders.get("/").accept(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk())
                .andExpect(content().string(equalTo("Greetings from Spring Boot!")));
    }

    /**
     * Method under test: {@link HelloController#index()}
     */
    @Test
    void testIndex() throws Exception {
        MockHttpServletRequestBuilder requestBuilder = MockMvcRequestBuilders.get("/");
        MockMvcBuilders.standaloneSetup(helloController)
                .build()
                .perform(requestBuilder)
                .andExpect(MockMvcResultMatchers.status().isOk())
                .andExpect(MockMvcResultMatchers.content().contentType("text/plain;charset=ISO-8859-1"))
                .andExpect(MockMvcResultMatchers.content().string("Greetings from Spring Boot!"));
    }

    /**
     * Method under test: {@link HelloController#index()}
     */
    @Test
    void testIndex2() throws Exception {
        MockHttpServletRequestBuilder requestBuilder = MockMvcRequestBuilders.get("/", "Uri Variables");
        MockMvcBuilders.standaloneSetup(helloController)
                .build()
                .perform(requestBuilder)
                .andExpect(MockMvcResultMatchers.status().isOk())
                .andExpect(MockMvcResultMatchers.content().contentType("text/plain;charset=ISO-8859-1"))
                .andExpect(MockMvcResultMatchers.content().string("Greetings from Spring Boot!"));
    }
}

Vamos a comparar solo los métodos de test que nos ha generado diffblue con los que teníamos antes, pero omitimos uno de los tests que prueba los varargs.

    @Test
    public void getHello() throws Exception {
        mvc.perform(MockMvcRequestBuilders.get("/").accept(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk())
                .andExpect(content().string(equalTo("Greetings from Spring Boot!")));
    }

    /**
     * Method under test: {@link HelloController#index()}
     */
    @Test
    void testIndex() throws Exception {
        MockHttpServletRequestBuilder requestBuilder = MockMvcRequestBuilders.get("/");
        MockMvcBuilders.standaloneSetup(helloController)
                .build()
                .perform(requestBuilder)
                .andExpect(MockMvcResultMatchers.status().isOk())
                .andExpect(MockMvcResultMatchers.content().contentType("text/plain;charset=ISO-8859-1"))
                .andExpect(MockMvcResultMatchers.content().string("Greetings from Spring Boot!"));
    }
}

Lo primero que nos llama la atención es que el test generado usa MockMvcBuilders.standaloneSetup para asegurarse que el test es unitario y solo usa lo imprescindible.

A la hora de generar los tests, vemos que no coge contexto y no usa directamente status().isOk() sino que usa MockMvcResultMatchers.status().isOk(). Esto es algo que se puede mejorar, ya que no es necesario usar el MockMvcResultMatchers y se puede usar directamente el status().isOk(). Tambien se puede mejorar el content().string(equalTo("Greetings from Spring Boot!"))); por content().string("Greetings from Spring Boot!"))); ya que no es necesario usar el equalTo.

Aquí vemos que el programador que ha escrito el test, le pone el accept para que sea el cliente el que le diga que tipo de respuesta quiere. El test escrito automáticamente no tiene ese contexto y por eso lo omite. Es complicado saber cuál es de los dos es más acertado, en cualquier caso no es un mal test y prueba de forma muy similar al que han generado desde la aplicación.

Esto hay que tomarlo desde el contexto de que es un ejemplo sencillo.

6. Conclusiones.

  • Diffblue es una herramienta muy interesante que genera unos tests acertados que nos pueden valer como primera aproximación para tener una red de seguridad. Los tests deberían ser revisados, ponerles nombres que fueran más descriptivos y ajustarlos a nuestras necesidades.
  • Si el proyecto es legacy, está bien empezar por los tests unitarios para tener algo más de seguridad, pero no se nos pueden olvidar los tests de integración para ponernos a refactorizar.
  • No podremos usar TDD, ya que necesita que el código este escrito previamente y nos perderemos las Ventajas del TDD.

7. Referencias.

DEJA UNA RESPUESTA

Por favor ingrese su comentario!

He leído y acepto la política de privacidad

Por favor ingrese su nombre aquí

Información básica acerca de la protección de datos

  • Responsable:
  • Finalidad:
  • Legitimación:
  • Destinatarios:
  • Derechos:
  • Más información: Puedes ampliar información acerca de la protección de datos en el siguiente enlace:política de privacidad