Lab: Writing Comprehensive Test Cases
Lab Overview
In this lab, you’ll create a simple Spring Boot application with a layered architecture and write comprehensive test cases for each layer. You’ll learn how to use JUnit 5, Mockito, and MockMvc to test your application thoroughly.
Lab Objectives
- Understand the importance of testing in software development
- Learn how to write unit tests for Spring Boot applications
- Implement tests for controllers using MockMvc
- Create tests for service layers using Mockito
- Write integration tests for repositories
- Apply test coverage analysis to improve test quality
Prerequisites
- Basic knowledge of Spring Boot
- Familiarity with Java programming
- IDE (IntelliJ IDEA, Eclipse, etc.)
- Maven or Gradle
Part 1: Project Setup
Step 1: Create a new Spring Boot project
Use Spring Initializer (https://start.spring.io/) with the following options:
- Project: Maven
- Language: Java
- Spring Boot: 3.2.x (or latest stable)
- Dependencies:
- Spring Web
- Spring Data JPA
- H2 Database
- Lombok (optional, but recommended)
Step 2: Import the project into your IDE
Step 3: Create a simple Book Management System
Create these packages:
com.example.demo.model
com.example.demo.repository
com.example.demo.service
com.example.demo.controller
Step 4: Create the Book entity
package com.example.demo.model;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Entity
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Book {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private String author;
private String isbn;
private double price;
// Constructor without id for easier testing
public Book(String title, String author, String isbn, double price) {
this.title = title;
this.author = author;
this.isbn = isbn;
this.price = price;
}
}
Step 5: Create the Book repository
package com.example.demo.repository;
import com.example.demo.model.Book;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface BookRepository extends JpaRepository<Book, Long> {
List<Book> findByAuthor(String author);
Book findByIsbn(String isbn);
}
Step 6: Create the Book service
package com.example.demo.service;
import com.example.demo.model.Book;
import com.example.demo.repository.BookRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
@Service
public class BookService {
private final BookRepository bookRepository;
@Autowired
public BookService(BookRepository bookRepository) {
this.bookRepository = bookRepository;
}
public List<Book> getAllBooks() {
return bookRepository.findAll();
}
public Optional<Book> getBookById(Long id) {
return bookRepository.findById(id);
}
public Book createBook(Book book) {
return bookRepository.save(book);
}
public Book updateBook(Long id, Book bookDetails) {
Optional<Book> book = bookRepository.findById(id);
if (book.isPresent()) {
Book existingBook = book.get();
existingBook.setTitle(bookDetails.getTitle());
existingBook.setAuthor(bookDetails.getAuthor());
existingBook.setIsbn(bookDetails.getIsbn());
existingBook.setPrice(bookDetails.getPrice());
return bookRepository.save(existingBook);
}
return null; // In a real application, you might throw an exception
}
public boolean deleteBook(Long id) {
if (bookRepository.existsById(id)) {
bookRepository.deleteById(id);
return true;
}
return false;
}
public List<Book> getBooksByAuthor(String author) {
return bookRepository.findByAuthor(author);
}
public Book getBookByIsbn(String isbn) {
return bookRepository.findByIsbn(isbn);
}
}
Step 7: Create the Book controller
package com.example.demo.controller;
import com.example.demo.model.Book;
import com.example.demo.service.BookService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Optional;
@RestController
@RequestMapping("/api/books")
public class BookController {
private final BookService bookService;
@Autowired
public BookController(BookService bookService) {
this.bookService = bookService;
}
@GetMapping
public ResponseEntity<List<Book>> getAllBooks() {
return ResponseEntity.ok(bookService.getAllBooks());
}
@GetMapping("/{id}")
public ResponseEntity<Book> getBookById(@PathVariable Long id) {
Optional<Book> book = bookService.getBookById(id);
return book.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
}
@PostMapping
public ResponseEntity<Book> createBook(@RequestBody Book book) {
Book createdBook = bookService.createBook(book);
return ResponseEntity.status(HttpStatus.CREATED).body(createdBook);
}
@PutMapping("/{id}")
public ResponseEntity<Book> updateBook(@PathVariable Long id, @RequestBody Book book) {
Book updatedBook = bookService.updateBook(id, book);
if (updatedBook != null) {
return ResponseEntity.ok(updatedBook);
}
return ResponseEntity.notFound().build();
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteBook(@PathVariable Long id) {
boolean deleted = bookService.deleteBook(id);
if (deleted) {
return ResponseEntity.noContent().build();
}
return ResponseEntity.notFound().build();
}
@GetMapping("/author/{author}")
public ResponseEntity<List<Book>> getBooksByAuthor(@PathVariable String author) {
List<Book> books = bookService.getBooksByAuthor(author);
if (books.isEmpty()) {
return ResponseEntity.noContent().build();
}
return ResponseEntity.ok(books);
}
@GetMapping("/isbn/{isbn}")
public ResponseEntity<Book> getBookByIsbn(@PathVariable String isbn) {
Book book = bookService.getBookByIsbn(isbn);
if (book != null) {
return ResponseEntity.ok(book);
}
return ResponseEntity.notFound().build();
}
}
Step 8: Configure application.properties for testing
Create a new file at src/test/resources/application.properties
with the following content:
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=true
Part 2: Writing Repository Tests
Step 1: Creating a Repository Test
Create a test class for the BookRepository:
package com.example.demo.repository;
import com.example.demo.model.Book;
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.boot.test.autoconfigure.orm.jpa.TestEntityManager;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest
public class BookRepositoryTest {
@Autowired
private TestEntityManager entityManager;
@Autowired
private BookRepository bookRepository;
@Test
public void whenFindByAuthor_thenReturnBooks() {
// given
Book book1 = new Book("Spring Boot Testing", "John Doe", "1234567890", 29.99);
Book book2 = new Book("Advanced Spring Boot", "John Doe", "0987654321", 39.99);
Book book3 = new Book("Java Fundamentals", "Jane Smith", "5432167890", 19.99);
entityManager.persist(book1);
entityManager.persist(book2);
entityManager.persist(book3);
entityManager.flush();
// when
List<Book> foundBooks = bookRepository.findByAuthor("John Doe");
// then
assertThat(foundBooks).hasSize(2);
assertThat(foundBooks).extracting(Book::getTitle)
.containsExactlyInAnyOrder("Spring Boot Testing", "Advanced Spring Boot");
}
@Test
public void whenFindByIsbn_thenReturnBook() {
// given
Book savedBook = new Book("Spring Boot Testing", "John Doe", "1234567890", 29.99);
entityManager.persist(savedBook);
entityManager.flush();
// when
Book foundBook = bookRepository.findByIsbn("1234567890");
// then
assertThat(foundBook).isNotNull();
assertThat(foundBook.getTitle()).isEqualTo("Spring Boot Testing");
assertThat(foundBook.getAuthor()).isEqualTo("John Doe");
}
@Test
public void whenFindByNonExistentIsbn_thenReturnNull() {
// when
Book foundBook = bookRepository.findByIsbn("nonexistentISBN");
// then
assertThat(foundBook).isNull();
}
}
Key Concepts in Repository Testing:
@DataJpaTest
: Configures the test to focus on JPA componentsTestEntityManager
: Provides methods to persist test data- Testing custom finder methods
- Verifying expected results with AssertJ assertions
Part 3: Writing Service Tests
Step 1: Creating a Service Test
Create a test class for the BookService:
package com.example.demo.service;
import com.example.demo.model.Book;
import com.example.demo.repository.BookRepository;
import org.junit.jupiter.api.BeforeEach;
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.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
public class BookServiceTest {
@Mock
private BookRepository bookRepository;
@InjectMocks
private BookService bookService;
private Book book1;
private Book book2;
@BeforeEach
public void setup() {
book1 = new Book(1L, "Spring Boot Testing", "John Doe", "1234567890", 29.99);
book2 = new Book(2L, "Advanced Spring Boot", "John Doe", "0987654321", 39.99);
}
@Test
public void whenGetAllBooks_thenReturnBookList() {
// given
when(bookRepository.findAll()).thenReturn(Arrays.asList(book1, book2));
// when
List<Book> books = bookService.getAllBooks();
// then
assertThat(books).hasSize(2);
assertThat(books).contains(book1, book2);
verify(bookRepository, times(1)).findAll();
}
@Test
public void whenGetBookById_thenReturnBook() {
// given
when(bookRepository.findById(1L)).thenReturn(Optional.of(book1));
// when
Optional<Book> foundBook = bookService.getBookById(1L);
// then
assertThat(foundBook).isPresent();
assertThat(foundBook.get()).isEqualTo(book1);
verify(bookRepository, times(1)).findById(1L);
}
@Test
public void whenCreateBook_thenReturnSavedBook() {
// given
Book bookToSave = new Book("New Book", "New Author", "1111111111", 19.99);
when(bookRepository.save(any(Book.class))).thenReturn(
new Book(3L, "New Book", "New Author", "1111111111", 19.99));
// when
Book savedBook = bookService.createBook(bookToSave);
// then
assertThat(savedBook.getId()).isEqualTo(3L);
assertThat(savedBook.getTitle()).isEqualTo("New Book");
verify(bookRepository, times(1)).save(bookToSave);
}
@Test
public void whenUpdateBook_thenReturnUpdatedBook() {
// given
Book bookUpdates = new Book("Updated Title", "Updated Author", "1234567890", 49.99);
when(bookRepository.findById(1L)).thenReturn(Optional.of(book1));
when(bookRepository.save(any(Book.class))).thenAnswer(invocation -> invocation.getArgument(0));
// when
Book updatedBook = bookService.updateBook(1L, bookUpdates);
// then
assertThat(updatedBook).isNotNull();
assertThat(updatedBook.getTitle()).isEqualTo("Updated Title");
assertThat(updatedBook.getAuthor()).isEqualTo("Updated Author");
assertThat(updatedBook.getPrice()).isEqualTo(49.99);
verify(bookRepository, times(1)).findById(1L);
verify(bookRepository, times(1)).save(any(Book.class));
}
@Test
public void whenUpdateNonExistentBook_thenReturnNull() {
// given
Book bookUpdates = new Book("Updated Title", "Updated Author", "1234567890", 49.99);
when(bookRepository.findById(99L)).thenReturn(Optional.empty());
// when
Book updatedBook = bookService.updateBook(99L, bookUpdates);
// then
assertThat(updatedBook).isNull();
verify(bookRepository, times(1)).findById(99L);
verify(bookRepository, never()).save(any(Book.class));
}
@Test
public void whenDeleteExistingBook_thenReturnTrue() {
// given
when(bookRepository.existsById(1L)).thenReturn(true);
doNothing().when(bookRepository).deleteById(1L);
// when
boolean result = bookService.deleteBook(1L);
// then
assertThat(result).isTrue();
verify(bookRepository, times(1)).existsById(1L);
verify(bookRepository, times(1)).deleteById(1L);
}
@Test
public void whenDeleteNonExistentBook_thenReturnFalse() {
// given
when(bookRepository.existsById(99L)).thenReturn(false);
// when
boolean result = bookService.deleteBook(99L);
// then
assertThat(result).isFalse();
verify(bookRepository, times(1)).existsById(99L);
verify(bookRepository, never()).deleteById(any());
}
}
Key Concepts in Service Testing:
@ExtendWith(MockitoExtension.class)
: Enables Mockito annotations@Mock
: Creates mock objects for dependencies@InjectMocks
: Injects mocks into the service being tested- Using Mockito to mock repository responses
- Testing service methods that manipulate data
- Verifying that repository methods were called correctly
Part 4: Writing Controller Tests
Step 1: Creating a Controller Test
Create a test class for the BookController:
package com.example.demo.controller;
import com.example.demo.model.Book;
import com.example.demo.service.BookService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
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 java.util.Optional;
import static org.hamcrest.Matchers.*;
import static org.mockito.ArgumentMatchers.any;
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)
public class BookControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private BookService bookService;
@Autowired
private ObjectMapper objectMapper;
private Book book1;
private Book book2;
@BeforeEach
public void setup() {
book1 = new Book(1L, "Spring Boot Testing", "John Doe", "1234567890", 29.99);
book2 = new Book(2L, "Advanced Spring Boot", "John Doe", "0987654321", 39.99);
}
@Test
public void getAllBooks_shouldReturnAllBooks() throws Exception {
List<Book> books = Arrays.asList(book1, book2);
when(bookService.getAllBooks()).thenReturn(books);
mockMvc.perform(get("/api/books")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$", hasSize(2)))
.andExpect(jsonPath("$[0].title", is("Spring Boot Testing")))
.andExpect(jsonPath("$[1].title", is("Advanced Spring Boot")));
verify(bookService, times(1)).getAllBooks();
}
@Test
public void getBookById_withValidId_shouldReturnBook() throws Exception {
when(bookService.getBookById(1L)).thenReturn(Optional.of(book1));
mockMvc.perform(get("/api/books/1")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id", is(1)))
.andExpect(jsonPath("$.title", is("Spring Boot Testing")))
.andExpect(jsonPath("$.author", is("John Doe")));
verify(bookService, times(1)).getBookById(1L);
}
@Test
public void getBookById_withInvalidId_shouldReturnNotFound() throws Exception {
when(bookService.getBookById(99L)).thenReturn(Optional.empty());
mockMvc.perform(get("/api/books/99")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isNotFound());
verify(bookService, times(1)).getBookById(99L);
}
@Test
public void createBook_shouldReturnCreatedBook() throws Exception {
Book bookToCreate = new Book("New Book", "New Author", "1111111111", 19.99);
Book createdBook = new Book(3L, "New Book", "New Author", "1111111111", 19.99);
when(bookService.createBook(any(Book.class))).thenReturn(createdBook);
mockMvc.perform(post("/api/books")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(bookToCreate)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id", is(3)))
.andExpect(jsonPath("$.title", is("New Book")))
.andExpect(jsonPath("$.author", is("New Author")));
verify(bookService, times(1)).createBook(any(Book.class));
}
@Test
public void updateBook_withValidId_shouldReturnUpdatedBook() throws Exception {
Book bookUpdates = new Book("Updated Title", "Updated Author", "1234567890", 49.99);
Book updatedBook = new Book(1L, "Updated Title", "Updated Author", "1234567890", 49.99);
when(bookService.updateBook(eq(1L), any(Book.class))).thenReturn(updatedBook);
mockMvc.perform(put("/api/books/1")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(bookUpdates)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id", is(1)))
.andExpect(jsonPath("$.title", is("Updated Title")))
.andExpect(jsonPath("$.author", is("Updated Author")));
verify(bookService, times(1)).updateBook(eq(1L), any(Book.class));
}
@Test
public void updateBook_withInvalidId_shouldReturnNotFound() throws Exception {
Book bookUpdates = new Book("Updated Title", "Updated Author", "1234567890", 49.99);
when(bookService.updateBook(eq(99L), any(Book.class))).thenReturn(null);
mockMvc.perform(put("/api/books/99")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(bookUpdates)))
.andExpect(status().isNotFound());
verify(bookService, times(1)).updateBook(eq(99L), any(Book.class));
}
@Test
public void deleteBook_withValidId_shouldReturnNoContent() throws Exception {
when(bookService.deleteBook(1L)).thenReturn(true);
mockMvc.perform(delete("/api/books/1")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isNoContent());
verify(bookService, times(1)).deleteBook(1L);
}
@Test
public void deleteBook_withInvalidId_shouldReturnNotFound() throws Exception {
when(bookService.deleteBook(99L)).thenReturn(false);
mockMvc.perform(delete("/api/books/99")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isNotFound());
verify(bookService, times(1)).deleteBook(99L);
}
}
Key Concepts in Controller Testing:
@WebMvcTest
: Configures the test for Spring MVC componentsMockMvc
: Simulates HTTP requests and responses@MockBean
: Creates and injects mock service into the application context- Testing different HTTP methods (GET, POST, PUT, DELETE)
- Verifying JSON response structure with JsonPath
- Testing different response status codes
Part 5: Integration Testing
Step 1: Creating an Integration Test
Create an integration test that tests the entire application flow:
package com.example.demo;
import com.example.demo.model.Book;
import com.example.demo.repository.BookRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
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.web.servlet.MockMvc;
import static org.hamcrest.Matchers.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@SpringBootTest
@AutoConfigureMockMvc
public class BookIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private BookRepository bookRepository;
@Autowired
private ObjectMapper objectMapper;
@BeforeEach
public void setup() {
// Clear the database before each test
bookRepository.deleteAll();
// Add test data
bookRepository.save(new Book("Spring Boot Testing", "John Doe", "1234567890", 29.99));
bookRepository.save(new Book("Advanced Spring Boot", "John Doe", "0987654321", 39.99));
}
@Test
public void testCreateReadUpdateDelete() throws Exception {
// Create a new book
Book newBook = new Book("New Book", "New Author", "1111111111", 19.99);
String createdBookJson = mockMvc.perform(post("/api/books")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(newBook)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.title", is("New Book")))
.andExpect(jsonPath("$.author", is("New Author")))
.andReturn()
.getResponse()
.getContentAsString();
// Extract the ID of the created book
Book createdBook = objectMapper.readValue(createdBookJson, Book.class);
Long bookId = createdBook.getId();
// Read the book
mockMvc.perform(get("/api/books/" + bookId)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id", is(bookId.intValue())))
.andExpect(jsonPath("$.title", is("New Book")));
// Update the book
Book bookUpdates = new Book("Updated Book", "Updated Author", "1111111111", 29.99);
mockMvc.perform(put("/api/books/" + bookId)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(bookUpdates)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id", is(bookId.intValue())))
.andExpect(jsonPath("$.title", is("Updated Book")))
.andExpect(jsonPath("$.author", is("Updated Author")));
// Delete the book
mockMvc.perform(delete("/api/books/" + bookId)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isNoContent());
// Verify the book is deleted
mockMvc.perform(get("/api/books/" + bookId)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isNotFound());
}
@Test
public void testFindByAuthor() throws Exception {
// Find books by author
mockMvc.perform(get("/api/books/author/John Doe")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$", hasSize(2)))
.andExpect(jsonPath("$[*].author", everyItem(is("John Doe"))));
}
@Test
public void testFindByIsbn() throws Exception {
// Find book by ISBN
mockMvc.perform(get("/api/books/isbn/1234567890")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.title", is("Spring Boot Testing")))
.andExpect(jsonPath("$.isbn", is("1234567890")));
}
}
Key Concepts in Integration Testing:
@SpringBootTest
: Loads the entire application context- Working with real databases (in this case, an in-memory H2 database)
- Testing complete user flows from HTTP request to database and back
- Setting up test data with repositories
- Testing CRUD operations in sequence
Part 6: Test Coverage Analysis
Step 1: Add JaCoCo for Test Coverage
Add JaCoCo to your Maven pom.xml:
<build>
<plugins>
<!-- Other plugins -->
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.10</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>
</plugins>
</build>
Step 2: Run tests with coverage
Run the following command to generate a coverage report:
mvn clean test
Then open the report at target/site/jacoco/index.html
to see your test coverage.
Lab Assignment
Now that you’ve seen how to write tests for different layers of a Spring Boot application, complete these tasks:
- Add a new feature to the Book Management System: the ability to search for books by price range.
- Add a new method to the repository:
List<Book> findByPriceBetween(double minPrice, double maxPrice);
- Add a corresponding method to the service and controller
- Write comprehensive tests for this new feature at all three layers
- Add validation to the Book entity:
- Title should not be empty
- ISBN should be a valid ISBN format (either 10 or 13 digits)
- Price should be greater than 0
- Write tests that verify the validation works correctly
- Create a negative test case for each layer:
- Repository: Test what happens when searching for a non-existent record
- Service: Test error handling for invalid inputs
- Controller: Test API validation by sending invalid requests
Part 7: Parameterized Tests
Parameterized tests allow you to run the same test with different sets of inputs. Let’s add a parameterized test to validate ISBN formats:
package com.example.demo.model;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.Validation;
import jakarta.validation.Validator;
import jakarta.validation.ValidatorFactory;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
public class BookValidationTest {
private final ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
private final Validator validator = factory.getValidator();
@ParameterizedTest
@ValueSource(strings = {
"1234567890", // ISBN-10
"9781234567897", // ISBN-13
"978-1-234-56789-7" // ISBN-13 with hyphens
})
public void validIsbnShouldPassValidation(String isbn) {
// Create a book with the test ISBN
Book book = new Book("Test Book", "Test Author", isbn, 19.99);
// Validate the book
Set<ConstraintViolation<Book>> violations = validator.validate(book);
// There should be no violations
assertThat(violations).isEmpty();
}
@ParameterizedTest
@ValueSource(strings = {
"12345", // Too short
"12345678901234567890", // Too long
"abcdefghij", // Not numeric
"123456789X" // Invalid character
})
public void invalidIsbnShouldFailValidation(String isbn) {
// Create a book with the test ISBN
Book book = new Book("Test Book", "Test Author", isbn, 19.99);
// Validate the book
Set<ConstraintViolation<Book>> violations = validator.validate(book);
// There should be at least one violation
assertThat(violations).isNotEmpty();
assertThat(violations).anyMatch(violation ->
violation.getPropertyPath().toString().equals("isbn"));
}
@ParameterizedTest
@CsvSource({
"Test Book, Test Author, 1234567890, 19.99, 0",
", Test Author, 1234567890, 19.99, 1",
"Test Book, , 1234567890, 19.99, 1",
"Test Book, Test Author, , 19.99, 1",
"Test Book, Test Author, 1234567890, 0, 1",
"Test Book, Test Author, 1234567890, -1, 1"
})
public void bookValidationTest(String title, String author, String isbn, double price, int expectedViolations) {
// Create a book with the test parameters
Book book = new Book(title, author, isbn, price);
// Validate the book
Set<ConstraintViolation<Book>> violations = validator.validate(book);
// Check if the number of violations matches the expected count
assertThat(violations).hasSize(expectedViolations);
}
}
Part 8: Testing Exception Handling
Let’s add a custom exception and test exception handling:
Step 1: Create a Custom Exception
package com.example.demo.exception;
public class BookNotFoundException extends RuntimeException {
public BookNotFoundException(Long id) {
super("Book not found with id: " + id);
}
public BookNotFoundException(String isbn) {
super("Book not found with ISBN: " + isbn);
}
}
Step 2: Update the Service to Use the Exception
// In BookService.java
public Book getBookById(Long id) {
return bookRepository.findById(id)
.orElseThrow(() -> new BookNotFoundException(id));
}
public Book getBookByIsbn(String isbn) {
Book book = bookRepository.findByIsbn(isbn);
if (book == null) {
throw new BookNotFoundException(isbn);
}
return book;
}
Step 3: Create a Global Exception Handler
package com.example.demo.exception;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.Map;
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BookNotFoundException.class)
public ResponseEntity<Object> handleBookNotFoundException(BookNotFoundException ex) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("timestamp", LocalDateTime.now());
body.put("message", ex.getMessage());
return new ResponseEntity<>(body, HttpStatus.NOT_FOUND);
}
}
Step 4: Test Exception Handling
package com.example.demo.exception;
import com.example.demo.service.BookService;
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 com.example.demo.repository.BookRepository;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.when;
import java.util.Optional;
@ExtendWith(MockitoExtension.class)
public class ExceptionHandlingTest {
@Mock
private BookRepository bookRepository;
@InjectMocks
private BookService bookService;
@Test
public void whenGetNonExistentBookById_thenThrowBookNotFoundException() {
// given
Long id = 99L;
when(bookRepository.findById(id)).thenReturn(Optional.empty());
// when, then
assertThatThrownBy(() -> bookService.getBookById(id))
.isInstanceOf(BookNotFoundException.class)
.hasMessageContaining("Book not found with id: " + id);
}
@Test
public void whenGetNonExistentBookByIsbn_thenThrowBookNotFoundException() {
// given
String isbn = "nonexistent";
when(bookRepository.findByIsbn(isbn)).thenReturn(null);
// when, then
assertThatThrownBy(() -> bookService.getBookByIsbn(isbn))
.isInstanceOf(BookNotFoundException.class)
.hasMessageContaining("Book not found with ISBN: " + isbn);
}
}
Part 9: Testing With Spring Boot Test Slices
Spring Boot provides “test slices” that load only the parts of the application relevant to the test:
Testing Repository with @DataJpaTest
We’ve already seen this in Part 2, but here’s a reminder:
@DataJpaTest
public class BookRepositoryTest {
// Tests for repository layer
}
Testing Service with @Service
@ExtendWith(MockitoExtension.class)
public class BookServiceTest {
// Tests for service layer
}
Testing Controller with @WebMvcTest
@WebMvcTest(BookController.class)
public class BookControllerTest {
// Tests for controller layer
}
Testing the Full Application with @SpringBootTest
@SpringBootTest
@AutoConfigureMockMvc
public class BookIntegrationTest {
// Integration tests
}
Part 10: Testing REST API Documentation with SpringDoc
Step 1: Add SpringDoc Dependencies
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.2.0</version>
</dependency>
Step 2: Configure OpenAPI Documentation
package com.example.demo.config;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class OpenApiConfig {
@Bean
public OpenAPI customOpenAPI() {
return new OpenAPI()
.info(new Info()
.title("Book Management API")
.version("1.0")
.description("A simple Book Management REST API"));
}
}
Step 3: Add API Documentation to the Controller
package com.example.demo.controller;
import com.example.demo.model.Book;
import com.example.demo.service.BookService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/books")
@Tag(name = "Book", description = "Book management APIs")
public class BookController {
private final BookService bookService;
@Autowired
public BookController(BookService bookService) {
this.bookService = bookService;
}
@Operation(summary = "Get all books")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Found all books",
content = { @Content(mediaType = "application/json",
schema = @Schema(implementation = Book.class)) })
})
@GetMapping
public ResponseEntity<List<Book>> getAllBooks() {
return ResponseEntity.ok(bookService.getAllBooks());
}
@Operation(summary = "Get a book by its id")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Found the book",
content = { @Content(mediaType = "application/json",
schema = @Schema(implementation = Book.class)) }),
@ApiResponse(responseCode = "404", description = "Book not found",
content = @Content)
})
@GetMapping("/{id}")
public ResponseEntity<Book> getBookById(
@Parameter(description = "id of book to be searched") @PathVariable Long id) {
return ResponseEntity.ok(bookService.getBookById(id));
}
// Add similar annotations to other methods
}
Step 4: Test API Documentation
package com.example.demo;
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.test.web.servlet.MockMvc;
import static org.hamcrest.Matchers.containsString;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
public class OpenApiDocumentationTest {
@Autowired
private MockMvc mockMvc;
@Test
public void apiDocumentationAvailable() throws Exception {
mockMvc.perform(get("/v3/api-docs"))
.andExpect(status().isOk())
.andExpect(content().string(containsString("Book Management API")));
}
@Test
public void swaggerUiAvailable() throws Exception {
mockMvc.perform(get("/swagger-ui.html"))
.andExpect(status().isOk())
.andExpect(content().string(containsString("Swagger UI")));
}
}
Final Lab Assignment Tasks:
Now that you’ve learned comprehensive testing techniques for Spring Boot applications, complete these tasks:
Task 1: Implement the Price Range Feature
Add a feature to search books by price range:
- Add to the repository:
List<Book> findByPriceBetween(double minPrice, double maxPrice);
- Add to the service:
public List<Book> getBooksByPriceRange(double minPrice, double maxPrice) {
return bookRepository.findByPriceBetween(minPrice, maxPrice);
}
- Add to the controller:
@GetMapping("/price-range")
public ResponseEntity<List<Book>> getBooksByPriceRange(
@RequestParam double min, @RequestParam double max) {
List<Book> books = bookService.getBooksByPriceRange(min, max);
if (books.isEmpty()) {
return ResponseEntity.noContent().build();
}
return ResponseEntity.ok(books);
}
- Write tests for all three layers
Task 2: Add Validation
Add validation to the Book entity:
- Update the Book entity:
@Entity
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Book {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotBlank(message = "Title is required")
private String title;
@NotBlank(message = "Author is required")
private String author;
@NotBlank(message = "ISBN is required")
@Pattern(regexp = "^(?:ISBN(?:-1[03])?:? )?(?=[0-9X]{10}$|(?=(?:[0-9]+[- ]){3})[- 0-9X]{13}$|97[89][0-9]{10}$|(?=(?:[0-9]+[- ]){4})[- 0-9]{17}$)(?:97[89][- ]?)?[0-9]{1,5}[- ]?[0-9]+[- ]?[0-9]+[- ]?[0-9X]$", message = "Invalid ISBN format")
private String isbn;
@Min(value = 0, message = "Price must be greater than 0")
private double price;
// Constructor without id for easier testing
public Book(String title, String author, String isbn, double price) {
this.title = title;
this.author = author;
this.isbn = isbn;
this.price = price;
}
}
- Update the controller to handle validation:
@PostMapping
public ResponseEntity<Object> createBook(@Valid @RequestBody Book book, BindingResult bindingResult) {
if (bindingResult.hasErrors()) {
Map<String, String> errors = new HashMap<>();
bindingResult.getFieldErrors().forEach(error ->
errors.put(error.getField(), error.getDefaultMessage()));
return ResponseEntity.badRequest().body(errors);
}
Book createdBook = bookService.createBook(book);
return ResponseEntity.status(HttpStatus.CREATED).body(createdBook);
}
- Write validation tests as shown in Part 7
Task 3: Write Negative Test Cases
Write negative test cases for each layer:
- Repository:
@Test
public void whenFindByNonExistentId_thenReturnEmpty() {
Optional<Book> foundBook = bookRepository.findById(999L);
assertThat(foundBook).isEmpty();
}
- Service:
@Test
public void whenCreateBookWithNullTitle_thenThrowException() {
Book invalidBook = new Book(null, "Author", "1234567890", 19.99);
assertThatThrownBy(() -> {
bookService.createBook(invalidBook);
}).isInstanceOf(Exception.class);
}
- Controller:
@Test
public void createBook_withInvalidBook_shouldReturnBadRequest() throws Exception {
Book invalidBook = new Book(null, "", "invalid-isbn", -10.0);
mockMvc.perform(post("/api/books")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(invalidBook)))
.andExpect(status().isBadRequest());
}
Lab Submission Requirements:
- Complete source code including:
- Entity classes with validation
- Repository interfaces with custom queries
- Service classes with business logic
- REST controllers with validation handling
- Exception classes and handlers
- Test classes for all layers
- A brief report (1-2 pages) explaining:
- Your approach to testing the application
- Key testing strategies you used
- Test coverage analysis
- Any challenges you faced and how you resolved them
- Screenshots of:
- Test execution results
- Test coverage report
Grading Criteria:
- Code Quality (25%): Clean, well-organized code following best practices
- Test Coverage (25%): Comprehensive tests covering all layers
- Test Quality (25%): Effective tests that verify correct behavior
- Understanding (25%): Demonstrated understanding of testing concepts in the report
Additional Resources:
- Spring Boot Testing Documentation
- JUnit 5 User Guide
- Mockito Documentation
- AssertJ Documentation
- Testing with JaCoCo
This comprehensive lab will guide you through the most important aspects of testing in Spring Boot applications. The lab covers:
-
Setting up a basic Spring Boot application with the standard 3-tier architecture (controller, service, repository)
-
Repository testing - Using
@DataJpaTest
to test database interactions without loading the entire application context -
Service layer testing - Using Mockito to mock dependencies and isolate the service layer for unit testing
-
Controller testing - Using MockMvc to test API endpoints without starting a web server
-
Integration testing - Testing the full application flow from HTTP request to database and back
-
Test coverage analysis - Using JaCoCo to identify untested code
-
Advanced testing techniques including:
- Parameterized tests for testing multiple inputs
- Exception handling testing
- API documentation testing
By completing this lab, you will gain a solid understanding of how to write effective tests for all layers of a Spring Boot application, which is an essential skill for professional Java developers.
By Wahid Hamdi