Cree con confianza: una guía para las pruebas JUnit

Publicado: 2022-03-11

Con el avance de la tecnología y la industria pasando del modelo en cascada a Agile y ahora a DevOps, los cambios y las mejoras en una aplicación se implementan en producción en el momento en que se realizan. Con el código que se implementa en producción tan rápido, debemos estar seguros de que nuestros cambios funcionan y también de que no rompen ninguna funcionalidad preexistente.

Para generar esta confianza, debemos tener un marco para las pruebas de regresión automática. Para realizar pruebas de regresión, hay muchas pruebas que deben llevarse a cabo desde el punto de vista del nivel de API, pero aquí cubriremos dos tipos principales de pruebas:

  • Pruebas unitarias , donde cualquier prueba dada cubre la unidad más pequeña de un programa (una función o procedimiento). Puede o no tomar algunos parámetros de entrada y puede o no devolver algunos valores.
  • Pruebas de integración , donde las unidades individuales se prueban juntas para verificar si todas las unidades interactúan entre sí como se esperaba.

Hay numerosos marcos disponibles para cada lenguaje de programación. Nos centraremos en escribir pruebas de unidad e integración para una aplicación web escrita en el marco Spring de Java.

La mayoría de las veces, escribimos métodos en una clase y estos, a su vez, interactúan con métodos de alguna otra clase. En el mundo actual, especialmente en las aplicaciones empresariales, la complejidad de las aplicaciones es tal que un solo método puede llamar a más de un método de varias clases. Entonces, al escribir la prueba unitaria para dicho método, necesitamos una forma de devolver datos simulados de esas llamadas. Esto se debe a que la intención de esta prueba unitaria es probar solo un método y no todas las llamadas que realiza este método en particular.


Pasemos a las pruebas unitarias de Java en Spring usando el marco JUnit. Empezaremos con algo de lo que quizás hayas oído hablar: burlarse.

¿Qué es burlarse y cuándo entra en escena?

Supongamos que tiene una clase, CalculateArea , que tiene una función calculateArea(Type type, Double... args) que calcula el área de una forma del tipo dado (círculo, cuadrado o rectángulo).

El código es algo así en una aplicación normal que no usa inyección de dependencia:

 public class CalculateArea { SquareService squareService; RectangleService rectangleService; CircleService circleService; CalculateArea(SquareService squareService, RectangleService rectangeService, CircleService circleService) { this.squareService = squareService; this.rectangleService = rectangeService; this.circleService = circleService; } public Double calculateArea(Type type, Double... r ) { switch (type) { case RECTANGLE: if(r.length >=2) return rectangleService.area(r[0],r[1]); else throw new RuntimeException("Missing required params"); case SQUARE: if(r.length >=1) return squareService.area(r[0]); else throw new RuntimeException("Missing required param"); case CIRCLE: if(r.length >=1) return circleService.area(r[0]); else throw new RuntimeException("Missing required param"); default: throw new RuntimeException("Operation not supported"); } } }
 public class SquareService { public Double area(double r) { return r * r; } }
 public class RectangleService { public Double area(Double r, Double h) { return r * h; } }
 public class CircleService { public Double area(Double r) { return Math.PI * r * r; } }
 public enum Type { RECTANGLE,SQUARE,CIRCLE; }

Ahora, si queremos realizar una prueba unitaria de la función calculateArea() de la clase CalculateArea , entonces nuestro motivo debería ser verificar si los casos de switch y las condiciones de excepción funcionan. No debemos probar si los servicios de forma están devolviendo los valores correctos, porque como se mencionó anteriormente, el motivo de la prueba unitaria de una función es probar la lógica de la función, no la lógica de las llamadas que realiza la función.

