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 en 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.
@Controller
@RequestMapping(value = "/api", produces = MimeTypeUtils.APPLICATION_JSON_VALUE)
public class LanguageApiController {
private final LanguageService languageService;
@Autowired
public LanguageApiController(
LanguageService languageService) {
this.languageService = languageService;
}
@RequestMapping(value = "/languages", method = GET)
public ResponseEntity<List<Language>> getLanguages(@RequestParam(value = "contains", required = false) String contains) {
return ResponseEntity.ok(languageService.getLanguages(contains));
}
@RequestMapping(value = "/languages/{name}", method = GET)
public ResponseEntity<Language> getLanguage(@PathVariable("name") String name) {
return ResponseEntity.ok(languageService.getLanguage(name).orElseThrow(() -> new SpringMockMvcException(
HttpStatus.NOT_FOUND, "Language was not found")));
}
@ExceptionHandler(SpringMockMvcException.class)
public ResponseEntity<String> onSpringMockMvcException(HttpServletRequest request, SpringMockMvcException ex) {
return ResponseEntity.status(ex.getHttpStatus()).body(String.format("%s - %s",
ex.getHttpStatus().value(), ex.getMessage()));
}
}
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
.
@Test
public void getLanguages_null_shouldReturnListOfStrings() throws Exception {
// Given
final String mockedEsoteric = "Arnoldc";
final List<String> mockedLanguages = Stream.concat(
LanguageRepository.LANGUAGES.stream(),
Stream.of(mockedEsoteric)).collect(Collectors.toList());
doReturn(mockedLanguages).when(languageService).getLanguages(null);
// When
final ResponseEntity<List<String>> result = languageApiController.getLanguages(null);
// Then
assertThat(result.getBody(), hasSize(mockedLanguages.size()));
assertThat(result.getBody(), hasItem(mockedEsoteric));
}
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
:
@RunWith(SpringRunner.class)
@WebMvcTest(controllers = LanguageApiController.class)
public class LanguageApiControllerWebMvcTest {
@Autowired
private MockMvc mockMvc;
@Test
public void getLanguages_null_shouldReturnOk() throws Exception {
// Given
// Real application context
// When
final ResultActions result = mockMvc.perform(
get("/api/languages")
.accept(MimeTypeUtils.APPLICATION_JSON_VALUE));
// Then
final int expectedSize = LANGUAGES.size();
final String[] expectedLanguageNames = LANGUAGES.stream().map(Language::getName)
.collect(Collectors.toList()).toArray(new String[LANGUAGES.size()]);
result.andExpect(status().isOk());
result.andExpect(jsonPath("$.length()").value(expectedSize));
result.andExpect(jsonPath("$[*].name", containsInAnyOrder(expectedLanguageNames)));
}
}
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:
@RunWith(SpringJUnit4ClassRunner.class)
@WebAppConfiguration
@ContextConfiguration(classes = {
SpringMockMvcConfiguration.class
})
public class LanguageApiControllerApplicationIntegrationTest {
@Autowired
private WebApplicationContext webApplicationContext;
private MockMvc mockMvc;
@Before
public void setUp() {
mockMvc = MockMvcBuilders
.webAppContextSetup(webApplicationContext)
.build();
}
/*...*/
@Test
public void getLanguages_null_shouldReturnOk() throws Exception {
// Given
// Real application context
// When
final ResultActions result = mockMvc.perform(
get("/api/languages")
.accept(MimeTypeUtils.APPLICATION_JSON_VALUE));
// Then
final int expectedSize = LANGUAGES.size();
final String[] expectedLanguageNames = LANGUAGES.stream().map(Language::getName)
.collect(Collectors.toList()).toArray(new String[LANGUAGES.size()]);
result.andExpect(status().isOk());
result.andExpect(jsonPath("$.length()").value(expectedSize));
result.andExpect(jsonPath("$[*].name", containsInAnyOrder(expectedLanguageNames)));
}
}
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:
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = {
LanguageApiController.class
})
@Import(Config.class)
public class LanguageApiControllerTest {
@Autowired
private LanguageService languageService;
@Autowired
private LanguageApiController languageApiController;
private MockMvc mockMvc;
@Before
public void setUp() {
mockMvc = MockMvcBuilders
.standaloneSetup(languageApiController)
.build();
}
/* ... */
@TestConfiguration
protected static class Config {
@Bean
public LanguageService languageService() {
return Mockito.mock(LanguageService.class);
}
}
}
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
:
@Test
public void getLanguages_null_shouldReturnOk() throws Exception {
// Given
final Language mockedEsoteric = new Language("Arnoldc", "Lauri Hartikka");
final List<Language> mockedLanguages = Stream.concat(
LanguageRepository.LANGUAGES.stream(),
Stream.of(mockedEsoteric)).collect(Collectors.toList());
doReturn(mockedLanguages).when(languageService).getLanguages(null);
// When
final ResultActions result = mockMvc.perform(
get("/api/languages")
.accept(MimeTypeUtils.APPLICATION_JSON_VALUE));
// Then
result.andExpect(status().isOk());
result.andExpected(jsonPath("$.length()").value(mockedLanguages.size()));
result.andExpected(jsonPath("$[?(@.name === 'Arnoldc')]").exists());
}
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
.
@Test
public void getLanguages_invalidAcceptHeader_shouldReturnNotAcceptable() throws Exception {
// Given
final String invalidAcceptMimeType = MimeTypeUtils.APPLICATION_XML_VALUE;
doReturn(LanguageRepository.LANGUAGES).when(languageService).getLanguages(null);
// When
final ResultActions result = mockMvc.perform(
get("/api/languages")
.accept(invalidAcceptMimeType));
// Then
result.andExpected(status().isNotAcceptable());
}
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.
@Test
public void getLanguage_nonExistingLanguage_shouldReturnNotFound() throws Exception {
// Given
final String mockedLanguageName = "Arnoldc";
doReturn(Optional.empty()).when(languageService).getLanguage(mockedLanguageName);
// When
final ResultActions result = mockMvc.perform(
get("/api/languages/".concat(mockedLanguageName))
.accept(MimeTypeUtils.APPLICATION_JSON_VALUE));
// Then
result.andExpected(status().isNotFound());
}
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"