“Testing your API is like a monkey double-checking its banana stash—you want to be absolutely sure no one’s tampered with it!” 🍌✅

You’ve built a Spring Boot API, implemented features, and perhaps even secured it. But how confident are you that everything works as expected, especially after new changes? This is where testing comes in! In this guide, we’ll make your Spring Boot applications monkey-proof by diving into both unit and integration testing. By the end, you’ll be writing tests that ensure your code is robust, reliable, and ready for the wild.


🚩 Prerequisites

  1. Java Development Kit (JDK) 17+
  2. Maven or Gradle
  3. A basic Spring Boot REST project (e.g., from How to set up your first Spring Boot project).
  4. Familiarity with JUnit 5.

1️⃣ Why Test Your APIs?

Tests aren’t just for bug-hunting; they’re your safety net:

  • Catch Bugs Early: Find issues before they hit production.
  • Refactoring Confidence: Change code without fear of breaking existing features.
  • Documentation: Tests show how your code is *supposed* to be used.
  • Maintainability: A well-tested codebase is easier to understand and extend.

2️⃣ Setting Up for Testing

Dependencies

Spring Boot includes spring-boot-starter-test by default, which brings in JUnit 5, Mockito, AssertJ, and more. Ensure your pom.xml or build.gradle has it:

<!-- pom.xml (usually already present) -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

Project Structure

Keep your tests in src/test/java, mirroring your main package structure (e.g., com.example.demo.service.MyServiceTest for com.example.demo.service.MyService).


3️⃣ Unit Testing: Isolated Logic

Unit tests focus on small, isolated parts of your code (e.g., a single method in a service). We use Mockito to “mock” dependencies so we’re only testing *our* code, not its collaborators.

Example: Service Test

Let’s say you have a BananaService that interacts with a BananaRepository:

// src/main/java/com/example/demo/service/BananaService.java
package com.example.demo.service;

import com.example.demo.model.Banana;
import com.example.demo.repository.BananaRepository;
import org.springframework.stereotype.Service;

import java.util.Optional;

@Service
public class BananaService {
    private final BananaRepository bananaRepository;

    public BananaService(BananaRepository bananaRepository) {
        this.bananaRepository = bananaRepository;
    }

    public Optional<Banana> getBananaById(Long id) {
        return bananaRepository.findById(id);
    }

    public Banana saveBanana(Banana banana) {
        // Imagine some business logic here
        if (banana.getRipeness() < 1) {
            throw new IllegalArgumentException("Banana too green!");
        }
        return bananaRepository.save(banana);
    }
}

Now, test it:

// src/test/java/com/example/demo/service/BananaServiceTest.java
package com.example.demo.service;

import com.example.demo.model.Banana;
import com.example.demo.repository.BananaRepository;
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.Optional;

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;

@ExtendWith(MockitoExtension.class) // Enable Mockito for JUnit 5
class BananaServiceTest {

    @Mock // Create a mock for BananaRepository
    private BananaRepository bananaRepository;

    @InjectMocks // Inject mocks into BananaService
    private BananaService bananaService;

    private Banana ripeBanana;
    private Banana greenBanana;

    @BeforeEach
    void setUp() {
        ripeBanana = new Banana("Cavendish", 4);
        ripeBanana.setId(1L);
        greenBanana = new Banana("Plantain", 0); // Too green!
    }

    @Test
    void getBananaById_shouldReturnBanana_whenFound() {
        when(bananaRepository.findById(1L)).thenReturn(Optional.of(ripeBanana));

        Optional<Banana> found = bananaService.getBananaById(1L);

        assertThat(found).isPresent();
        assertThat(found.get().getName()).isEqualTo("Cavendish");
    }

    @Test
    void getBananaById_shouldReturnEmpty_whenNotFound() {
        when(bananaRepository.findById(2L)).thenReturn(Optional.empty());

        Optional<Banana> found = bananaService.getBananaById(2L);

        assertThat(found).isEmpty();
    }

    @Test
    void saveBanana_shouldSaveAndReturnBanana() {
        when(bananaRepository.save(any(Banana.class))).thenReturn(ripeBanana);

        Banana saved = bananaService.saveBanana(ripeBanana);

        assertThat(saved).isNotNull();
        assertThat(saved.getName()).isEqualTo("Cavendish");
    }

    @Test
    void saveBanana_shouldThrowException_whenRipenessTooLow() {
        assertThrows(IllegalArgumentException.class, () -> {
            bananaService.saveBanana(greenBanana);
        });
    }
}
  • @Mock: Creates a mock instance of BananaRepository.
  • @InjectMocks: Injects the mocked BananaRepository into BananaService.
  • when(...).thenReturn(...): Defines the behavior of the mocked methods.

4️⃣ Integration Testing: The Full Stack

Integration tests boot up a portion (or all) of your Spring application context, verifying how different components (controllers, services, repositories) interact with each other and potentially a real database.

@SpringBootTest Basics

@SpringBootTest loads your full application context. Combine it with @AutoConfigureMockMvc to test your web layer without a running server.

// src/test/java/com/example/demo/controller/BananaControllerIntegrationTest.java
package com.example.demo.controller;

import com.example.demo.model.Banana;
import com.example.demo.repository.BananaRepository;
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.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;

import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@SpringBootTest // Loads the full Spring application context
@AutoConfigureMockMvc // Configures MockMvc for web layer testing
@ActiveProfiles("test") // Use a dedicated test profile if you have one
class BananaControllerIntegrationTest {

    @Autowired
    private MockMvc mockMvc; // Used to send HTTP requests to the controller

    @Autowired
    private BananaRepository bananaRepository; // To set up test data