Por lo tanto, imitaremos los valores devueltos por funciones de servicio individuales (p. ej., rectangleService.area() y probaremos la función de llamada (p. ej CalculateArea.calculateArea() ) en función de esos valores simulados.

Un caso de prueba simple para el servicio de rectángulos, probando que calculateArea() de hecho llama a rectangleService.area() con los parámetros correctos, se vería así:

 import org.junit.Assert; import org.junit.Before; import org.junit.Test; import org.mockito.Mockito; public class CalculateAreaTest { RectangleService rectangleService; SquareService squareService; CircleService circleService; CalculateArea calculateArea; @Before public void init() { rectangleService = Mockito.mock(RectangleService.class); squareService = Mockito.mock(SquareService.class); circleService = Mockito.mock(CircleService.class); calculateArea = new CalculateArea(squareService,rectangleService,circleService); } @Test public void calculateRectangleAreaTest() { Mockito.when(rectangleService.area(5.0d,4.0d)).thenReturn(20d); Double calculatedArea = this.calculateArea.calculateArea(Type.RECTANGLE, 5.0d, 4.0d); Assert.assertEquals(new Double(20d),calculatedArea); } }

Dos líneas principales a tener en cuenta aquí son:

  • rectangleService = Mockito.mock(RectangleService.class); —Esto crea un simulacro, que no es un objeto real, sino uno simulado.
  • Mockito.when(rectangleService.area(5.0d,4.0d)).thenReturn(20d); —Esto dice que, cuando se simula, y se llama al método de area del objeto rectangleService con los parámetros especificados, luego se devuelve 20d .

Ahora, ¿qué sucede cuando el código anterior es parte de una aplicación Spring?

 import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; @Component public class CalculateArea { SquareService squareService; RectangleService rectangleService; CircleService circleService; public CalculateArea(@Autowired SquareService squareService, @Autowired RectangleService rectangeService, @Autowired CircleService circleService) { this.squareService = squareService; this.rectangleService = rectangeService; this.circleService = circleService; } public Double calculateArea(Type type, Double... r ) { // (same implementation as before) } }

Aquí tenemos dos anotaciones para que el marco Spring subyacente las detecte en el momento de la inicialización del contexto:

  • @Component : crea un bean de tipo CalculateArea
  • @Autowired : busca los beans rectangleService , squareService y circleService y los inyecta en el área calculatedArea del frijol

De manera similar, también creamos beans para otras clases:

 import org.springframework.stereotype.Service; @Service public class SquareService { public Double area(double r) { return r*r; } }
 import org.springframework.stereotype.Service; @Service public class CircleService { public Double area(Double r) { return Math.PI * r * r; } }
 import org.springframework.stereotype.Service; @Service public class RectangleService { public Double area(Double r, Double h) { return r*h; } }

Ahora, si ejecutamos las pruebas, los resultados son los mismos. Usamos inyección de constructor aquí y, afortunadamente, no cambiamos nuestro caso de prueba.

Pero hay otra forma de inyectar los beans de los servicios cuadrados, circulares y rectangulares: inyección de campo. Si usamos eso, entonces nuestro caso de prueba necesitará algunos cambios menores.

No entraremos en la discusión de qué mecanismo de inyección es mejor, ya que eso no está dentro del alcance del artículo. Pero podemos decir esto: no importa qué tipo de mecanismo use para inyectar beans, siempre hay una manera de escribir pruebas JUnit para ello.

En el caso de la inyección de campo, el código es algo como esto:

 @Component public class CalculateArea { @Autowired SquareService squareService; @Autowired RectangleService rectangleService; @Autowired CircleService circleService; public Double calculateArea(Type type, Double... r ) { // (same implementation as before) } }

Nota: Dado que estamos usando inyección de campo, no hay necesidad de un constructor parametrizado, por lo que el objeto se crea usando el predeterminado y los valores se establecen usando el mecanismo de inyección de campo.

El código para nuestras clases de servicio sigue siendo el mismo que el anterior, pero el código para la clase de prueba es el siguiente:

 public class CalculateAreaTest { @Mock RectangleService rectangleService; @Mock SquareService squareService; @Mock CircleService circleService; @InjectMocks CalculateArea calculateArea; @Before public void init() { MockitoAnnotations.initMocks(this); } @Test public void calculateRectangleAreaTest() { Mockito.when(rectangleService.area(5.0d,4.0d)).thenReturn(20d); Double calculatedArea = this.calculateArea.calculateArea(Type.RECTANGLE, 5.0d, 4.0d); Assert.assertEquals(new Double(20d),calculatedArea); } }

Algunas cosas son diferentes aquí: no lo básico, sino la forma en que lo logramos.

Primero, la forma en que simulamos nuestros objetos: usamos anotaciones @Mock junto con initMocks() para crear simulacros. En segundo lugar, inyectamos simulacros en el objeto real usando @InjectMocks junto con initMocks() .

Esto solo se hace para reducir el número de líneas de código.

¿Qué son los corredores de prueba y qué tipos de corredores existen?

En el ejemplo anterior, el corredor básico que se utiliza para ejecutar todas las pruebas es BlockJUnit4ClassRunner que detecta todas las anotaciones y ejecuta todas las pruebas en consecuencia.

Si queremos algo más de funcionalidad, entonces podemos escribir un corredor personalizado. Por ejemplo, en la clase de prueba anterior, si queremos omitir la línea MockitoAnnotations.initMocks(this); entonces podríamos usar un corredor diferente que se construye sobre BlockJUnit4ClassRunner , por ejemplo, MockitoJUnitRunner .

Usando MockitoJUnitRunner , ni siquiera necesitamos inicializar simulacros e inyectarlos. Eso lo hará MockitoJUnitRunner solo leyendo las anotaciones.

(También está SpringJUnit4ClassRunner , que inicializa el ApplicationContext necesario para las pruebas de integración de Spring, al igual que se crea un ApplicationContext cuando se inicia una aplicación Spring. Esto lo cubriremos más adelante).

burla parcial

Cuando queremos que un objeto en la clase de prueba se burle de algunos métodos, pero también llame a algunos métodos reales, entonces necesitamos burlas parciales. Esto se logra a través de @Spy en JUnit.

A diferencia de usar @Mock , con @Spy , se crea un objeto real, pero los métodos de ese objeto se pueden simular o se pueden llamar, lo que sea que necesitemos.

Por ejemplo, si el método de area en la clase RectangleService llama a un método extra log() y realmente queremos imprimir ese registro, entonces el código cambia a algo como lo siguiente:

 @Service public class RectangleService { public Double area(Double r, Double h) { log(); return r*h; } public void log() { System.out.println("skip this"); } }

Si cambiamos la anotación @Mock de rectangleService a @Spy , y también hacemos algunos cambios en el código como se muestra a continuación, en los resultados veríamos que se imprimen los registros, pero se simulará el método area() . Es decir, la función original se ejecuta únicamente por sus efectos secundarios; sus valores de retorno se reemplazan por los simulados.

 @RunWith(MockitoJUnitRunner.class) public class CalculateAreaTest { @Spy RectangleService rectangleService; @Mock SquareService squareService; @Mock CircleService circleService; @InjectMocks CalculateArea calculateArea; @Test public void calculateRectangleAreaTest() { Mockito.doCallRealMethod().when(rectangleService).log(); Mockito.when(rectangleService.area(5.0d,4.0d)).thenReturn(20d); Double calculatedArea = this.calculateArea.calculateArea(Type.RECTANGLE, 5.0d, 4.0d); Assert.assertEquals(new Double(20d),calculatedArea); } }

¿Cómo hacemos para probar un Controller o un controlador de RequestHandler ?

De lo que aprendimos anteriormente, el código de prueba de un controlador para nuestro ejemplo sería algo como lo siguiente:

 import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseBody; @Controller public class AreaController { @Autowired CalculateArea calculateArea; @RequestMapping(value = "api/area", method = RequestMethod.GET) @ResponseBody public ResponseEntity calculateArea( @RequestParam("type") String type, @RequestParam("param1") String param1, @RequestParam(value = "param2", required = false) String param2 ) { try { Double area = calculateArea.calculateArea( Type.valueOf(type), Double.parseDouble(param1), Double.parseDouble(param2) ); return new ResponseEntity(area, HttpStatus.OK); } catch (Exception e) { return new ResponseEntity(e.getCause(), HttpStatus.INTERNAL_SERVER_ERROR); } } }
 import org.junit.Assert; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.junit.MockitoJUnitRunner; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @RunWith(MockitoJUnitRunner.class) public class AreaControllerTest { @Mock CalculateArea calculateArea; @InjectMocks AreaController areaController; @Test public void calculateAreaTest() { Mockito .when(calculateArea.calculateArea(Type.RECTANGLE,5.0d, 4.0d)) .thenReturn(20d); ResponseEntity responseEntity = areaController.calculateArea("RECTANGLE", "5", "4"); Assert.assertEquals(HttpStatus.OK,responseEntity.getStatusCode()); Assert.assertEquals(20d,responseEntity.getBody()); } }

Mirando el código de prueba del controlador anterior, funciona bien, pero tiene un problema básico: solo prueba la llamada al método, no la llamada a la API real. Faltan todos esos casos de prueba en los que los parámetros API y el estado de las llamadas API deben probarse para diferentes entradas.

Este código es mejor:

 import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Mockito; import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup; @RunWith(SpringJUnit4ClassRunner.class) public class AreaControllerTest { @Mock CalculateArea calculateArea; @InjectMocks AreaController areaController; MockMvc mockMvc; @Before public void init() { mockMvc = standaloneSetup(areaController).build(); } @Test public void calculateAreaTest() throws Exception { Mockito .when(calculateArea.calculateArea(Type.RECTANGLE,5.0d, 4.0d)) .thenReturn(20d); mockMvc.perform( MockMvcRequestBuilders.get("/api/area?type=RECTANGLE&param1=5&param2=4") ) .andExpect(status().isOk()) .andExpect(content().string("20.0")); } }

Aquí podemos ver cómo MockMvc realiza el trabajo de realizar llamadas API reales. También tiene algunos comparadores especiales como status() y content() que facilitan la validación del contenido.

Pruebas de integración de Java con JUnit y simulacros

Ahora que sabemos que las unidades individuales del código funcionan, asegurémonos de que también interactúen entre sí como esperamos.

Primero, necesitamos instanciar todos los beans, lo mismo que sucede en el momento de la inicialización del contexto de Spring durante el inicio de la aplicación.

Para esto, definimos todos los beans en una clase, digamos TestConfig.java :

 import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class TestConfig { @Bean public AreaController areaController() { return new AreaController(); } @Bean public CalculateArea calculateArea() { return new CalculateArea(); } @Bean public RectangleService rectangleService() { return new RectangleService(); } @Bean public SquareService squareService() { return new SquareService(); } @Bean public CircleService circleService() { return new CircleService(); } }

Ahora veamos cómo usamos esta clase y escribimos una prueba de integración:

 import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup; @RunWith(SpringJUnit4ClassRunner.class) @ContextConfiguration(classes = {TestConfig.class}) public class AreaControllerIntegrationTest { @Autowired AreaController areaController; MockMvc mockMvc; @Before public void init() { mockMvc = standaloneSetup(areaController).build(); } @Test public void calculateAreaTest() throws Exception { mockMvc.perform( MockMvcRequestBuilders.get("/api/area?type=RECTANGLE&param1=5&param2=4") ) .andExpect(status().isOk()) .andExpect(content().string("20.0")); } }

Algunas cosas cambian aquí:

  • @ContextConfiguration(classes = {TestConfig.class}) — esto le dice al caso de prueba dónde residen todas las definiciones de beans.
  • Ahora en lugar de @InjectMocks usamos:
 @Autowired AreaController areaController;

Todo lo demás se mantiene igual. Si depuramos la prueba, veremos que el código se ejecuta realmente hasta la última línea del método area() en RectangleService donde se calcula el return r*h . En otras palabras, se ejecuta la lógica empresarial real.

Esto no significa que no haya simulaciones de llamadas a métodos o llamadas a bases de datos disponibles en las pruebas de integración. En el ejemplo anterior, no se usó ningún servicio o base de datos de terceros, por lo tanto, no necesitábamos usar simulacros. En la vida real, tales aplicaciones son raras y, a menudo, nos encontramos con una base de datos o una API de terceros, o ambas. En ese caso, cuando creamos el bean en la clase TestConfig , no creamos el objeto real, sino uno simulado, y lo usamos donde sea necesario.

Bono: Cómo crear datos de prueba de objetos grandes

A menudo, lo que detiene a los desarrolladores de back-end al escribir pruebas unitarias o de integración son los datos de prueba que tenemos que preparar para cada prueba.

Normalmente, si los datos son lo suficientemente pequeños y tienen una o dos variables, entonces es fácil simplemente crear un objeto de una clase de datos de prueba y asignar algunos valores.

Por ejemplo, si esperamos que un objeto simulado devuelva otro objeto, cuando se llama a una función en el objeto simulado, haríamos algo como esto:

 Class1 object = new Class1(); object.setVariable1(1); object.setVariable2(2);

Y luego, para usar este objeto, haríamos algo como esto:

 Mockito.when(service.method(arguments...)).thenReturn(object);

Esto está bien en los ejemplos de JUnit anteriores, pero cuando las variables miembro en la clase Class1 anterior siguen aumentando, establecer campos individuales se convierte en una molestia. A veces, incluso puede suceder que una clase tenga definido otro miembro de clase no primitivo. Luego, la creación de un objeto de esa clase y la configuración de campos obligatorios individuales aumenta aún más el esfuerzo de desarrollo solo para lograr un modelo estándar.

La solución es generar un esquema JSON de la clase anterior y agregar los datos correspondientes en el archivo JSON una vez. Ahora, en la clase de prueba donde creamos el objeto Class1 , no necesitamos crear el objeto manualmente. En su lugar, leemos el archivo JSON y, usando ObjectMapper , lo asignamos a la clase Class1 requerida:

 ObjectMapper objectMapper = new ObjectMapper(); Class1 object = objectMapper.readValue( new String(Files.readAllBytes( Paths.get("src/test/resources/"+fileName)) ), Class1.class );

Este es un esfuerzo único de crear un archivo JSON y agregarle valores. Cualquier nueva prueba posterior puede usar una copia de ese archivo JSON con campos modificados según las necesidades de la nueva prueba.

Conceptos básicos de JUnit: enfoques múltiples y habilidades transferibles

Está claro que hay muchas formas de escribir pruebas unitarias de Java dependiendo de cómo elijamos inyectar beans. Desafortunadamente, la mayoría de los artículos sobre el tema tienden a asumir que solo hay una forma, por lo que es fácil confundirse, especialmente cuando se trabaja con código escrito bajo una suposición diferente. Con suerte, nuestro enfoque aquí ahorra tiempo a los desarrolladores para descubrir la forma correcta de simular y qué corredor de prueba usar.

Independientemente del lenguaje o marco que usemos, tal vez incluso cualquier versión nueva de Spring o JUnit, la base conceptual sigue siendo la misma que se explica en el tutorial de JUnit anterior. ¡Feliz prueba!