Una guía rápida para optimizar tests con Kotlin y Spring Boot

¿Trabajas con Kotlin + Spring Boot + Gradle y tus tests son demasiado lentos? Este artículo es para vos.

Una guía rápida para optimizar tests con Kotlin y Spring Boot

You can read this post in English here.

Esta lista de puntos problemáticos sobre el rendimiento de los tests y las recomendaciones para abordarlos proviene de mi experiencia con una suite de tests en un proyecto de Kotlin + Spring + Boot + Gradle con aproximadamente 1500 tests. Comenzamos con 13 minutos por ejecución y terminamos con 5 minutos (ejecutandolos en el mismo contexto).

¿Por qué es importante tener tests rápidos?

Los tests lentos pueden ser un dolor de cabeza en casos como:

  • Cuando haces TDD (Test-driven development) y necesitas ejecutar un test varias veces (cambiando algo pequeño y corriendo el test una y otra vez).
  • Cuando cambias el código y querés ejecutar todas los tests para ver si todo sigue funcionando bien.

Problemas abordados

Tiempo de inicialización de JVM

Probablemente viste este mensaje en tu IDE de Intellij cuando ejecutas tests localmente:

Esta es la Máquina Virtual de Java (JVM) iniciándose. A veces, esto toma demasiado tiempo y, cuando querés ejecutar solamente un test, es un problema. Investigando un poco, noté que ejecutar los tests con la configuración de JUnit en lugar de la configuración predeterminada (Gradle) mejora mucho este tiempo de inicialización.

Para ejecutar tus tests con JUnit en lugar de Gradle:

Vas a PreferenciasConstrucción, Ejecución, DespliegueGradle y pones la configuración "Elegir por test" y cuando quieras ejecutar un test, una clase de test o una suite, podes elegir ejecutarlas con JUnit:

Contexto de Spring

Algunos tests necesitan inicializar el contexto de Spring antes de ejecutarse. Estos son las que tienen, por ejemplo, la anotación @SpringBootTest arriba.

Esto puede tardar demasiado, por lo que deberíamos intentar reutilizar el contexto de Spring Boot entre las clases de test, para iniciarlo menos veces.

El contexto se reutiliza por defecto, a menos que hagas cosas como:

  • Usar anotaciones de Mockito o Mockk, como @MockBean o @SpyBean, que generan su propio contexto personalizado.
  • Usar la anotación @DirtiesContext que crea explícitamente un nuevo contexto (ejemplo de uso común: en caso de que estés cambiando clases singleton en dos clases de test y no quieras afectar una clase de test con la otra, agregas esta anotación).

¿Cuál es la solución?

  • Probá diferentes alternativas para evitar usar @MockBean y @SpyBean.

    Dos ejemplos:

  1) Si tenes un test de Controlador que quiere probar el caso en el que se devuelve un 404 cuando el coche que estás buscando no existe en la base de datos:

  No hagas esto

@SpringBootTest
@AutoConfigureMockMvc
class CarControllerTest {
    @Autowired
    private lateinit var mvc: MockMvc
    @MockkBean
    private lateinit var carService: CarService

    @Test
    fun `a 404 code is returned if you are asking for a non-existent car`() {
        every { carService.find(1) } throws NoSuchCarException()

        mvc.perform(MockMvcRequestBuilders.get("/cars/").param("carId", "1"))
        .andExpect(MockMvcResultMatchers.status().`is`(HttpStatus.NOT_FOUND.value()))
    }
}

  No mockees ni del servicio ni del repositorio, en su lugar: pedí un coche que sabes que no existe en la base de datos (por ejemplo, id = 9999), o elimina el coche con id = 1 a través del CarRepository antes de pedirlo a través del Controlador.

  Hacer esto es una ventaja, ya que estás probando esta funcionalidad de una manera más realista y sin usar mocks.

  2) En algunos casos, podes reemplazar un @MockBean creando un mock normal pasándolo a través del constructor de la clase que necesitarás, en lugar de usar @Autowired para esa clase. También te vas a deshacer de la anotación @SpringBootTest:

  No hagas esto

class CarServiceTest {
    @MockkBean
    private lateinit var weatherGateway: WeatherGateway
    @Autowired
    private lateinit var carService: CarService