    @Autowired
    private ObjectMapper objectMapper; // For converting objects to JSON

    @BeforeEach
    void setUp() {
        bananaRepository.deleteAll(); // Clean up database before each test
    }

    @Test
    void createBanana_shouldReturnCreatedBanana() throws Exception {
        Banana newBanana = new Banana("Gros Michel", 5);

        mockMvc.perform(post("/api/bananas")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(newBanana)))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.name").value("Gros Michel"))
                .andExpect(jsonPath("$.ripeness").value(5));

        assertThat(bananaRepository.findAll()).hasSize(1);
    }

    @Test
    void getBananaById_shouldReturnBanana_whenExists() throws Exception {
        Banana savedBanana = bananaRepository.save(new Banana("Lady Finger", 3));

        mockMvc.perform(get("/api/bananas/{id}", savedBanana.getId()))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.name").value("Lady Finger"));
    }

    @Test
    void getBananaById_shouldReturnNotFound_whenNotExists() throws Exception {
        mockMvc.perform(get("/api/bananas/{id}", 999L))
                .andExpect(status().isNotFound());
    }

    @Test
    void getAllBananas_shouldReturnEmptyList_initially() throws Exception {
        mockMvc.perform(get("/api/bananas"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$").isEmpty());
    }

    @Test
    void deleteBanana_shouldReturnNoContent() throws Exception {
        Banana savedBanana = bananaRepository.save(new Banana("Red Dacca", 2));

        mockMvc.perform(delete("/api/bananas/{id}", savedBanana.getId()))
                .andExpect(status().isNoContent());

        assertThat(bananaRepository.findById(savedBanana.getId())).isEmpty();
    }
}
  • MockMvc: Simulates HTTP requests without deploying a full server.
  • jsonPath("$").value(...): Asserts values in the JSON response body.
  • @ActiveProfiles("test"): Ensures your test uses a dedicated configuration (e.g., in-memory H2 for tests).

5️⃣ Web Layer Testing: @WebMvcTest

If you only want to test your controller and its direct dependencies (without loading the entire service/repository layers), @WebMvcTest is perfect. It’s faster as it loads a much smaller context.

Example: Controller Test with Mocked Service

// src/test/java/com/example/demo/controller/BananaControllerWebLayerTest.java
package com.example.demo.controller;

import com.example.demo.model.Banana;
import com.example.demo.service.BananaService;
import com.fasterxml.jackson.databind.ObjectMapper;
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.Collections;
import java.util.Optional;

import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.Matchers.is;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@WebMvcTest(BananaController.class) // Test only BananaController and its web components
class BananaControllerWebLayerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean // Create a mock for BananaService, not the real one
    private BananaService bananaService;

    @Autowired
    private ObjectMapper objectMapper;

    @Test
    void getAllBananas_shouldReturnListOfBananas() throws Exception {
        Banana banana = new Banana("Fugla", 3);
        banana.setId(1L);
        when(bananaService.getAllBananas()).thenReturn(Collections.singletonList(banana));

        mockMvc.perform(get("/api/bananas"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$", hasSize(1)))
                .andExpect(jsonPath("$[0].name", is("Fugla")));
    }

    @Test
    void createBanana_shouldReturnCreatedBanana() throws Exception {
        Banana newBanana = new Banana("Apple Banana", 4);
        newBanana.setId(1L);
        when(bananaService.saveBanana(any(Banana.class))).thenReturn(newBanana);

        mockMvc.perform(post("/api/bananas")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(newBanana)))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.name", is("Apple Banana")));
    }

    @Test
    void getBananaById_shouldReturnBanana() throws Exception {
        Banana banana = new Banana("Blue Java", 2);
        banana.setId(1L);
        when(bananaService.getBananaById(1L)).thenReturn(Optional.of(banana));

        mockMvc.perform(get("/api/bananas/1"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.name", is("Blue Java")));
    }
}
  • @WebMvcTest(BananaController.class): Focuses on the web layer. Only BananaController and its Spring MVC infrastructure are loaded.
  • @MockBean: Used to provide mock instances of dependencies (like BananaService) that BananaController relies on.

💡 Monkey-Proof Tips

  • Test Pyramid: Aim for many fast unit tests, fewer integration tests, and even fewer end-to-end tests.
  • Clear Assertions: Use AssertJ or Hamcrest for readable and expressive assertions (e.g., assertThat(list).hasSize(2)).
  • Test Data Setup: For integration tests, use @BeforeEach to set up or clean up test data to ensure test isolation.
  • Testcontainers: For integration tests involving real databases or message queues, consider Testcontainers to spin up disposable instances programmatically.
  • Performance: Integration tests are slower than unit tests. Only use @SpringBootTest when you genuinely need the full context.

🚀 Challenge

  1. Extend Unit Tests: Add more unit tests to BananaServiceTest to cover edge cases, such as updating a banana with invalid ripeness, or trying to retrieve a banana by a null ID.
  2. Add a New Endpoint: Create a new GET endpoint /api/bananas/search?name=... in BananaController that takes a partial name and returns matching bananas. Then, write both an @WebMvcTest and an @SpringBootTest for this new endpoint.
  3. Implement Validation Error Testing: Add an integration test that attempts to create a Banana with invalid data (e.g., empty name, ripeness below 1). Assert that the API returns a 400 Bad Request status and includes appropriate error messages in the JSON response.

👏 You’ve now equipped yourself with the tools to write robust unit and integration tests for your Spring Boot APIs! Your code is no longer a wild jungle; it’s a meticulously checked banana farm. Keep testing, and keep building high-quality Java applications!

By admin

Leave a Reply

Your email address will not be published. Required fields are marked *