Tests integrados en Spring Boot con Fongo

En los procesos de transformación digital de cualquier empresa, la migración de las arquitecturas monolíticas a arquitecturas basadas en microservicios es prácticamente una obligación. Como ya hemos comentado en otros posts, los microservicios ofrecen múltiples ventajas como escalado, integración con otros sistemas, simplicitud, etc. Pero también obliga a los desarrolladores a que su código sea más sólido y de mayor calidad, lo que obliga a elaborar tests de gran calidad para asegurar esta solidez.

Para desarrollar microservicios usando Java como lenguaje de programación, Spring, con su módulo SpringBoot, facilita mucho la tarea de implementar dicho microservicio. Si además, debe integrarse con una base de datos NoSQL como puede ser MongoDB, Spring también ofrece módulos que facilitan esta integración.

Una vez tenemos implementado nuestro microservicio SpringBoot con su base de datos se recomienda utilizar una base de datos en memoria para poner a prueba todo el flujo a la hora de diseñar los test integrados, desde la llamada REST a la lógica de base de datos.

Para implementar tests integrados en servicios SpringBoot con MongoDB, una posible solución es utilizar Fongo. Y de ello vamos a hablar en este post.

Fongo permite cargar una base de datos MongoDB en memoria para ser usada en los tests integrados. La última versión hasta la fecha del módulo Spring Data MongoDB (versión 1.9) ha añadido la compatibilidad con MongDB 3.0 y con el Driver de Java para MongoDB versión 3.2.

En primer lugar iremos a la página de Spring Initializer para generar el esqueleto de nuestro microservicio con SpringBoot:

Seleccionamos las dependencias de Web y MongoDB y haremos clic en Generate project para descargar el esqueleto, que importaremos en como proyecto Maven en el IDE que usemos.

Una vez importado el proyecto, la estructura que tendremos será la siguiente:

Para este ejemplo, hemos usado la versión 1.4.2 de SpringBoot. Incluyendo el starter de MongoDB nos incluye automáticamente la versión 3.2.2 del driver de MongoDB para Java. Para dicha versión, debemos usar la versión 2.0.x de Fongo.

Nuestro microservicio de ejemplo tendrá dos operaciones: Una para leer los datos de una persona por un id y otra para grabar los datos de una nueva persona. El bean Persona tendrá los siguientes datos:

import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;


@Document(collection = "person")
public class Person {


	@Id
	private int id;
	private String name;
	private String address;
	
public void setId(int id) {
		this.id = id;
	}
public int getId(){
		return id;
	}
	public String getName() {
		return name;
	}
	public void setName(String name) {
		this.name = name;
	}
	public String getAddress() {
		return address;
	}
	public void setAddress(String address) {
		this.address = address;
	}
}

Una vez tenemos el bean de datos, en la capa de acceso a datos, en lugar de crear una interfaz que extiende de la clase MongoRepository de spring-data-mongodb, hemos creado una interfaz para nuestra capa de acceso a datos con los métodos necesarios y una implementación de esa interfaz con una clase MongoOperation inyectada por constructor:

Interfaz:

import com.isolisduran.springboot.bean.Person;


public interface PersonRepository {
	Person findOne(int id);
	Person save(Person person);
}

Implementación:

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.mongodb.core.MongoOperations;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.stereotype.Repository;


import com.isolisduran.springboot.bean.Person;
import com.isolisduran.springboot.repository.PersonRepository;


@Repository
public class PersonRepositoryMongo implements PersonRepository{


	private final MongoOperations mongoOperations;
	
	@Autowired
	public PersonRepositoryMongo(MongoOperations mongoOperations) {
        this.mongoOperations = mongoOperations;
	}
	
	@Override
	public Person findOne(int id) {
		return mongoOperations.findOne(new Query(Criteria.where("id").is(id)), Person.class);
	}


	@Override
	public Person save(Person person) {
		mongoOperations.save(person);
		return person;
	}
}

La capa de lógica de negocio quedaría de la siguiente forma:

