Lab - Testing the Book Catalog Application
Objective
In this lab, you will implement comprehensive test cases for the Book Catalog application from Module 4. You’ll write unit tests for repositories, services, and controllers, as well as integration tests for the entire application.
Prerequisites
- Completed Book Catalog application from Module 4
- Java 11 or higher
- Maven or Gradle
- IDE (IntelliJ IDEA, Eclipse, or VS Code)
Step 1: Set Up the Testing Environment
First, let’s ensure we have all the necessary dependencies for testing. Open your pom.xml
file and check if the following dependencies are included:
<!-- Test dependencies -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
Next, create a testing configuration file in src/test/resources/application.properties
:
# Use H2 database for testing
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=password
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.jpa.hibernate.ddl-auto=create-drop
# Disable Prometheus metrics for testing
management.endpoints.web.exposure.include=health,info
Step 2: Write Repository Tests
Create a new test class for the BookRepository
in src/test/java/com/example/bookcatalog/repository/BookRepositoryTest.java
:
package com.example.bookcatalog.repository;
import com.example.bookcatalog.model.Book;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.test.context.ActiveProfiles;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
@DataJpaTest
@ActiveProfiles("test")
class BookRepositoryTest {
@Autowired
private BookRepository bookRepository;
@Test
@DisplayName("Should find book by ID")
void shouldFindBookById() {
// Arrange
Book book = new Book();
book.setTitle("Test Book");
book.setAuthor("Test Author");
book.setIsbn("1234567890");
book.setDescription("Test Description");
Book savedBook = bookRepository.save(book);
// Act
Optional<Book> foundBook = bookRepository.findById(savedBook.getId());
// Assert
assertTrue(foundBook.isPresent());
assertEquals(savedBook.getId(), foundBook.get().getId());
assertEquals("Test Book", foundBook.get().getTitle());
assertEquals("Test Author", foundBook.get().getAuthor());
}
@Test
@DisplayName("Should find all books")
void shouldFindAllBooks() {
// Arrange
bookRepository.deleteAll(); // Ensure clean state
Book book1 = new Book();
book1.setTitle("Test Book 1");
book1.setAuthor("Test Author 1");
book1.setIsbn("1234567890");
bookRepository.save(book1);
Book book2 = new Book();
book2.setTitle("Test Book 2");
book2.setAuthor("Test Author 2");
book2.setIsbn("0987654321");
bookRepository.save(book2);
// Act
List<Book> books = bookRepository.findAll();
// Assert
assertEquals(2, books.size());
}
@Test
@DisplayName("Should find books by author")
void shouldFindBooksByAuthor() {
// Arrange
String author = "Test Author";
Book book1 = new Book();
book1.setTitle("Test Book 1");
book1.setAuthor(author);
book1.setIsbn("1234567890");
bookRepository.save(book1);
Book book2 = new Book();
book2.setTitle("Test Book 2");
book2.setAuthor(author);
book2.setIsbn("0987654321");
bookRepository.save(book2);
Book book3 = new Book();
book3.setTitle("Test Book 3");
book3.setAuthor("Another Author");
book3.setIsbn("5555555555");
bookRepository.save(book3);
// Act
List<Book> books = bookRepository.findByAuthorContainingIgnoreCase(author);
// Assert
assertEquals(2, books.size());
books.forEach(book -> assertTrue(book.getAuthor().equals(author)));
}
@Test
@DisplayName("Should find book by ISBN")
void shouldFindBookByISBN() {
// Arrange
String isbn = "1234567890";
Book book = new Book();
book.setTitle("Test Book");
book.setAuthor("Test Author");
book.setIsbn(isbn);
bookRepository.save(book);
// Act
Optional<Book> foundBook = bookRepository.findByIsbn(isbn);
// Assert
assertTrue(foundBook.isPresent());
assertEquals(isbn, foundBook.get().getIsbn());
}
}
Step 3: Write Service Tests
Create a test class for the BookService
in src/test/java/com/example/bookcatalog/service/BookServiceTest.java
:
package com.example.bookcatalog.service;
import com.example.bookcatalog.exception.BookNotFoundException;
import com.example.bookcatalog.model.Book;
import com.example.bookcatalog.repository.BookRepository;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class BookServiceTest {
@Mock
private BookRepository bookRepository;
@InjectMocks
private BookServiceImpl bookService;
@Test
@DisplayName("Should find all books")
void shouldFindAllBooks() {
// Arrange
List<Book> books = Arrays.asList(
createBook(1L, "Book 1", "Author 1", "1234567890"),
createBook(2L, "Book 2", "Author 2", "0987654321")
);
when(bookRepository.findAll()).thenReturn(books);
// Act
List<Book> foundBooks = bookService.findAll();
// Assert
assertEquals(2, foundBooks.size());
verify(bookRepository).findAll();
}
@Test
@DisplayName("Should find book by ID")
void shouldFindBookById() {
// Arrange
Long id = 1L;
Book book = createBook(id, "Book 1", "Author 1", "1234567890");
when(bookRepository.findById(id)).thenReturn(Optional.of(book));
// Act
Book foundBook = bookService.findById(id);
// Assert
assertNotNull(foundBook);
assertEquals(id, foundBook.getId());
verify(bookRepository).findById(id);
}
@Test
@DisplayName("Should throw exception when book not found")
void shouldThrowExceptionWhenBookNotFound() {
// Arrange
Long id = 1L;
when(bookRepository.findById(id)).thenReturn(Optional.empty());
// Act & Assert
assertThrows(BookNotFoundException.class, () -> bookService.findById(id));
verify(bookRepository).findById(id);
}
@Test
@DisplayName("Should save book")
void shouldSaveBook() {
// Arrange
Book book = createBook(null, "New Book", "New Author", "1234567890");
Book savedBook = createBook(1L, "New Book", "New Author", "1234567890");
when(bookRepository.save(book)).thenReturn(savedBook);
// Act
Book result = bookService.save(book);
// Assert
assertNotNull(result);
assertEquals(savedBook.getId(), result.getId());
verify(bookRepository).save(book);
}
@Test
@DisplayName("Should update book")
void shouldUpdateBook() {
// Arrange
Long id = 1L;
Book existingBook = createBook(id, "Old Title", "Old Author", "1234567890");
Book updatedBookData = createBook(null, "New Title", "New Author", "1234567890");
Book savedBook = createBook(id, "New Title", "New Author", "1234567890");
when(bookRepository.findById(id)).thenReturn(Optional.of(existingBook));
when(bookRepository.save(any(Book.class))).thenReturn(savedBook);
// Act
Book result = bookService.update(id, updatedBookData);
// Assert
assertNotNull(result);
assertEquals("New Title", result.getTitle());
assertEquals("New Author", result.getAuthor());
verify(bookRepository).findById(id);
verify(bookRepository).save(any(Book.class));
}
@Test
@DisplayName("Should throw exception when updating non-existent book")
void shouldThrowExceptionWhenUpdatingNonExistentBook() {
// Arrange
Long id = 1L;
Book updatedBookData = createBook(null, "New Title", "New Author", "1234567890");
when(bookRepository.findById(id)).thenReturn(Optional.empty());
// Act & Assert
assertThrows(BookNotFoundException.class, () -> bookService.update(id, updatedBookData));
verify(bookRepository).findById(id);
verify(bookRepository, never()).save(any(Book.class));
}
@Test
@DisplayName("Should delete book by ID")
void shouldDeleteBookById() {
// Arrange
Long id = 1L;
Book book = createBook(id, "Book 1", "Author 1", "1234567890");
when(bookRepository.findById(id)).thenReturn(Optional.of(book));
doNothing().when(bookRepository).deleteById(id);
// Act
bookService.deleteById(id);
// Assert
verify(bookRepository).findById(id);
verify(bookRepository).deleteById(id);
}
@Test
@DisplayName("Should throw exception when deleting non-existent book")
void shouldThrowExceptionWhenDeletingNonExistentBook() {
// Arrange
Long id = 1L;
when(bookRepository.findById(id)).thenReturn(Optional.empty());
// Act & Assert
assertThrows(BookNotFoundException.class, () -> bookService.deleteById(id));
verify(bookRepository).findById(id);
verify(bookRepository, never()).deleteById(id);
}
@Test
@DisplayName("Should find books by author")
void shouldFindBooksByAuthor() {
// Arrange
String author = "Test Author";
List<Book> books = Arrays.asList(
createBook(1L, "Book 1", author, "1234567890"),
createBook(2L, "Book 2", author, "0987654321")
);
when(bookRepository.findByAuthorContainingIgnoreCase(author)).thenReturn(books);
// Act
List<Book> foundBooks = bookService.findByAuthor(author);
// Assert
assertEquals(2, foundBooks.size());
verify(bookRepository).findByAuthorContainingIgnoreCase(author);
}
private Book createBook(Long id, String title, String author, String isbn) {
Book book = new Book();
book.setId(id);
book.setTitle(title);
book.setAuthor(author);
book.setIsbn(isbn);
book.setDescription("Test Description");
return book;
}
}
Step 4: Write Controller Tests
Create a test class for the BookController
in src/test/java/com/example/bookcatalog/controller/BookControllerTest.java
:
package com.example.bookcatalog.controller;
import com.example.bookcatalog.exception.BookNotFoundException;
import com.example.bookcatalog.model.Book;
import com.example.bookcatalog.service.BookService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import java.util.Arrays;
import java.util.List;
import static org.hamcrest.Matchers.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@WebMvcTest(BookController.class)
class BookControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private BookService bookService;
@Autowired
private ObjectMapper objectMapper;
@Test
@DisplayName("Should get all books")
void shouldGetAllBooks() throws Exception {
// Arrange
List<Book> books = Arrays.asList(
createBook(1L, "Book 1", "Author 1", "1234567890"),
createBook(2L, "Book 2", "Author 2", "0987654321")
);
when(bookService.findAll()).thenReturn(books);
// Act & Assert
mockMvc.perform(get("/api/books"))
.andExpect(status().isOk())
.andExpect(jsonPath("$", hasSize(2)))
.andExpect(jsonPath("$[0].id").value(1))
.andExpect(jsonPath("$[0].title").value("Book 1"))
.andExpect(jsonPath("$[1].id").value(2))
.andExpect(jsonPath("$[1].title").value("Book 2"));
verify(bookService).findAll();
}
@Test
@DisplayName("Should get book by ID")
void shouldGetBookById() throws Exception {
// Arrange
Long id = 1L;
Book book = createBook(id, "Test Book", "Test Author", "1234567890");
when(bookService.findById(id)).thenReturn(book);
// Act & Assert
mockMvc.perform(get("/api/books/{id}", id))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(id))
.andExpect(jsonPath("$.title").value("Test Book"))
.andExpect(jsonPath("$.author").value("Test Author"))
.andExpect(jsonPath("$.isbn").value("1234567890"));
verify(bookService).findById(id);
}
@Test
@DisplayName("Should return 404 when book not found")
void shouldReturn404WhenBookNotFound() throws Exception {
// Arrange
Long id = 1L;
when(bookService.findById(id)).thenThrow(new BookNotFoundException(id));
// Act & Assert
mockMvc.perform(get("/api/books/{id}", id))
.andExpect(status().isNotFound());
verify(bookService).findById(id);
}
@Test
@DisplayName("Should create a new book")
void shouldCreateBook() throws Exception {
// Arrange
Book bookToCreate = createBook(null, "New Book", "New Author", "1234567890");
Book createdBook = createBook(1L, "New Book", "New Author", "1234567890");
when(bookService.save(any(Book.class))).thenReturn(createdBook);
// Act & Assert
mockMvc.perform(post("/api/books")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(bookToCreate)))
.andExpect(status().isCreated())
.andExpect(header().string("Location", containsString("/api/books/1")));
verify(bookService).save(any(Book.class));
}
@Test
@DisplayName("Should update a book")
void shouldUpdateBook() throws Exception {
// Arrange
Long id = 1L;
Book bookToUpdate = createBook(null, "Updated Book", "Updated Author", "1234567890");
Book updatedBook = createBook(id, "Updated Book", "Updated Author", "1234567890");
when(bookService.update(eq(id), any(Book.class))).thenReturn(updatedBook);
// Act & Assert
mockMvc.perform(put("/api/books/{id}", id)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(bookToUpdate)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(id))
.andExpect(jsonPath("$.title").value("Updated Book"))
.andExpect(jsonPath("$.author").value("Updated Author"));
verify(bookService).update(eq(id), any(Book.class));
}
@Test
@DisplayName("Should return 404 when updating non-existent book")
void shouldReturn404WhenUpdatingNonExistentBook() throws Exception {
// Arrange
Long id = 1L;
Book bookToUpdate = createBook(null, "Updated Book", "Updated Author", "1234567890");
when(bookService.update(eq(id), any(Book.class))).thenThrow(new BookNotFoundException(id));
// Act & Assert
mockMvc.perform(put("/api/books/{id}", id)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(bookToUpdate)))
.andExpect(status().isNotFound());
verify(bookService).update(eq(id), any(Book.class));
}
@Test
@DisplayName("Should delete a book")
void shouldDeleteBook() throws Exception {
// Arrange
Long id = 1L;
doNothing().when(bookService).deleteById(id);
// Act & Assert
mockMvc.perform(delete("/api/books/{id}", id))
.andExpect(status().isNoContent());
verify(bookService).deleteById(id);
}
@Test
@DisplayName("Should return 404 when deleting non-existent book")
void shouldReturn404WhenDeletingNonExistentBook() throws Exception {
// Arrange
Long id = 1L;
doThrow(new BookNotFoundException(id)).when(bookService).deleteById(id);
// Act & Assert
mockMvc.perform(delete("/api/books/{id}", id))
.andExpect(status().isNotFound());
verify(bookService).deleteById(id);
}
@Test
@DisplayName("Should search books by author")
void shouldSearchBooksByAuthor() throws Exception {
// Arrange
String author = "Test Author";
List<Book> books = Arrays.asList(
createBook(1L, "Book 1", author, "1234567890"),
createBook(2L, "Book 2", author, "0987654321")
);
when(bookService.findByAuthor(author)).thenReturn(books);
// Act & Assert
mockMvc.perform(get("/api/books/search")
.param("author", author))
.andExpect(status().isOk())
.andExpect(jsonPath("$", hasSize(2)))
.andExpect(jsonPath("$[0].author").value(author))
.andExpect(jsonPath("$[1].author").value(author));
verify(bookService).findByAuthor(author);
}
@Test
@DisplayName("Should validate book when creating")
void shouldValidateBookWhenCreating() throws Exception {
// Arrange
Book invalidBook = new Book();
invalidBook.setAuthor(""); // Empty author should fail validation
// Act & Assert
mockMvc.perform(post("/api/books")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(invalidBook)))
.andExpect(status().isBadRequest());
verify(bookService, never()).save(any(Book.class));
}
private Book createBook(Long id, String title, String author, String isbn) {
Book book = new Book();
book.setId(id);
book.setTitle(title);
book.setAuthor(author);
book.setIsbn(isbn);
book.setDescription("Test Description");
return book;
}
}
Step 5: Write Integration Tests
Create integration tests for the Book Catalog application in src/test/java/com/example/bookcatalog/BookCatalogApplicationTests.java
:
package com.example.bookcatalog;
import com.example.bookcatalog.model.Book;
import com.example.bookcatalog.repository.BookRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
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.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import static org.hamcrest.Matchers.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class BookCatalogApplicationTests {
@Autowired
private MockMvc mockMvc;
@Autowired
private BookRepository bookRepository;
@Autowired
private ObjectMapper objectMapper;
@AfterEach
void cleanup() {
bookRepository.deleteAll();
}
@Test
@DisplayName("Should load application context")
void contextLoads() {
// This test will fail if the application context cannot be loaded
}
@Test
@DisplayName("Should create, retrieve, update, and delete a book")
void shouldPerformCrudOperations() throws Exception {
// Step 1: Create a new book
Book bookToCreate = new Book();
bookToCreate.setTitle("Integration Test Book");
bookToCreate.setAuthor("Test Author");
bookToCreate.setIsbn("9876543210");
bookToCreate.setDescription("Test Description");
MvcResult createResult = mockMvc.perform(post("/api/books")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(bookToCreate)))
.andExpect(status().isCreated())
.andReturn();
String location = createResult.getResponse().getHeader("Location");
assertNotNull(location);
// Extract the book ID from the location URI
String bookIdStr = location.substring(location.lastIndexOf('/') + 1);
Long bookId = Long.parseLong(bookIdStr);
// Step 2: Retrieve the created book
mockMvc.perform(get("/api/books/{id}", bookId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(bookId))
.andExpect(jsonPath("$.title").value("Integration Test Book"))
.andExpect(jsonPath("$.author").value("Test Author"))
.andExpect(jsonPath("$.isbn").value("9876543210"));
// Step 3: Update the book
Book updatedBook = new Book();
updatedBook.setTitle("Updated Book Title");
updatedBook.setAuthor("Updated Author");
updatedBook.setIsbn("9876543210");
updatedBook.setDescription("Updated Description");
mockMvc.perform(put("/api/books/{id}", bookId)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(updatedBook)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(bookId))
.andExpect(jsonPath("$.title").value("Updated Book Title"))
.andExpect(jsonPath("$.author").value("Updated Author"))
.andExpect(jsonPath("$.description").value("Updated Description"));
// Step 4: Delete the book
mockMvc.perform(delete("/api/books/{id}", bookId))
.andExpect(status().isNoContent());
// Verify the book was deleted
mockMvc.perform(get("/api/books/{id}", bookId))
.andExpect(status().isNotFound());
}
@Test
@DisplayName("Should search books by author")
void shouldSearchBooksByAuthor() throws Exception {
// Arrange
String author = "Search Test Author";
Book book1 = new Book();
book1.setTitle("Book 1");
book1.setAuthor(author);
book1.setIsbn("1111111111");
book1.setDescription("Description 1");
bookRepository.save(book1);
Book book2 = new Book();
book2.setTitle("Book 2");
book2.setAuthor(author);
book2.setIsbn("2222222222");
book2.setDescription("Description 2");
bookRepository.save(book2);
Book book3 = new Book();
book3.setTitle("Book 3");
book3.setAuthor("Different Author");
book3.setIsbn("3333333333");
book3.setDescription("Description 3");
bookRepository.save(book3);
// Act & Assert
mockMvc.perform(get("/api/books/search")
.param("author", author))
.andExpect(status().isOk())
.andExpect(jsonPath("$", hasSize(2)))
.andExpect(jsonPath("$[0].author").value(author))
.andExpect(jsonPath("$[1].author").value(author));
}
@Test
@DisplayName("Should handle validation errors")
void shouldHandleValidationErrors() throws Exception {
// Arrange
Book invalidBook = new Book();
// Missing required fields
// Act & Assert
mockMvc.perform(post("/api/books")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(invalidBook)))
.andExpect(status().isBadRequest());
}
}
Step 6: Test Exception Handling
Create a test class for the exception handling in src/test/java/com/example/bookcatalog/exception/GlobalExceptionHandlerTest.java
:
package com.example.bookcatalog.exception;
import com.example.bookcatalog.controller.BookController;
import com.example.bookcatalog.service.BookService;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.web.servlet.MockMvc;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(BookController.class)
class GlobalExceptionHandlerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private BookService bookService;
@Test
@DisplayName("Should handle BookNotFoundException")
void shouldHandleBookNotFoundException() throws Exception {
// Arrange
Long bookId = 1L;
when(bookService.findById(bookId)).thenThrow(new BookNotFoundException(bookId));
// Act & Assert
mockMvc.perform(get("/api/books/{id}", bookId))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.status").value(404))
.andExpect(jsonPath("$.message").value(containsString("Book not found with id: " + bookId)))
.andExpect(jsonPath("$.timestamp").exists());
}
@Test
@DisplayName("Should handle generic exceptions")
void shouldHandleGenericExceptions() throws Exception {
// Arrange
Long bookId = 1L;
when(bookService.findById(bookId)).thenThrow(new RuntimeException("Unexpected error"));
// Act & Assert
mockMvc.perform(get("/api/books/{id}", bookId))
.andExpect(status().isInternalServerError())
.andExpect(jsonPath("$.status").value(500))
.andExpect(jsonPath("$.message").value("An unexpected error occurred"))
.andExpect(jsonPath("$.timestamp").exists());
}
}
Step 7: Create a Test Utility Class
Create a test utility class to avoid code duplication in src/test/java/com/example/bookcatalog/util/TestUtils.java
:
package com.example.bookcatalog.util;
import com.example.bookcatalog.model.Book;
public class TestUtils {
public static Book createTestBook(Long id, String title, String author, String isbn) {
Book book = new Book();
book.setId(id);
book.setTitle(title);
book.setAuthor(author);
book.setIsbn(isbn);
book.setDescription("Test Description");
return book;
}
}
Now, update your tests to use this utility class to reduce code duplication.
Step 8: Add Custom Metrics Tests
Let’s add tests for the custom metrics we added in Module 10:
package com.example.bookcatalog.metrics;
import com.example.bookcatalog.model.Book;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class BookMetricsTest {
private MeterRegistry meterRegistry;
private BookMetrics bookMetrics;
@BeforeEach
void setUp() {
meterRegistry = new SimpleMeterRegistry();
bookMetrics = new BookMetrics(meterRegistry);
}
@Test
@DisplayName("Should record book view")
void shouldRecordBookView() {
// Arrange
Book book = new Book();
book.setId(1L);
book.setTitle("Test Book");
// Act
bookMetrics.recordBookView(book);
// Assert
Counter counter = meterRegistry.find("book.views").counter();
assertEquals(1.0, counter.count());
}
@Test
@DisplayName("Should record book creation")
void shouldRecordBookCreation() {
// Act
bookMetrics.recordBookCreation();
// Assert
Counter counter = meterRegistry.find("book.creations").counter();
assertEquals(1.0, counter.count());
}
@Test
@DisplayName("Should record book update")
void shouldRecordBookUpdate() {
// Act
bookMetrics.recordBookUpdate();
// Assert
Counter counter = meterRegistry.find("book.updates").counter();
assertEquals(1.0, counter.count());
}
@Test
@DisplayName("Should record book deletion")
void shouldRecordBookDeletion() {
// Act
bookMetrics.recordBookDeletion();
// Assert
Counter counter = meterRegistry.find("book.deletions").counter();
assertEquals(1.0, counter.count());
}
}
Step 9: Configure JaCoCo for Code Coverage
Add JaCoCo to your pom.xml
to generate code coverage reports:
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.8</version>
<executions>
<execution>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<phase>test</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
</executions>
</plugin>
Step 10: Implementing Test-Driven Development
Let’s practice TDD by adding a new feature to our Book Catalog application: the ability to rate books. We’ll start by writing the tests first.
- First, let’s add the rating field to our Book model:
package com.example.bookcatalog.model;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
@Entity
public class Book {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotBlank(message = "Title is required")
@Size(max = 255, message = "Title cannot exceed 255 characters")
private String title;
@NotBlank(message = "Author is required")
@Size(max = 255, message = "Author cannot exceed 255 characters")
private String author;
@NotBlank(message = "ISBN is required")
@Size(max = 13, message = "ISBN cannot exceed 13 characters")
private String isbn;
@Size(max = 1000, message = "Description cannot exceed 1000 characters")
private String description;
@Min(value = 1, message = "Rating must be between 1 and 5")
@Max(value = 5, message = "Rating must be between 1 and 5")
private Integer rating;
// Getters and setters
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getAuthor() {
return author;
}
public void setAuthor(String author) {
this.author = author;
}
public String getIsbn() {
return isbn;
}
public void setIsbn(String isbn) {
this.isbn = isbn;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public Integer getRating() {
return rating;
}
public void setRating(Integer rating) {
this.rating = rating;
}
}
- Now, let’s create a test for the repository to find books by rating:
@Test
@DisplayName("Should find books by rating")
void shouldFindBooksByRating() {
// Arrange
Integer rating = 5;
Book book1 = new Book();
book1.setTitle("Highly Rated Book 1");
book1.setAuthor("Author 1");
book1.setIsbn("1111111111");
book1.setRating(rating);
bookRepository.save(book1);
Book book2 = new Book();
book2.setTitle("Highly Rated Book 2");
book2.setAuthor("Author 2");
book2.setIsbn("2222222222");
book2.setRating(rating);
bookRepository.save(book2);
Book book3 = new Book();
book3.setTitle("Average Book");
book3.setAuthor("Author 3");
book3.setIsbn("3333333333");
book3.setRating(3);
bookRepository.save(book3);
// Act
List<Book> books = bookRepository.findByRating(rating);
// Assert
assertEquals(2, books.size());
books.forEach(book -> assertEquals(rating, book.getRating()));
}
- Update the BookRepository interface:
package com.example.bookcatalog.repository;
import com.example.bookcatalog.model.Book;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public interface BookRepository extends JpaRepository<Book, Long> {
List<Book> findByAuthorContainingIgnoreCase(String author);
Optional<Book> findByIsbn(String isbn);
List<Book> findByRating(Integer rating);
}
- Now, let’s write a test for the service layer:
@Test
@DisplayName("Should find books by rating")
void shouldFindBooksByRating() {
// Arrange
Integer rating = 5;
List<Book> books = Arrays.asList(
createBook(1L, "Book 1", "Author 1", "1234567890", rating),
createBook(2L, "Book 2", "Author 2", "0987654321", rating)
);
when(bookRepository.findByRating(rating)).thenReturn(books);
// Act
List<Book> foundBooks = bookService.findByRating(rating);
// Assert
assertEquals(2, foundBooks.size());
verify(bookRepository).findByRating(rating);
}
- Update the BookService interface and implementation:
package com.example.bookcatalog.service;
import com.example.bookcatalog.model.Book;
import java.util.List;
public interface BookService {
List<Book> findAll();
Book findById(Long id);
Book save(Book book);
Book update(Long id, Book book);
void deleteById(Long id);
List<Book> findByAuthor(String author);
List<Book> findByRating(Integer rating);
}
package com.example.bookcatalog.service;
import com.example.bookcatalog.exception.BookNotFoundException;
import com.example.bookcatalog.model.Book;
import com.example.bookcatalog.repository.BookRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class BookServiceImpl implements BookService {
private final BookRepository bookRepository;
@Autowired
public BookServiceImpl(BookRepository bookRepository) {
this.bookRepository = bookRepository;
}
@Override
public List<Book> findAll() {
return bookRepository.findAll();
}
@Override
public Book findById(Long id) {
return bookRepository.findById(id)
.orElseThrow(() -> new BookNotFoundException(id));
}
@Override
public Book save(Book book) {
return bookRepository.save(book);
}
@Override
public Book update(Long id, Book book) {
Book existingBook = findById(id);
existingBook.setTitle(book.getTitle());
existingBook.setAuthor(book.getAuthor());
existingBook.setIsbn(book.getIsbn());
existingBook.setDescription(book.getDescription());
existingBook.setRating(book.getRating());
return bookRepository.save(existingBook);
}
@Override
public void deleteById(Long id) {
findById(id); // Verify book exists
bookRepository.deleteById(id);
}
@Override
public List<Book> findByAuthor(String author) {
return bookRepository.findByAuthorContainingIgnoreCase(author);
}
@Override
public List<Book> findByRating(Integer rating) {
return bookRepository.findByRating(rating);
}
}
- Finally, let’s write a test for the controller:
@Test
@DisplayName("Should search books by rating")
void shouldSearchBooksByRating() throws Exception {
// Arrange
Integer rating = 5;
List<Book> books = Arrays.asList(
createBook(1L, "Book 1", "Author 1", "1234567890", rating),
createBook(2L, "Book 2", "Author 2", "0987654321", rating)
);
when(bookService.findByRating(rating)).thenReturn(books);
// Act & Assert
mockMvc.perform(get("/api/books/search/rating/{rating}", rating))
.andExpect(status().isOk())
.andExpect(jsonPath("$", hasSize(2)))
.andExpect(jsonPath("$[0].rating").value(rating))
.andExpect(jsonPath("$[1].rating").value(rating));
verify(bookService).findByRating(rating);
}
- Update the BookController:
@GetMapping("/search/rating/{rating}")
public ResponseEntity<List<Book>> searchBooksByRating(@PathVariable Integer rating) {
List<Book> books = bookService.findByRating(rating);
return ResponseEntity.ok(books);
}
- Finally, let’s add an integration test:
@Test
@DisplayName("Should search books by rating")
void shouldSearchBooksByRating() throws Exception {
// Arrange
Integer rating = 5;
Book book1 = new Book();
book1.setTitle("Highly Rated Book 1");
book1.setAuthor("Author 1");
book1.setIsbn("1111111111");
book1.setRating(rating);
bookRepository.save(book1);
Book book2 = new Book();
book2.setTitle("Highly Rated Book 2");
book2.setAuthor("Author 2");
book2.setIsbn("2222222222");
book2.setRating(rating);
bookRepository.save(book2);
Book book3 = new Book();
book3.setTitle("Average Book");
book3.setAuthor("Author 3");
book3.setIsbn("3333333333");
book3.setRating(3);
bookRepository.save(book3);
// Act & Assert
mockMvc.perform(get("/api/books/search/rating/{rating}", rating))
.andExpect(status().isOk())
.andExpect(jsonPath("$", hasSize(2)))
.andExpect(jsonPath("$[0].rating").value(rating))
.andExpect(jsonPath("$[1].rating").value(rating));
}
Step 11: Run All Tests
Now, run your tests to make sure everything is working as expected:
mvn clean test
To generate the JaCoCo coverage report:
mvn clean verify
You can find the coverage report in target/site/jacoco/index.html
.
Step 12: Analyze Test Coverage
Open the JaCoCo report and analyze your test coverage. Look for areas with low coverage and add more tests as needed. Aim for at least 80% code coverage for each class.
Step 13: Set Up Continuous Integration (CI)
Create a GitHub Actions workflow in .github/workflows/ci.yml
to run your tests automatically on every push:
name: Java CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up JDK 17
uses: actions/setup-java@v3
with:
java-version: "17"
distribution: "temurin"
cache: maven
- name: Build with Maven
run: mvn clean verify
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
file: ./target/site/jacoco/jacoco.xml
fail_ci_if_error: true
Summary and Conclusion
In this lab, you’ve implemented comprehensive testing for the Book Catalog application, including:
- Unit testing the repository layer using @DataJpaTest
- Unit testing the service layer using Mockito
- Unit testing the controller layer using @WebMvcTest and MockMvc
- Integration testing the entire application using @SpringBootTest
- Testing exception handling
- Creating test utilities to avoid code duplication
- Testing custom metrics
- Setting up JaCoCo for code coverage reports
- Practiced Test-Driven Development (TDD) by adding a new feature
- Setting up Continuous Integration (CI) with GitHub Actions
Testing is a critical part of the software development lifecycle. By implementing comprehensive tests, you can ensure that your application works as expected and continues to work as you make changes. Spring Boot provides excellent support for testing, making it easy to write tests for all layers of your application.
Key principles to remember:
- Write tests at all levels of your application (unit, integration, end-to-end)
- Use mocks to isolate the code under test
- Test both happy paths and error scenarios
- Aim for high code coverage, but focus on testing business logic thoroughly
- Use TDD when adding new features
- Set up CI to run tests automatically
- Analyze and improve test coverage continuously
By following these principles, you’ll create more reliable and maintainable Spring Boot applications.
By Wahid Hamdi