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 components
  • TestEntityManager: 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 components
  • MockMvc: 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:

  1. 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
  1. 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
  1. 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:

  1. Add to the repository:
List<Book> findByPriceBetween(double minPrice, double maxPrice);
  1. Add to the service:
public List<Book> getBooksByPriceRange(double minPrice, double maxPrice) {
    return bookRepository.findByPriceBetween(minPrice, maxPrice);
}
  1. 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);
}
  1. Write tests for all three layers

Task 2: Add Validation

Add validation to the Book entity:

  1. 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;
    }
}
  1. 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);
}
  1. Write validation tests as shown in Part 7

Task 3: Write Negative Test Cases

Write negative test cases for each layer:

  1. Repository:
@Test
public void whenFindByNonExistentId_thenReturnEmpty() {
    Optional<Book> foundBook = bookRepository.findById(999L);
    assertThat(foundBook).isEmpty();
}
  1. Service:
@Test
public void whenCreateBookWithNullTitle_thenThrowException() {
    Book invalidBook = new Book(null, "Author", "1234567890", 19.99);

    assertThatThrownBy(() -> {
        bookService.createBook(invalidBook);
    }).isInstanceOf(Exception.class);
}
  1. 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:

  1. 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
  1. 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
  1. 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:

This comprehensive lab will guide you through the most important aspects of testing in Spring Boot applications. The lab covers:

  1. Setting up a basic Spring Boot application with the standard 3-tier architecture (controller, service, repository)

  2. Repository testing - Using @DataJpaTest to test database interactions without loading the entire application context

  3. Service layer testing - Using Mockito to mock dependencies and isolate the service layer for unit testing

  4. Controller testing - Using MockMvc to test API endpoints without starting a web server

  5. Integration testing - Testing the full application flow from HTTP request to database and back

  6. Test coverage analysis - Using JaCoCo to identify untested code

  7. 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