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

Escribe un comentario