  Hace esto

class CarServiceTest {
    private var weatherGateway: WeatherGateway = mockk()
    private var carService = CarService(weatherGateway)
  • Si tenes MockBeans que son difíciles de sacarlos de encima, podes crear una clase padre con esos mocks y heredar de la misma en todas las clases de test de SpringBoot, de modo que el contexto sea siempre el mismo contexto personalizado.

    Por ejemplo: tenés una clase que tiene un mockbean "A" y otra que tiene un mockbean "B". En ese caso hay 2 contextos personalizados diferentes ❌

    ✅ La solución puede ser crear una clase padre que tenga tanto mockbean "A" como mockbean "B", para que el contexto personalizado sea único en ambas clases hijas.

    ⚠️ Tené en cuenta que algunas clases de test pueden necesitar la instancia real de esa clase y no el mock, por lo que probablemente deberías "desmockearla" en la clase en la que la estás haciendo mock después de usarla.

  • Evita el uso de anotaciones como @DirtiesContext al identificar qué beans se compartieron y cambiaron entre las clases de test y limpiando esos beans antes de que otra clase los use.

    Ejemplo: Notamos que estábamos usando el mismo bean en muchas clases de test: un repositorio JPA. Y aunque eliminábamos todo en el repositorio después de cada clase de test, teníamos algunos tests inestables. Esto se debe a que no importa si eliminas objetos en el repositorio, los ids siguen incrementándose, y teníamos algunos tests que dependían de un id específico.

    Por ejemplo: creo algo en el repositorio, y luego verifico algo con el id 1 porque es la única entidad en el repositorio… suposición incorrecta!

    ✅ La solución es hacer tests que no dependan de un id específico y codificado.
    Un ejemplo de esto:
@Test
fun "an exception is thrown if the car doesn't have enough fuel to travel"() {
    val car = carRepository.save(Car(type = CarType.FORD_FOCUS, remainingFuelLiters = 35))
    
    val exception = assertThrows<NotEnoughFuelException> { carService.travel(car.id, 400) }
    assertEquals("Your car has not enough fuel to travel 400 km", exception.message)
}

Otras cosas útiles

Configuraciones personalizadas de ejecución/debug y tagging

Podes crear tus propias configuraciones de JUnit para ejecutar solo los tests de una clase específica, un directorio específico, un paquete y más.

También podés crear un tag sobre una clase de test específica, usando la anotación @Tag de JUnit:

@SpringBootTest
@Tag(“integration-test”)
class CarServiceTest {

Luego podés crear una configuración combinando diferentes tags (los tags pueden usarse como campos booleanos, por lo que se pueden usar todos los operadores booleanos). Por ejemplo, esta configuración ignora los tests de integración:

También podés marcar la opción "Guardar como archivo de proyecto" para guardar una configuración y agregarla al repositorio para que todos los demás la tengan.

Profiling

Medir cuánto tiempo toma ejecutar los tests y qué es lo que toma más tiempo. Podés hacerlo de varias maneras. Sin embargo, los números pueden no ser realmente representativos de lo que está sucediendo. Por ejemplo, a veces el IDE indica que los tests se ejecutan en aproximadamente 1 minuto. Pero ese no es el caso. Simplemente no está computando el tiempo de buildeo y los tiempos de creación del contexto de la aplicación.

Para ver informes con más detalles y números en tiempo real, podés exportarlos con estas 2 opciones del IDE:

Recomendaciones y Conclusiones

  • Los puntos problemáticos mencionados acá no son los únicos que podés encontrar, pero pueden disminuir mucho el rendimiento de tus tests.
  • Intentá usar JUnit en lugar de Gradle para ejecutar tests, especialmente si estás ejecutando suites simples y queres conocer el resultado más rápido.
  • Inicializar el contexto de Spring no es barato, hacelo solo si es necesario. El uso de anotaciones como @SpringBootTest generalmente está asociado con tests de integración, y para evitar caer en el anti-patrón del cono de helado, se recomienda tener más tests unitarios que tests de integración, así que no pongas la anotación @SpringBootTest en todas partes.
  • Si estás usando @SpringBootTest, intentá reutilizar el contexto tanto como puedas, evitando el anti-patrón que mencioné antes y haciendo alguna investigación si ves que no se está reutilizando y no estás usando las anotaciones que mencioné (para entender en qué tests se está inicializando el contexto de Spring, tenes que mirar en qué clase de test aparece el logotipo de SPRING).
  • Usá mocks solo si es necesario, por ejemplo, en caso de que el contexto sea difícil de reproducir. No hagas mock de todo porque al final no vas a estar probando nada.