Interfaz

import com.isolisduran.springboot.bean.Person;


public interface PersonService {
	public Person getById(int id);
	public Person create(Person person);
}

Implementación

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;


import com.isolisduran.springboot.bean.Person;
import com.isolisduran.springboot.repository.PersonRepository;
import com.isolisduran.springboot.service.PersonService;


@Service
public class PersonServiceDefault implements PersonService {


	@Autowired
	private PersonRepository personRepository;
	
	@Override
	public Person getById(int id) {
		return personRepository.findOne(id);
	}


	@Override
	public Person create(Person person) {
		return personRepository.save(person);
	}
}

Y el Controller que expone el API REST quedaría de la siguiente forma:

import javax.validation.Valid;


import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;


import com.isolisduran.springboot.bean.Person;
import com.isolisduran.springboot.service.PersonService;


@RequestMapping("/api")
@RestController
public class PersonController {


	@Autowired
	private PersonService personService;


	@RequestMapping(method = RequestMethod.GET, value = "/person/{id}", produces = MediaType.APPLICATION_JSON_VALUE)
	public @ResponseBody ResponseEntity<Person> getById(@PathVariable int id) {
		Person person = personService.getById(id);
		return new ResponseEntity<>(person, HttpStatus.OK);
	}


	@RequestMapping(method = RequestMethod.POST, value = "/person", produces = MediaType.TEXT_PLAIN_VALUE, consumes = MediaType.APPLICATION_JSON_VALUE)
	public @ResponseBody ResponseEntity<String> create(@Valid @RequestBody Person person) {
		Person newPerson = personService.create(person);
		return new ResponseEntity<String>(String.valueOf(newPerson.getId()), HttpStatus.CREATED);
	}
}

Con este código tendríamos microservicio implementado con Java+SpringBoot que usa una base de datos MongoDB y que podemos compilar y testear ejecutando desde línea de comandos la instrucción:

$: mvn clean package

Veremos que además de compilar, también ejecuta un test que prueba que el microservicio arranca correctamente. Veremos en el log que aunque el test no falla, muestra un error del tipo:

Exception in monitor thread while connecting to server localhost:27017

Esto se debe a que por defecto el driver de Mongo intenta establecer en el arranque una conexión a una base de datos local. Por ahora lo podemos obviar y borrar la clase del paquete de test, pero para entornos productivos deberíamos excluir la autoconfiguración de Mongo y cargar manualmente la configuración de MongoDB en una clase de configuración.

Para incluir Fongo en nuestro proyecto, podemos incluir la dependencia de Fongo en el pom.xml de nuestro proyecto Maven de la siguiente forma:

<dependency>
   <groupId>com.github.fakemongo</groupId>
   <artifactId>fongo</artifactId>
   <version>2.0.6</version>
   <scope>test</scope>
</dependency>

Una vez incluida la dependencia de Fongo, procedemos a implementar nuestros tests integrados. En este caso, probaremos las dos operaciones de nuestro API REST. El objetivo es probar esta llamada mockeando la base de datos usando Fongo.

Los pasos son los siguientes:

  • Crear una clase abstracta que extienda de AbstractMongoConfiguration en la cual cargaremos a nivel de test la base de datos en memoria.

– Sobreescribiremos el método getDatabaseName() para devolver un nombre “fake” de base de datos (por ejemplo “test”) obteniéndolo de la propiedad spring.data.mongodb.database. Esta propiedad la inicializaremos en el test usando la anotación @TestPropertySource.

– Sobreescribiremos también el método mongo() en el que instanciamos la base de datos Fongo.

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.env.Environment;
import org.springframework.data.mongodb.config.AbstractMongoConfiguration;


import com.github.fakemongo.Fongo;
import com.mongodb.Mongo;


public abstract class AbstractFongoBaseConfiguration extends AbstractMongoConfiguration{


    @Autowired
    private Environment env;


    @Override
    protected String getDatabaseName() {
        return env.getRequiredProperty("spring.data.mongodb.database");
    }


