Al finalizar este tutorial el estudiante estará en capacidad de realizar la implementación de la lógica de una entidad junto con las pruebas unitarias utilizando el framework Spring Boot.
Para realizar este tutorial Ud. debe:
El siguiente diagrama muestra el modelo conceptual del ejemplo:
Luego de haber implementado las entidades y la persistencia, continuaremos con la implementación de la lógica. En el paquete services
encontramos toda la lógica de la aplicación. Para entender el funcionamiento usaremos como ejemplo la clase BookService
.
@Slf4j
@Service
public class BookService {
@Autowired
BookRepository bookRepository;
@Autowired
EditorialRepository editorialRepository;
...
Observamos que la clase está anotada con @Slf4j y @Service. La primera anotación facilita la creación de logs para tener un registro de los eventos que ocurren en cada método. La segunda indica que la clase es un Servicio de Spring que puede ser usado en otras partes del código mediante una inyección de dependencias.
Tenemos un atributo bookRepository de tipo BookRepository anotado con @Autowired, lo que significa que es una inyección de dependencia; y un atributo editorialRepository de tipo EditorialRepository.
Luego, como se explicará más adelante en este documento, se crearán los métodos de la lógica y posteriormente se implementarán las pruebas.
Una vez implementados los métodos de la lógica el siguiente paso es desarrollar las pruebas unitarias.
Las pruebas se especifican en la siguiente carpeta:
src/test/java/co/edu/uniandes/dse/project-name/services
La convención de nombramiento de las clases que implementan las pruebas será el nombre de la entidad seguido de los sufijos ServiceTest
, por ejemplo BookServiceTest
.
Analicemos la prueba del servicio BookService.
@DataJpaTest
@Transactional
@Import(BookService.class)
class BookServiceTest {
@Autowired
private BookService bookService;
@Autowired
private TestEntityManager entityManager;
private PodamFactory factory = new PodamFactoryImpl();
private List<BookEntity> bookList = new ArrayList<>();
private List<EditorialEntity> editorialList = new ArrayList<>();
/**
* Configuración inicial de la prueba.
*/
@BeforeEach
void setUp() {
clearData();
insertData();
}
/**
* Limpia las tablas que están implicadas en la prueba.
*/
private void clearData() {
entityManager.getEntityManager().createQuery("delete from BookEntity");
entityManager.getEntityManager().createQuery("delete from EditorialEntity");
entityManager.getEntityManager().createQuery("delete from AuthorEntity");
}
/**
* Inserta los datos iniciales para el correcto funcionamiento de las pruebas.
*/
private void insertData() {
for (int i = 0; i < 3; i++) {
EditorialEntity editorialEntity = factory.manufacturePojo(EditorialEntity.class);
entityManager.persist(editorialEntity);
editorialList.add(editorialEntity);
}
for (int i = 0; i < 3; i++) {
BookEntity bookEntity = factory.manufacturePojo(BookEntity.class);
bookEntity.setEditorial(editorialList.get(0));
entityManager.persist(bookEntity);
bookList.add(bookEntity);
}
AuthorEntity authorEntity = factory.manufacturePojo(AuthorEntity.class);
entityManager.persist(authorEntity);
authorEntity.getBooks().add(bookList.get(0));
bookList.get(0).getAuthors().add(authorEntity);
}
...
La clase tiene 3 anotaciones:
@DataJpaTest
. Indica que en la prueba se involucra el acceso a datos con JPA.@Transactional
. Indica que los métodos en la prueba serán transaccionales.@Import(
BookService.class)
. Indica la clase del servicio que se usará en el test. En cada clase se debe inyectar su servicio y el Entity Manager:
@Autowired
private BookService bookService;
@Autowired
private TestEntityManager entityManager;
La primera inyección se usa para tener acceso a los métodos del servicio mientras que la segunda define un EntityManager
para las pruebas (acceso a métodos para persistir y recuperar datos de la persistencia sin pasar por el servicio).
Cada clase debe agregar una referencia a Podam, una librería que facilita la creación de instancias de objetos con datos ficticios.
private PodamFactory factory = new PodamFactoryImpl();
Para este caso se ha definido una lista de entidades que ayudarán a crear las pruebas.
private List<BookEntity> bookList = new ArrayList<>();
private List<EditorialEntity> editorialList = new ArrayList<>();
El siguiente paso es la configuración inicial de la prueba. Esto se hace en el método setUp
anotado por @BeforeEach
, lo que indica que este método se ejecuta antes de cada test. En este método se hace el llamado a los métodos clearData
e insertData
. El primero borra los datos de las tablas implicadas en las pruebas y el segundo se encarga de insertar registros con ayuda del EntityManager
y se guardan en una lista.
@BeforeEach
void setUp() {
clearData();
insertData();
}
A continuación implementaremos los métodos de la lógica junto con sus pruebas unitarias.
Iniciamos con el método que permite crear un nuevo libro.
@Transactional
public BookEntity createBook(BookEntity bookEntity) throws EntityNotFoundException, IllegalOperationException {
log.info("Inicia proceso de creación del libro");
if (bookEntity.getEditorial() == null)
throw new IllegalOperationException("Editorial is not valid");
Optional<EditorialEntity> editorialEntity = editorialRepository.findById(bookEntity.getEditorial().getId());
if (editorialEntity.isEmpty())
throw new IllegalOperationException("Editorial is not valid");
if (!validateISBN(bookEntity.getIsbn()))
throw new IllegalOperationException("ISBN is not valid");
if (!bookRepository.findByIsbn(bookEntity.getIsbn()).isEmpty())
throw new IllegalOperationException("ISBN already exists");
bookEntity.setEditorial(editorialEntity.get());
log.info("Termina proceso de creación del libro");
return bookRepository.save(bookEntity);
}
El método createBook
está anotado con @Transactional
que se utiliza para denotar las funciones que hacen uso de la base de datos de manera transaccional (cumpliendo atributos ACID).
Recibe como parámetro un atributo bookEntity
de tipo BookEntity que contiene los datos necesarios para persistir el nuevo libro.
Como una forma de tener una traza de los eventos que ocurren al ejecutar el método, se incluye un log (soportado por la librería Lombok). Este log mostrará en consola un mensaje que indica que se inicia el proceso de creación de un libro.
Posteriormente se realizan varias validaciones, es decir, se verifican las reglas de negocio. La primera regla consiste en verificar que el valor del atributo editorial del libro no sea nulo. En caso contrario se lanzará una excepción. Si el valor del atributo no es nulo, se buscará en la base de datos que exista una editorial con el id proporcionado. Si la editorial con ese id no existe también se lanzará una excepción.
Luego se valida que el atributo isbn
no sea ni nulo ni una cadena vacía. También se comprueba que no exista otro libro con el mismo ISBN. Para esto se usa el método findByIsbn
definido en el repositorio BookRepository.
Si todas las validaciones son exitosas, se llama al método save
del repositorio y se retorna la entidad la cual tendrá el id del libro generado por la base de datos.
@Test
void testCreateBook() throws EntityNotFoundException, IllegalOperationException {
BookEntity newEntity = factory.manufacturePojo(BookEntity.class);
newEntity.setEditorial(editorialList.get(0));
newEntity.setIsbn("1-4028-9462-7");
BookEntity result = bookService.createBook(newEntity);
assertNotNull(result);
BookEntity entity = entityManager.find(BookEntity.class, result.getId());
assertEquals(newEntity.getId(), entity.getId());
assertEquals(newEntity.getName(), entity.getName());
assertEquals(newEntity.getDescription(), entity.getDescription());
assertEquals(newEntity.getImage(), entity.getImage());
assertEquals(newEntity.getPublishingDate(), entity.getPublishingDate());
assertEquals(newEntity.getIsbn(), entity.getIsbn());
}
En esta prueba se inicia creando un nueva instancia de BookEntity
con datos aleatorios generados por Podam
. A esa instancia se le setea una editorial existente que corresponde a la ubicada en la primera posición de la lista editorialList
. También se setea un ISBN que sea válido. Luego con ayuda del método createBook
del servicio se persiste el libro. Se espera que el retorno del método no sea nulo.
Con ayuda del entity manager se busca en la base de datos el libro con el nuevo id y se validan que todos los atributos de libro almacenado correspondan con los datos del objeto newEntity
.
@Test
void testCreateBookWithNoValidISBN() {
assertThrows(IllegalOperationException.class, () -> {
BookEntity newEntity = factory.manufacturePojo(BookEntity.class);
newEntity.setEditorial(editorialList.get(0));
newEntity.setIsbn("");
bookService.createBook(newEntity);
});
}
En esta prueba se crea un libro con datos generados por Podam. Se setea una editorial existente, pero se setea un isbn que corresponde a una cadena vacía. Se espera entonces que el servicio lance una excepción de tipo IllegalOperationException.
@Test
void testCreateBookWithNoValidISBN2() {
assertThrows(IllegalOperationException.class, () -> {
BookEntity newEntity = factory.manufacturePojo(BookEntity.class);
newEntity.setEditorial(editorialList.get(0));
newEntity.setIsbn(null);
bookService.createBook(newEntity);
});
}
En esta prueba se crea un libro con datos generados por Podam. Se setea una editorial existente, pero se setea un isbn nulo. Se espera entonces que el servicio lance una excepción de tipo IllegalOperationException.
@Test
void testCreateBookWithStoredISBN() {
assertThrows(IllegalOperationException.class, () -> {
BookEntity newEntity = factory.manufacturePojo(BookEntity.class);
newEntity.setEditorial(editorialList.get(0));
newEntity.setIsbn(bookList.get(0).getIsbn());
bookService.createBook(newEntity);
});
}
En esta prueba se crea un libro con datos generados por Podam. Se setea una editorial existente, pero se setea un isbn correspondiente a un libro que está almacenado previamente (el que está en la posición inicial de la lista bookList). Se espera entonces que el servicio lance una excepción de tipo IllegalOperationException.
@Test
void testCreateBookWithInvalidEditorial() {
assertThrows(IllegalOperationException.class, () -> {
BookEntity newEntity = factory.manufacturePojo(BookEntity.class);
newEntity.setIsbn("1-4028-9462-7");
EditorialEntity editorialEntity = new EditorialEntity();
editorialEntity.setId(0L);
newEntity.setEditorial(editorialEntity);
bookService.createBook(newEntity);
});
}
En esta prueba se crea un libro con datos generados por Podam. Se setea una editorial con el id 0 la cual no existe en la base de datos. Se espera entonces que el servicio lance una excepción de tipo IllegalOperationException.
@Test
void testCreateBookWithNullEditorial() {
assertThrows(IllegalOperationException.class, () -> {
BookEntity newEntity = factory.manufacturePojo(BookEntity.class);
newEntity.setIsbn("1-4028-9462-7");
newEntity.setEditorial(null);
bookService.createBook(newEntity);
});
}
En esta prueba se crea un libro con datos generados por Podam. Se setea el valor null en el atributo editorial. Se espera entonces que el servicio lance una excepción de tipo IllegalOperationException.
Este método usa el método findAll del repositorio que traerá todos los libros almacenados de la base de datos. Note que en este caso el retorno no es una entidad sino una lista de entidades (List
).
@Transactional
public List<BookEntity> getBooks() {
log.info("Inicia proceso de consultar todos los libros");
return bookRepository.findAll();
}
@Test
void testGetBooks() {
List<BookEntity> list = bookService.getBooks();
assertEquals(bookList.size(), list.size());
for (BookEntity entity : list) {
boolean found = false;
for (BookEntity storedEntity : bookList) {
if (entity.getId().equals(storedEntity.getId())) {
found = true;
}
}
assertTrue(found);
}
}
En esta prueba, con ayuda del método getBooks
del servicio se obtiene el listado de todos los libros almacenados en la base de datos. Se espera que el tamaño de ese listado sea igual al tamaño de la lista bookList
. Luego se itera la lista y debe haber una correspondencia en el id entre los libros retornados por el servicio y los almacenados en la lista.
Este método busca un libro por id. Recibe como parámetro el id del libro que se quiere consultar. Con ayuda del método findById
del repositorio se buscará el libro. En este caso el retorno de ese método es de tipo Optional
. Esto significa que es posible que el método retorne null
si ese libro no existe. Para evitar una excepción de tipo null pointer exception
, un tipo de dato Optional
obliga a verificar que el atributo no sea vacío (bookEntity.isEmpty()
). En caso de que sea vacío se retorna una excepción. Si no es vacío se retorna el elemento llamando al método get()
del Optional
.
@Transactional
public BookEntity getBook(Long bookId) throws EntityNotFoundException {
log.info("Inicia proceso de consultar el libro con id = {0}", bookId);
Optional<BookEntity> bookEntity = bookRepository.findById(bookId);
if (bookEntity.isEmpty())
throw new EntityNotFoundException(ErrorMessage.BOOK_NOT_FOUND);
log.info("Termina proceso de consultar el libro con id = {0}", bookId);
return bookEntity.get();
}
@Test
void testGetBook() throws EntityNotFoundException {
BookEntity entity = bookList.get(0);
BookEntity resultEntity = bookService.getBook(entity.getId());
assertNotNull(resultEntity);
assertEquals(entity.getId(), resultEntity.getId());
assertEquals(entity.getName(), resultEntity.getName());
assertEquals(entity.getDescription(), resultEntity.getDescription());
assertEquals(entity.getIsbn(), resultEntity.getIsbn());
assertEquals(entity.getImage(), resultEntity.getImage());
}
En esta prueba, con ayuda del método getBook
del servicio se obtiene el libro con el id correspondiente al almacenado en la primera posición de la lista bookList
. Se espera que todos los atributos del libro retornado por el servicio correspondan con el almacenado en la lista.
@Test
void testGetInvalidBook() {
assertThrows(EntityNotFoundException.class,()->{
bookService.getBook(0L);
});
}
En esta prueba se busca el libro con el id 0 el cual no existe en la base de datos. Por lo tanto, se espera que el método lance una excepción.
Este método recibe el id del libro que se quiere actualizar y la entidad con los nuevos datos del libro. Se busca el libro por el id proporcionado; en caso de que no exista se lanza una excepción.
También se valida que el isbn que se proporciona sea válido. Si esto es correcto a la entidad que se pasa como parámetro se le asigna el id del libro existente y se llama al método save
del repositorio. En este caso, como el libro con el id proporcionado ya existe, el método save
no creará un nuevo libro sino que actualizará el existente.
@Transactional
public BookEntity updateBook(Long bookId, BookEntity book)
throws EntityNotFoundException, IllegalOperationException {
log.info("Inicia proceso de actualizar el libro con id = {0}", bookId);
Optional<BookEntity> bookEntity = bookRepository.findById(bookId);
if (bookEntity.isEmpty())
throw new EntityNotFoundException(ErrorMessage.BOOK_NOT_FOUND);
if (!validateISBN(book.getIsbn()))
throw new IllegalOperationException("ISBN is not valid");
book.setId(bookId);
log.info("Termina proceso de actualizar el libro con id = {0}", bookId);
return bookRepository.save(book);
}
@Test
void testUpdateBook() throws EntityNotFoundException, IllegalOperationException {
BookEntity entity = bookList.get(0);
BookEntity pojoEntity = factory.manufacturePojo(BookEntity.class);
pojoEntity.setId(entity.getId());
bookService.updateBook(entity.getId(), pojoEntity);
BookEntity resp = entityManager.find(BookEntity.class, entity.getId());
assertEquals(pojoEntity.getId(), resp.getId());
assertEquals(pojoEntity.getName(), resp.getName());
assertEquals(pojoEntity.getDescription(), resp.getDescription());
assertEquals(pojoEntity.getIsbn(), resp.getIsbn());
assertEquals(pojoEntity.getImage(), resp.getImage());
assertEquals(pojoEntity.getPublishingDate(), resp.getPublishingDate());
}
En esta prueba se toma el libro almacenado en la posición inicial de la lista bookList
. Luego se crea una instancia de un libro y se le setea el id de un libro existente. Posteriormente, se llama al método updateBook
del servicio para actualizar los datos.
Ahora se verifica que los datos del libro consultado con el entity manager correspondan con los que están en el objeto denominado pojoEntity
.
@Test
void testUpdateBookInvalid() {
assertThrows(EntityNotFoundException.class, () -> {
BookEntity pojoEntity = factory.manufacturePojo(BookEntity.class);
pojoEntity.setId(0L);
bookService.updateBook(0L, pojoEntity);
});
}
En esta prueba se intenta actualizar un libro con un id que no existe; por tanto se espera que el método lance una excepción.
@Test
void testUpdateBookWithNoValidISBN() {
assertThrows(IllegalOperationException.class, () -> {
BookEntity entity = bookList.get(0);
BookEntity pojoEntity = factory.manufacturePojo(BookEntity.class);
pojoEntity.setIsbn("");
pojoEntity.setId(entity.getId());
bookService.updateBook(entity.getId(), pojoEntity);
});
}
En esta prueba se intenta actualizar un libro que existe pero al que se le ha actualizado el isbn a una cadena vacía; por tanto se espera que el método lance una excepción.
@Test
void testUpdateBookWithNoValidISBN2() {
assertThrows(IllegalOperationException.class, () -> {
BookEntity entity = bookList.get(0);
BookEntity pojoEntity = factory.manufacturePojo(BookEntity.class);
pojoEntity.setIsbn(null);
pojoEntity.setId(entity.getId());
bookService.updateBook(entity.getId(), pojoEntity);
});
}
En esta prueba se intenta actualizar un libro que existe pero al que se le ha actualizado el isbn a null; por tanto se espera que el método lance una excepción.
Este método recibe el id del libro que se quiere eliminar. Si el libro con ese id no existe se lanza una excepción.
En este método también se valida que el libro no tenga autores asociados. En caso de tenerlos también se lanza una excepción.
Si la validación es correcta se invoca al método deleteById
del repositorio. Este método no tiene un retorno.
@Transactional
public void deleteBook(Long bookId) throws EntityNotFoundException, IllegalOperationException {
log.info("Inicia proceso de borrar el libro con id = {0}", bookId);
Optional<BookEntity> bookEntity = bookRepository.findById(bookId);
if (bookEntity.isEmpty())
throw new EntityNotFoundException(ErrorMessage.BOOK_NOT_FOUND);
List<AuthorEntity> authors = bookEntity.get().getAuthors();
if (!authors.isEmpty())
throw new IllegalOperationException("Unable to delete book because it has associated authors");
bookRepository.deleteById(bookId);
log.info("Termina proceso de borrar el libro con id = {0}", bookId);
}
@Test
void testDeleteBook() throws EntityNotFoundException, IllegalOperationException {
BookEntity entity = bookList.get(1);
bookService.deleteBook(entity.getId());
BookEntity deleted = entityManager.find(BookEntity.class, entity.getId());
assertNull(deleted);
}
En esta prueba se toma el libro que está en la primera posición de la lista bookList
. Se llama al método deleteBook
del servicio pasando el id de ese libro. Luego, con ayuda del entity manager se busca ese libro y se espera que el resultado sea nulo ya que debió eliminarse.
@Test
void testDeleteInvalidBook() {
assertThrows(EntityNotFoundException.class, ()->{
bookService.deleteBook(0L);
});
}
En esta prueba se borra un libro con un id que no existe. Se espera por tanto que el método lance una excepción.
@Test
void testDeleteBookWithAuthor() {
assertThrows(IllegalOperationException.class, () -> {
BookEntity entity = bookList.get(0);
bookService.deleteBook(entity.getId());
});
}
En esta prueba se borra un libro que tiene asociados autores; por tanto se espera que el método deleteBook
del servicio lance una excepción.