MockMvc – Introducción a Spring MVC testing framework: Probando endpoints
Introducción
En esta publicación veremos cómo utilizar MockMvc para probar endpoints creados con Spring. Este es el primer post de una serie dónde analizaremos las principales ventajas de utilizar Spring MVC test framework para probar clases de tipo Controller respecto a otras soluciones y cuáles son las distintas formas de configurar los tests.
El código fuente completo del artículo puede encontrarse en GitHub. El proyecto contiene un controller MVC estándar que reenvía las llamadas a un recurso estático y dos controladores REST que devuelven una lista de lenguajes y otra de cafés.
MockMvc y la pirámide de los tests
En su libro, Succeeding with Agile: Software Development Using Scrum, Mike Cohn describe la pirámide de automatización de los tests como una estrategia de automatización de tests en términos de cantidad y esfuerzo que deben dedicarse a cada uno de los tipos de test. La pirámide también la emplean otros autores para definir un portfolio de tests en base al coste y velocidad. La base de la pirámide son los tests unitarios, por lo que debemos entender que los tests unitarios son los cimientos de la estrategia de testing y que debería de haber muchos más tests unitarios que tests end-to-end de alto nivel.
Como ya sabrás, los test unitarios se centran en probar el componente o unidad más pequeño de un software. En la programación orientada a objetos esto se traduce normalmente un una sola clase o interfaz, o un método o función dentro de esta clase. El test debe de realizarse de forma aislada a otras unidades del mismo software, para conseguir esto normalmente las dependencias de la unidad a probar se simulan (mock). Una de las características principales de los tests unitarios es que su ejecución debe de ser realmente rápida.
De forma similar, los tests de integración se centran en probar una combinación de las unidades o componentes anteriormente mencionados. Estrictamente hablando, los tests utilizando MockMvc
se encuentran en la frontera entre los tests unitarios y lost tests de integración. No son tests unitarios porque los endpoints se prueban de forma integrada con un contenedor MVC con dependencias e inputs simulados. Pero tampoco podríamos considerar estos test como de integración, ya que si se definen correctamente, con MockMvc
sólo probaremos un único componente con una plataforma simulada, pero no una combinación de componentes.
Tecnicismos a un lado, cuando preparemos nuestro portfolio de tests para un proyecto, los tests empleando Spring MVC deberían de tratarse como tests unitarios y ser parte de los cimientos de nuestra estrategia de testing.
Controller de lenguajes
Para ilustrar los ejemplos de este tutorial vamos a crear un controller sencillo que devuelve una lista de lenguajes de programación.
1@Controller
2@RequestMapping(value = "/api", produces = MimeTypeUtils.APPLICATION_JSON_VALUE)
3public class LanguageApiController {
4
5 private final LanguageService languageService;
6
7 @Autowired
8 public LanguageApiController(
9 LanguageService languageService) {
10 this.languageService = languageService;
11 }
12
13 @RequestMapping(value = "/languages", method = GET)
14 public ResponseEntity<List<Language>> getLanguages(@RequestParam(value = "contains", required = false) String contains) {
15 return ResponseEntity.ok(languageService.getLanguages(contains));
16 }
17
18 @RequestMapping(value = "/languages/{name}", method = GET)
19 public ResponseEntity<Language> getLanguage(@PathVariable("name") String name) {
20 return ResponseEntity.ok(languageService.getLanguage(name).orElseThrow(() -> new SpringMockMvcException(
21 HttpStatus.NOT_FOUND, "Language was not found")));
22 }
23
24 @ExceptionHandler(SpringMockMvcException.class)
25 public ResponseEntity<String> onSpringMockMvcException(HttpServletRequest request, SpringMockMvcException ex) {
26 return ResponseEntity.status(ex.getHttpStatus()).body(String.format("%s - %s",
27 ex.getHttpStatus().value(), ex.getMessage()));
28 }
29
30}
El controlador tiene una anotación global @RequestMapping
para especificar el mime-type para la salida de los endpoints declarados en la clase como application/json
. La clase expone dos endpoints que se apoyan en LanguageService
para realizar las operaciones de negocio.
El primero de los endpoints (/api/languages
) una lista de todos los lenguajes y opcionalmente podrá ser filtrada con un parámetro (contains
).
El siguiente endpoint (/api/language/{name}
) devolverá un único lenguaje a partir del parámetro proporcionado correspondiente a su nombre (único).
Por último, hemos declarado un ExceptionHandler
que se encargará de convertir cualquier excepción controlada SpringMockMvcException
en un ResponseEntity
.
Spring MVC test framework: ventajas
Los tests cuya ejecución depende de MockMvc
se encuentran en las fronteras entre los tests de integración y los tests unitarios, tal como se ha explicado en los párrafos anteriores. A continuación se muestra un test unitario estricto donde se prueba LanguageApiController
.
1@Test
2 public void getLanguages_null_shouldReturnListOfStrings() throws Exception {
3 // Given
4 final String mockedEsoteric = "Arnoldc";
5 final List<String> mockedLanguages = Stream.concat(
6 LanguageRepository.LANGUAGES.stream(),
7 Stream.of(mockedEsoteric)).collect(Collectors.toList());
8 doReturn(mockedLanguages).when(languageService).getLanguages(null);
9
10 // When
11 final ResponseEntity<List<String>> result = languageApiController.getLanguages(null);
12
13 // Then
14 assertThat(result.getBody(), hasSize(mockedLanguages.size()));
15 assertThat(result.getBody(), hasItem(mockedEsoteric));
16 }
17}
Este test comprueba que el método devuelve la lista esperada cuando se le pasa null como argumento. Esta prueba nos aportará un 100% de cobertura de test para este código, sin embargo, hay muchos aspectos de la especificación que no se comprueban, de forma más específica:
- Cabeceras de la negociación de contenidos (content negotiation headers): Este controlador se ha diseñado para producir únicamente contenido en formato
application/json
. - Códigos de respuesta (response codes): Con
MockMvc
somos capaces que el código de respuesta se corresponde con el esperado (200, 201…), incluso cuando el código se asigna fuera del método del controlador o post-procesado por una anotación/aspect. - Serialización/Deserialización de objetos a JSON: Con
MockMvc
podemos comprobar que el JSON que pasamos cuando hacemos una petición con contenido se deserializa correctamente y que el JSON generado en lel cuerpo de la respuesta tiene la estructura y contenido esperados. - Disponibilidad de cabeceras en la respuesta.
- Muchos otros escenarios y configuraciones que van más allá de la implementación concreta del método pero que son parte de la especificación e implementación del endpoint (Validación, Seguridad, Gestión de excepciones…)
Anotación WebMvcTest
La forma más sencilla de ejecutar un test con MockMvc
es utilizar la anotación de Spring Boot @WebMvcTest
. Está anotación configurará la suite JUnit con SpringRunner
para que despliegue una aplicación web de forma parcial auto configurando sólo aquellos componentes necesarios para una aplicación web MVC.
En mi opinión, esta no es la mejor estrategia para probar una aplicación, ya que se desplegará una aplicación completa simulada para cada suite de tests. Esto puede ser bastante exagerado cuando lo único que queremos es comprobar unitariamente el controlador y no su integración con el resto de componentes de la aplicación (pirámide de los tests).
El siguiente test empleará @WebMvcTest
para autoconfigurar el entorno de test y lanzar los test de comprobación utilizando una instancia inyectada de MockMvc
:
1@RunWith(SpringRunner.class)
2@WebMvcTest(controllers = LanguageApiController.class)
3public class LanguageApiControllerWebMvcTest {
4
5 @Autowired
6 private MockMvc mockMvc;
7
8 @Test
9 public void getLanguages_null_shouldReturnOk() throws Exception {
10 // Given
11 // Real application context
12
13 // When
14 final ResultActions result = mockMvc.perform(
15 get("/api/languages")
16 .accept(MimeTypeUtils.APPLICATION_JSON_VALUE));
17
18 // Then
19 final int expectedSize = LANGUAGES.size();
20 final String[] expectedLanguageNames = LANGUAGES.stream().map(Language::getName)
21 .collect(Collectors.toList()).toArray(new String[LANGUAGES.size()]);
22 result.andExpect(status().isOk());
23 result.andExpect(jsonPath("$.length()").value(expectedSize));
24 result.andExpect(jsonPath("$[*].name", containsInAnyOrder(expectedLanguageNames)));
25 }
26}
Insistir en que esta no es la mejor forma de preparar una prueba si estamos siguiendo la estrategia de la pirámide de los test. Otras alternativas para crear instancias de MockMvc
programáticamente se describen en los siguientes escenarios.
Configuración de integración con un contexto de aplicación web (Web Application Context)
Hay diversas formas en las que MockMvc
puede instanciarse en un test de forma programática, la forma en la que esto se haga definirá el grado de integración que el test realizará sobre los componentes de la aplicación. El mayor nivel de integración lo conseguiremos instanciando MockMvc
empleando .webAppContextSetup(...)
. En mi opinión este no es el mejor método para probar un endpoint ya que muchas de las ventajas de las pruebas unitarias se pierden (los test son más lentos, se incrementa la complejidad a la hora de simular objetos…).
El siguiente test verifica el comportamiento de la aplicación empleando esta técnica:
1@RunWith(SpringJUnit4ClassRunner.class)
2@WebAppConfiguration
3@ContextConfiguration(classes = {
4 SpringMockMvcConfiguration.class
5})
6public class LanguageApiControllerApplicationIntegrationTest {
7
8 @Autowired
9 private WebApplicationContext webApplicationContext;
10
11 private MockMvc mockMvc;
12
13 @Before
14 public void setUp() {
15 mockMvc = MockMvcBuilders
16 .webAppContextSetup(webApplicationContext)
17 .build();
18 }
19/*...*/
20 @Test
21 public void getLanguages_null_shouldReturnOk() throws Exception {
22 // Given
23 // Real application context
24
25 // When
26 final ResultActions result = mockMvc.perform(
27 get("/api/languages")
28 .accept(MimeTypeUtils.APPLICATION_JSON_VALUE));
29
30 // Then
31 final int expectedSize = LANGUAGES.size();
32 final String[] expectedLanguageNames = LANGUAGES.stream().map(Language::getName)
33 .collect(Collectors.toList()).toArray(new String[LANGUAGES.size()]);
34 result.andExpect(status().isOk());
35 result.andExpect(jsonPath("$.length()").value(expectedSize));
36 result.andExpect(jsonPath("$[*].name", containsInAnyOrder(expectedLanguageNames)));
37 }
38
39}
Hay dos secciones importantes en el código anterior. Entre las anotaciones de la clase encontramos @WebAppConfiguration
que indica que un WebApplicationContext
debería de cargarse para el test empleando el path por defecto a la raíz de la aplicación web. Además se define un @ContextConfiguration
para indicar qué clases/beans deberían de cargarse para el test. En este caso, vamos a reutilizar el Bean de configuración empleado en la aplicación real, de este modo podremos comprobar la integración completa de todos los componentes de la aplicación.
En la siguiente sección del código encontramos la puesta en marcha del test setUp/@Before. En este caso vamos a construir una instancia de MockMvc
empleando un WebApplicationContext
completamente inicializado. Spring nos permite inyectar este contexto mediante @Autowire
debido a que estamos utilizando la anotación @WebAppConfiguration
a nivel de clase.
El test comprueba que el endpoint se expone en la ruta (path) esperada y que responde con los lenguajes existentes en el repositorio real de la aplicación cuando recibe una petición con las cabeceras (headers) apropiadas. De este modo, además de verificar que el controller se ha implementado de forma adecuada, también validamos que la configuración en base a aspectos (i.e. @RequestMapping
…) se comporta de la forma esperada.
Tal como ya se ha mencionado, este test comparte la configuración real de la aplicación WebMvc (SpringMockMvcConfiguration
), por tanto durante el test habrá disponible un application context completo. Todo ello implica que el test tardará más ejecutarse y consumirá bastantes recursos, además de posiblemente necesidad de acceso a bases de datos y otros recursos reales. Para restringir el número de Beans en el test, debería de haber una o más configuraciones específicas de aplicación WebMvc para las pruebas.
Este tipo de configuración sólo debería de valorarse cuando lo que queremos es realizar pruebas con un grado considerable de integración entre los distintos componentes. En la siguiente sección se describe la configuración independiente (standalone) de MockMvc
, esta debería de ser la configuración de referencia cuando los tests se centran únicamente en verificar el funcionamiento de los controladores (controllers).
Configuración MockMvc standalone
MockMvcBuilders
ofrece una forma adicional para configurar MockMvc
, standalonSetup(...)
. Este método nos permite registrar uno o más Controllers sin necesidad de desplegar un WebApplicationContext
completo. Simular las dependencias de los controladores empleando esta técnica es muy sencillo y nos permite probar un controlador aislado de sus dependencias, del mismo modo que lo hemos hecho en el ejemplo con el test unitario estricto. Por el contrario, con esta técnica también somos capaces de verificar la ruta (path), método de la petición (request method), cabeceras (headers), respuestas, etc. por lo que tenemos lo mejor de ambos mundos.
El siguiente test demuestra este comportamiento:
1@RunWith(SpringJUnit4ClassRunner.class)
2@ContextConfiguration(classes = {
3 LanguageApiController.class
4})
5@Import(Config.class)
6public class LanguageApiControllerTest {
7
8 @Autowired
9 private LanguageService languageService;
10
11 @Autowired
12 private LanguageApiController languageApiController;
13
14 private MockMvc mockMvc;
15
16 @Before
17 public void setUp() {
18 mockMvc = MockMvcBuilders
19 .standaloneSetup(languageApiController)
20 .build();
21 }
22
23/* ... */
24
25 @TestConfiguration
26 protected static class Config {
27
28 @Bean
29 public LanguageService languageService() {
30 return Mockito.mock(LanguageService.class);
31 }
32
33 }
34
35}
Esta configuración difiere un poco de la del anterior ejemplo. La anotación @WebAppConfiguration
ya no es necesaria, por lo que Spring ya no cargará un contexto de aplicación completo (menor sobrecarga y uso de recursos). La anotación @ContextConfiguration
incluye una referencia al controlador que queremos probar, Spring creará un Bean para este Controller y lo inyectará en el test (@Autowire
). Por último hay una configuración interna donde declaramos los mock Beans para las dependencias del controlador. Spring inyectará estas instancias simuladas dentro del controlador. Los mock beans también podrán inyectarse dentro del test, por lo que preparar datos simulados será realmente fácil.
Languages Happy Path
El siguiente test emplea la configuración standalone de MockMvc y describe el happy path para el endpoint /api/languages
:
1@Test
2public void getLanguages_null_shouldReturnOk() throws Exception {
3 // Given
4 final Language mockedEsoteric = new Language("Arnoldc", "Lauri Hartikka");
5 final List<Language> mockedLanguages = Stream.concat(
6 LanguageRepository.LANGUAGES.stream(),
7 Stream.of(mockedEsoteric)).collect(Collectors.toList());
8 doReturn(mockedLanguages).when(languageService).getLanguages(null);
9
10 // When
11 final ResultActions result = mockMvc.perform(
12 get("/api/languages")
13 .accept(MimeTypeUtils.APPLICATION_JSON_VALUE));
14
15 // Then
16 result.andExpect(status().isOk());
17 result.andExpect(jsonPath("$.length()").value(mockedLanguages.size()));
18 result.andExpect(jsonPath("$[?(@.name === 'Arnoldc')]").exists());
19}
En la sección Given preparamos una lista de lenguajes de programación que serán devueltas por el Bean simulado LanguageService
cuando el método getLanguages
recibe un parámetro con valor null.
A continuación hacemos la llamada GET
al endpoint en el entorno Mock Mvc empleando un header Accept: application/json
. Finalmente, con la respuesta recibida, comprobamos que el código de estado es el esperado (200) y mediante JSON path verificamos que se incluyen todos los lenguajes que hemos simulado.
Tal como ya hemos mencionado, en este caso no sólo estamos comprobando la implementación del método en el controller, pero también su configuración a efectos de cabeceras de la negociación de contenidos, serialización del objeto en formato JSON y códigos de respuesta HTTP.
Invalid Accept header
Este test pone de manifiesto como el uso de MockMvc
nos permitirá comprobar si las anotaciones de un endpoint se comportan de la forma esperada y que el endpoint únicamente queda expuesto cuando recibe en la petición una cabecera Accept: application/json
.
1@Test
2public void getLanguages_invalidAcceptHeader_shouldReturnNotAcceptable() throws Exception {
3 // Given
4 final String invalidAcceptMimeType = MimeTypeUtils.APPLICATION_XML_VALUE;
5 doReturn(LanguageRepository.LANGUAGES).when(languageService).getLanguages(null);
6
7 // When
8 final ResultActions result = mockMvc.perform(
9 get("/api/languages")
10 .accept(invalidAcceptMimeType));
11
12 // Then
13 result.andExpect(status().isNotAcceptable());
14}
En este caso, la llamada a través de MockMvc
se realiza utilizando una cabecera Accept: application/xml
. El endpoint se ha configurado únicamente para devolver datos en formato JSON, por lo que Spring MVC nunca invocará el método del endpoint y devolverá un código de estado 406 (Not Acceptable) para la respuesta.
Probando el ExceptionHandler
El LanguageApiController
incluye un @ExceptionHandler
que se encarga de gestionar las excepciones controladas que se lanzan desde cualquier método del Controller. Cuando invocamos el endpoint /api/languages/{name}
, si no se encuentra ningún lenguaje cuyo nombre sea igual al de la variable del path, un excepción controlada SpringMockMvc
se lanza desde el LanguageService
.
El siguiente test verifica que el @ExceptionHandler
se comporta de la forma esperada y que la excepción controlada se serializa mediante un ResponseEntity
con el código de respuesta y contenido esperado.
1@Test
2public void getLanguage_nonExistingLanguage_shouldReturnNotFound() throws Exception {
3 // Given
4 final String mockedLanguageName = "Arnoldc";
5 doReturn(Optional.empty()).when(languageService).getLanguage(mockedLanguageName);
6
7 // When
8 final ResultActions result = mockMvc.perform(
9 get("/api/languages/".concat(mockedLanguageName))
10 .accept(MimeTypeUtils.APPLICATION_JSON_VALUE));
11
12 // Then
13 result.andExpect(status().isNotFound());
14}
En la sección Given configuramos un LanguageService
simulado para que devuelva un Optional
vacío cuando se le hace una petición de lenguaje con el nombre “Arnoldc”.
A continuación, se realiza una petición GET al endpoint /api/languages/Arnoldc
. Por último se comprueba que el código de respuesta es 404 (Not Found) de acuerdo con el código del @ExceptionHandler
...ResponseEntity.status(ex.getHttpStatus())...
.
Conclusión
Esta publicación es una introducción a Spring MVC testing framework. Se resaltan las ventajas de utilizar MockMvc
en lugar de hacer test unitarios estándar. Se muestran tres formas para preparar MockMvc
y como el empleo de standaloneSetup
nos permite obtener las ventajas de los test unitarios y de los de integración. El post se centra únicamente en probar un controller de Spring REST aunque MockMvc
se puede utilizar para probar la suite completa del framework Spring MVC.
El código fuente completo de este artículo puede encontrarse en Github.
Comentarios en "MockMvc – Introducción a Spring MVC testing framework: Probando endpoints"