    @Override
    public Mongo mongo() throws Exception {
        return new Fongo(getDatabaseName()).getMongo();
    }
}
  • Crear una clase de configuración para cargar la configuración de Fongo. Esta clase, que extiende de la clase AbstractFondoBaseConfiguration es la responsable de excluir las clases base de configuración de Mongo para que no cargue y trate de conectar con una base de datos real al arrancar el contexto de Spring.
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.data.mongo.MongoDataAutoConfiguration;
import org.springframework.boot.autoconfigure.mongo.MongoAutoConfiguration;
import org.springframework.boot.autoconfigure.mongo.embedded.EmbeddedMongoAutoConfiguration;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;


@EnableAutoConfiguration(exclude = { EmbeddedMongoAutoConfiguration.class, MongoAutoConfiguration.class,
		MongoDataAutoConfiguration.class })
@Configuration
@ComponentScan(basePackages = { "com.isolisduran.springboot" }, excludeFilters = {
		@ComponentScan.Filter(classes = { SpringBootApplication.class }) })
public class ConfigServerWithFongoConfiguration extends AbstractFongoBaseConfiguration {


}

Con esto podemos proceder a implementar nuestro test integrado que arrancará el contexto de Spring para poder llamar a nuestro API REST y comprobar el flujo completo.

  • Incluir la anotación @SpringBootTest para cargar la configuración de Fongo y la propia base de datos Fongo:
@SpringBootTest(classes = { ConfigServerWithFongoConfiguration.class})
  • Incluir la anotación @TestPropertySource para establecer el nombre de la base de datos que mockeará Fongo:
@TestPropertySource(properties = {"spring.data.mongodb.database=test"})

Con estos dos puntos tenemos configurada la Fongo para simular una base de datos MongoDB.

Una vez hecho esto, si necesitamos cargar datos específicos para realizar el test  integrado, podemos usar la clase MongoTemplate para crear colecciones y documentos:

  • Inyectamos la dependencia de MongoTemplate:
@Autowired
private MongoTemplate mongoTemplate;
  • Usamos esta instancia para crear colecciones, documentos, etc:
mongoTemplate.createCollection("collection-name");
mongoTemplate.insert(objectToInsert);

Con todo esto, un ejemplo de test integrado completo podría ser:

import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
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.boot.test.context.SpringBootTest.WebEnvironment;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.http.MediaType;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.ResultActions;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.result.MockMvcResultMatchers;


import com.fasterxml.jackson.databind.ObjectMapper;
import com.isolisduran.springboot.bean.Person;


import itest.config.ConfigServerWithFongoConfiguration;


@RunWith(SpringJUnit4ClassRunner.class)
@SpringBootTest(classes = { ConfigServerWithFongoConfiguration.class }, properties = {
		"server.port=8980" }, webEnvironment = WebEnvironment.DEFINED_PORT)
@AutoConfigureMockMvc
@TestPropertySource(properties = { "spring.data.mongodb.database=test" })
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
public class PersonControllerTest {


	@Autowired
	private MongoTemplate mongoTemplate;


	@Autowired
	private MockMvc mockMvc;


	private ObjectMapper jsonMapper;


	@Before
	public void setUp() {
		jsonMapper = new ObjectMapper();
	}


	@Test
	public void testGetPerson() throws Exception {


		Person personFongo = new Person();
		personFongo.setId(1);
		personFongo.setName("Name1");
		personFongo.setAddress("Address1");
		mongoTemplate.createCollection("person");
		mongoTemplate.insert(personFongo);


		ResultActions resultAction = mockMvc.perform(MockMvcRequestBuilders.get("http://localhost:8090/api/person/1"));
		resultAction.andExpect(MockMvcResultMatchers.status().is2xxSuccessful());
		MvcResult result = resultAction.andReturn();
		Person personResponse = jsonMapper.readValue(result.getResponse().getContentAsString(), Person.class);
		Assert.assertEquals(1, personResponse.getId());
		Assert.assertEquals("Name1", personResponse.getName());
		Assert.assertEquals("Address1", personResponse.getAddress());


	}


	@Test
	public void testCreatePerson() throws Exception {


		Person personRequest = new Person();
		personRequest.setId(1);
		personRequest.setName("Name1");
		personRequest.setAddress("Address1");


		String body = jsonMapper.writeValueAsString(personRequest);


		ResultActions resultAction = mockMvc.perform(MockMvcRequestBuilders.post("http://localhost:8090/api/person")
				.contentType(MediaType.APPLICATION_JSON).content(body));
		resultAction.andExpect(MockMvcResultMatchers.status().isCreated());


		Person personFongo = mongoTemplate.findOne(new Query(Criteria.where("id").is(1)), Person.class);
		Assert.assertEquals(personFongo.getName(), "Name1");
		Assert.assertEquals(personFongo.getAddress(), "Address1");
	}
}

Con esta clase de test estamos probando:

  1. Que al llamar a la operación GET /api/person/1 nos devuelve el documento que hemos insertado en la base de datos Fongo.
  2. Que al llamar a la operación POST /api/person, inserta correctamente en la base de datos Fongo el objeto pasado en el body.

Estos tests permiten probar todo el proceso completo que se realiza al llamar a un API REST, desde la propia llamada hasta el repositorio.

Con este post queremos demostrar lo sencillo que es incorporar tests integrados en nuestros servicios que usen una base de datos MongoDB, permitiendo simular todos los comportamientos que consideremos necesarios gracias a la versatilidad que ofrece Fongo, lo que desemboca en la construcción de servicios mucho más sólidos

Podéis encontrar el código de ejemplo en este repositorio de GitHub.

Con más de 10 años como Software Developer en Java, desde J2EE tradicional hasta servicios Cloud-Ready, le encanta "cacharrear" con todo lo tecnológico tanto en casa como en el trabajo. Además, es un apasionado de la fotografía y "runner" en sus ratos libres. Huye de los "dinosaurios tecnológicos" y busca equipos altamente motivados. Actualmente es Arquitecto de Software en Paradigma.

Ver toda la actividad de Isidro Solís

8 comentarios

  1. Anatoli dice:

    Gracias por una guía tan completa y específica, la verdad es que la estaba buscando (curioso cuanto menos que ni si quiera en ingles haya encontrado nada equivalente).

    Es la primera vez que me pongo con Spring y me gustaría aclarar el tema de las distintas configuraciones.

    Me he descargado tu código y me he fijado que la configuración para fongo debe de ir en el package de test, un gran descubrimiento para mi y que tiene todo el sentido del mundo (por eso tenia errores con la dependencia de fongo al intentar compilar porque tenia el scope fijado a test).

    Digamos que queremos dos configuraciones, como es el caso, una para producción y otra solo para los tests. Ya tenemos la segunda (fongo). Tu usas MongoConfiguration.java, esa configuración se carga también cuando se ejecutan los tests verdad? Porque no veo que la excluyas. Si quisiera tener una configuración de producción tal que asi: https://gist.github.com/avk2/85b78c876361daef7c1a547b59ebc43e Que debería de hacer para que no haya conflicto? Ahora mismo me esta dando error de conexión con mongo cuando intento compilar porque la configuración se carga. Como hago la distinción de que dicha configuración solo se use en tiempo de ejecucion y no durante la compilación ni tampoco para los tests? No se si lo que digo tiene algun sentido, corrigeme si es el caso. A parte de eso, ademas me gustaria que tanto una configuración como la otra tuvieran cosas en comun (@beans de eventos de mongo por ejemplo), supongo que debería de crear una 3a configuración con dichos beans?

    Eso es todo, un cordial saludo!

    Anatoli.

    • Isidro Solís dice:

      Hola Anatoli,
      Gracias por tus comentarios. Siempre es grato ver que tu conocimiento puede ser de utilidad para otros.
      En cuanto a tus preguntas, no estoy seguro de tu problema, pero aislando los casos, si tú quieres tener configuraciones distintas por entorno, lo que recomiendo es que uses los perfiles de spring, que te permiten tener ficheros de configuración para diferentes perfiles y, activar dichos perfiles en función del entorno en el que estés. En el ejemplo, hay un fichero de propiedades (application.yml) donde está definida una propiedad con la cadena de conexión que luego usa en la clase MongoConfiguration. Teniendo varios ficheros de propiedades como entornos, podrías tener distintas cadenas de conexión en función del entorno.

      Échale un ojo a esta documentación de los perfiles de SpringBoot que seguramente te pueda ayudar: https://docs.spring.io/spring-boot/docs/current/reference/html/boot-features-profiles.html

      Un saludo,

      • Anatoli dice:

        En parte sí, me faltaba eso, pero claro, si la aplicación (el main) tiene anotado:

        @EnableAutoConfiguration(exclude={MongoAutoConfiguration.class, MongoDataAutoConfiguration.class})

        Entonces seguiría sin funcionar, no? Que por cierto, comentándolo tampoco se soluciona.
        No estoy seguro de si hago un bueno uso de los perfiles. Lo que he echo es solo añadir spring.profiles.active=production en application.properites y anotar mi configuración con @Profile(“production”).

        Resumiendo y aclarando lo que quería decir antes: Quisiera que fuera transparente y automatizado (cambiando solo el perfil?) el ir desarrollando y testeando (unit tests + tests integrados) con fongo, y poder desplegar la aplicación normalmente conectándose al servidor de mongo real.

        Ahora mismo, habiendo seguido tu ejemplo, solo me permite compilar y testear contra fongo, pero no puedo desplegar la aplicación y probarla con una instancia de mongo.

        • Isidro Solís dice:

          Con los exclude lo que hago es que no use la autoconfiguración de Mongo y que así use la clase MongoConfiguration.
          En cuanto a los perfiles, la propiedad spring.profiles.active la tendrías que setear, por ejemplo, al arrancar tu servicio (java -jar miServicio.jar -Dspring.profiles.active=pro) y en el fichero de propiedades tener la cadena de conexión a la Mongo establecida por perfiles, de la forma:

          spring:
          profiles: dev

          spring:
          data:
          mongodb:
          uri: mongodb://test:27017/testdev


          spring:
          profiles: pro

          spring:
          data:
          mongodb:
          uri: mongodb://test:27017/testpro

          Entonces, dependiendo de qué entorno quieras usar, arrancarías el servicio con un perfil u otro dependiendo de la cadena de conexión a usar.

          Acabo de compilar y ejecutar el servicio y me ha conectado sin problemas a una instancia de mongo arrancada en local.

          Un saludo.

          • Anatoli dice:

            Pues he detectado el problema, esta en mi configuración, que se espera que haya una instancia de mongo arrancada. Lo he probado con tu configuración y funciona correctamente (al compilar no falla si no hay instancia de mongo y se conecta a mongo una vez en ejecución). Seguiré probando con tu configuración a ver, supongo que es equivalente a la mía pero de diferente forma?

            Gracias crack!

          • Isidro Solís dice:

            Ya sabes que en desarrollo, casi como en la vida, no hay una única forma de hacer las cosas. Suerte y gracias por tus comentarios.

            Un saludo.

  2. Anatoli dice:

    Hola de nuevo Isido, tengo otra duda jeje.

    Sabes si fongo permite que se inizialice con datos? Me refiero a usar el bean de repositoryPopulator con fongo. Tengo ésto: https://gist.github.com/avk2/5119d79b157afa0885ad24ef50cc546e
    Me he basado en la respuesta de Oliver G. https://stackoverflow.com/a/13901416/2968729

    En el log me sale que los datos se cargan, pero a la hora de la verdad, al ejecutar los tests de integración y intentar cargar dichos datos, falla (se obtiene null). Alguna idea?

    Salut

Escribe un comentario