Background

In the  third post of this series, I want to talk about unit testing, one of the most important part of the software development I thought.

Creaating a developer-friendly unit testing environment and writing high-quality test cases are both essential for building a robust and maintainable SaaS product. Over time, these practices help ensure your service remains reliable as it grows in complexity.

In this post, I'll not only cover how to write unit tests in Java Spring Boot, but also how to set up a clean and efficient testing environment that makes writing and running tests easier for the whole team.

Installation

Make sure you have this (Spring Boot usually adds it already):

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

This includes:

  • JUnit 5
  • Mockito
  • AssertJ
  • Spring Test

Next I want to introduce the H2, H2 is a lightweight relational database written entirely in Java. It belongs to a disposable and in-memory database that exists only for your tests. In Spring Boot, H2 lets you test real database behavior without the cost of running MySQL/Postgres.

<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>test</scope>
</dependency>

We also need to add the following application setting for our testing environment.

spring:
  datasource:
    url: jdbc:h2:mem:todolist-test;MODE=PostgreSQL
    driver-class-name: org.h2.Driver
    username: sa
    password:

  liquibase:
    enabled: false

  jpa:
    hibernate:
      ddl-auto: create-drop
    show-sql: false
    properties:
      hibernate:
        format_sql: false

logging:
  level:
    root: WARN

    # Spring framework noise
    org.springframework: WARN

    # Hibernate SQL + binding noise
    org.hibernate.SQL: WARN
    org.hibernate.type.descriptor.sql: WARN

    # JPA bootstrap logs
    org.springframework.orm.jpa: WARN
src/test/resources/application-test.yml

You can change the mode in the database url to your target database, I used the PostgreSQL here. Remenber to add the @ActiveProfiles("test") to the TodolistApplicationTests.java to tell the spring to use the application-test.yml to overwrite the application.yml, which means can load the testing config for unit test.

package com.example.todolist;

import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;

@SpringBootTest
@ActiveProfiles("test")
class TodolistApplicationTests {

	@Test
	void contextLoads() {
	}

}
TodolistApplicationTests.java

Implementation

In this section, we will walk through how to write the unit test in Java Spring Boot. In previous posts we have implemented the controller, service and repository part in Spring. Next we will write the unit tests for each of them, here we go:

Repository

package com.example.todolist.repository;

import com.example.todolist.model.Todo;
import org.springframework.data.jpa.repository.JpaRepository;

public interface TodoRepository extends JpaRepository<Todo, Long> {}
TodoRepository.java
package com.example.todolist.model;

import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

import java.time.Instant;

@Entity
@Table(name = "todolist")
@Getter
@Setter
@NoArgsConstructor
public class Todo {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String title;

    private String description;

    @Column(nullable = false)
    private boolean completed = false;

    // UTC-safe creation timestamp
    @Column(nullable = false, updatable = false,
            columnDefinition = "TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP")
    private Instant createdAt = Instant.now();

    // Custom constructor for creating new todos
    public Todo(String title, String description) {
        this.title = title;
        this.description = description;
    }

}
Todo.java

We need to define our test plans based the todo model and repository. We are testing:

  • JPA entity mapping
  • Auto-generated ID
  • @Column(nullable = false) constraints
  • Default values (completed, createdAt)
  • CRUD behavior (save, find, update, delete)

The whole test code for the repository is

package com.example.todolist.repository;

import com.example.todolist.model.Todo;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.test.context.ActiveProfiles;

import java.util.Optional;

import static org.assertj.core.api.Assertions.assertThat;

@DataJpaTest
@ActiveProfiles("test")
class TodoRepositoryTest {

    @Autowired
    private TodoRepository todoRepository;

    @Test
    void shouldSaveAndFindTodo() {
        // Arrange
        Todo todo = new Todo("Write tests", "Learn H2 with Spring Boot");

        // Act
        Todo saved = todoRepository.save(todo);

        // Assert
        assertThat(saved.getId()).isNotNull();
        assertThat(saved.isCompleted()).isFalse();
        assertThat(saved.getCreatedAt()).isNotNull();

        Optional<Todo> found = todoRepository.findById(saved.getId());

        assertThat(found).isPresent();
        assertThat(found.get().getTitle()).isEqualTo("Write tests");
    }

    @Test
    void shouldUpdateTodo() {
        // Arrange
        Todo todo = todoRepository.save(new Todo("Old title", "Old desc"));

        // Act
        todo.setTitle("New title");
        todo.setCompleted(true);
        Todo updated = todoRepository.save(todo);

        // Assert
        assertThat(updated.getTitle()).isEqualTo("New title");
        assertThat(updated.isCompleted()).isTrue();
    }

    @Test
    void shouldDeleteTodo() {
        // Arrange
        Todo todo = todoRepository.save(new Todo("Delete me", "Temp"));

        // Act
        todoRepository.deleteById(todo.getId());

        // Assert
        assertThat(todoRepository.findById(todo.getId())).isEmpty();
    }

    @Test
    void shouldEnforceNotNullConstraint() {
        // Arrange
        Todo todo = new Todo();
        todo.setDescription("Missing title");

        // Act & Assert
        assertThat(org.junit.jupiter.api.Assertions.assertThrows(
            org.springframework.dao.DataIntegrityViolationException.class,
            () -> {
                todoRepository.save(todo);
                todoRepository.flush();
            }
        )).isNotNull();
    }
}

The first thing I want to talk about is package com.example.todolist.repository;, "why in both TodoRepositoryTest.java and TodoRepository.java have package com.example.todolist.repository;? ", and it brings very important concept in the JAVA:

💡
Java does not care about folders — it cares about packages. Test classes should live in the same package as the code they test.

Usually I use the AAA (Triple-A) concept to write the unit test no matter which programming language I used. What Is the AAA (Triple-A) Concept? It’s a test structure pattern, not a framework feature.

“Set up → Do the thing → Check the result”

There are three parts you need to complete in your unit test:

  1. Arrange
  2. Act
  3. Assert

That's why this concept called AAA (Triple-A). Remember, if Arrange, Act, and Assert cannot be clearly separated, reconsider the responsibility of the code being tested.

Now we can run the mvn clean test command to check if the testing works correctly.

➜ mvn clean test
...
...
...
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.352 s -- in com.example.todolist.TodolistApplicationTests
[INFO]
[INFO] Results:
[INFO]
[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time:  3.229 s
[INFO] Finished at: 2025-12-13T23:09:29+08:00
[INFO] ------------------------------------------------------------------------

Ok, everything works well now. Next we will complete the unit tests for controller and service.

Service

package com.example.todolist.service;

import com.example.todolist.model.Todo;
import com.example.todolist.repository.TodoRepository;
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.List;
import java.util.Optional;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;

@ExtendWith(MockitoExtension.class)
class TodoServiceTest {

    @Mock
    private TodoRepository todoRepository;

    @InjectMocks
    private TodoService todoService;

    @Test
    void getAllTodos_shouldReturnAllTodos() {
        // Arrange
        Todo todo1 = new Todo("Task 1", "Desc 1");
        Todo todo2 = new Todo("Task 2", "Desc 2");

        when(todoRepository.findAll()).thenReturn(List.of(todo1, todo2));

        // Act
        List<Todo> result = todoService.getAllTodos();

        // Assert
        assertThat(result).hasSize(2);
        assertThat(result).extracting(Todo::getTitle)
                .containsExactly("Task 1", "Task 2");

        verify(todoRepository).findAll();
    }

    @Test
    void createTodo_shouldSaveAndReturnTodo() {
        // Arrange
        Todo todo = new Todo("New Task", "New Desc");

        Todo savedTodo = new Todo("New Task", "New Desc");
        savedTodo.setId(1L);

        when(todoRepository.save(any(Todo.class))).thenReturn(savedTodo);

        // Act
        Todo result = todoService.createTodo(todo);

        // Assert
        assertThat(result.getId()).isEqualTo(1L);
        assertThat(result.getTitle()).isEqualTo("New Task");
        assertThat(result.isCompleted()).isFalse();

        verify(todoRepository).save(todo);
    }

    @Test
    void updateTodo_shouldUpdateExistingTodo() {
        // Arrange
        Long todoId = 1L;

        Todo existing = new Todo("Old Title", "Old Desc");
        existing.setId(todoId);

        Todo updated = new Todo("New Title", "New Desc");
        updated.setCompleted(true);

        when(todoRepository.findById(todoId)).thenReturn(Optional.of(existing));
        when(todoRepository.save(any(Todo.class))).thenAnswer(invocation -> invocation.getArgument(0));

        // Act
        Todo result = todoService.updateTodo(todoId, updated);

        // Assert
        assertThat(result.getTitle()).isEqualTo("New Title");
        assertThat(result.getDescription()).isEqualTo("New Desc");
        assertThat(result.isCompleted()).isTrue();

        verify(todoRepository).findById(todoId);
        verify(todoRepository).save(existing);
    }

    @Test
    void updateTodo_shouldThrowException_whenTodoNotFound() {
        // Arrange
        Long todoId = 99L;
        Todo updated = new Todo("Doesn't matter", "Nope");

        when(todoRepository.findById(todoId)).thenReturn(Optional.empty());

        // Act + Assert
        assertThatThrownBy(() -> todoService.updateTodo(todoId, updated))
                .isInstanceOf(RuntimeException.class)
                .hasMessage("Todo not found");

        verify(todoRepository).findById(todoId);
        verify(todoRepository, never()).save(any());
    }

    @Test
    void deleteTodo_shouldDeleteById() {
        // Arrange
        Long todoId = 1L;

        doNothing().when(todoRepository).deleteById(todoId);

        // Act
        todoService.deleteTodo(todoId);

        // Assert
        verify(todoRepository).deleteById(todoId);
    }
}
TodolistApplicationTests.java

First of all, @ExtendWith(MockitoExtension.class) this line tells JUnit 5 to enable Mockito (Required for @Mock / @InjectMocks) support for this test class.

What's the difference between the @Mock and @injectMocks?

Annotation

What it does

@Mock

Creates a fake object (a mock)

@InjectMocks

Creates a real object and injects mocks into it

@Mock, Mockito creates a fake implementation of TodoRepository, no real logic runs unless you explicitly stub it. Example:

when(todoRepository.findAll())
    .thenReturn(List.of(todo1, todo2));

@InjectMocks, Mockito creates a real instance of TodoService, looks for fields annotated with @Mock then injects them into the service. For example

@InjectMocks
TodoService todoService;

is effectively to

TodoService todoService = new TodoService(mockTodoRepository);

For the verify()means "Assert that this method was called on the mock.". Essential for void methods and side effects.

Compared to Django/Python:

Python

Mockito

assert mock.called

verify(mock)

assert mock.call_count == 1

times(1)

assert not mock.called

never()

Controller

Here is the unit test code of controller:

package com.example.todolist.controller;

import com.example.todolist.model.Todo;
import com.example.todolist.service.TodoService;
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.List;

import static org.hamcrest.Matchers.hasSize;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@WebMvcTest(TodoController.class)
class TodoControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private TodoService todoService;

    @Autowired
    private ObjectMapper objectMapper;

    @Test
    void getAllTodos_shouldReturnTodoList() throws Exception {
        // Arrange
        Todo todo1 = new Todo("Task 1", "Desc 1");
        Todo todo2 = new Todo("Task 2", "Desc 2");

        when(todoService.getAllTodos()).thenReturn(List.of(todo1, todo2));

        // Act & Assert
        mockMvc.perform(get("/api/todos"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$", hasSize(2)))
                .andExpect(jsonPath("$[0].title").value("Task 1"))
                .andExpect(jsonPath("$[1].title").value("Task 2"));
    }

    @Test
    void createTodo_shouldReturnCreatedTodo() throws Exception {
        // Arrange
        Todo request = new Todo("New Task", "New Desc");

        Todo saved = new Todo("New Task", "New Desc");
        saved.setId(1L);

        when(todoService.createTodo(any(Todo.class))).thenReturn(saved);

        // Act & Assert
        mockMvc.perform(post("/api/todos")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(request)))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.id").value(1L))
                .andExpect(jsonPath("$.title").value("New Task"))
                .andExpect(jsonPath("$.completed").value(false));
    }

    @Test
    void updateTodo_shouldReturnUpdatedTodo() throws Exception {
        // Arrange
        Long todoId = 1L;

        Todo updated = new Todo("Updated Task", "Updated Desc");
        updated.setCompleted(true);
        updated.setId(todoId);

        when(todoService.updateTodo(eq(todoId), any(Todo.class)))
                .thenReturn(updated);

        // Act & Assert
        mockMvc.perform(put("/api/todos/{id}", todoId)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(updated)))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.title").value("Updated Task"))
                .andExpect(jsonPath("$.completed").value(true));
    }

    @Test
    void deleteTodo_shouldReturnOk() throws Exception {
        // Arrange
        Long todoId = 1L;
        doNothing().when(todoService).deleteTodo(todoId);

        // Act & Assert
        mockMvc.perform(delete("/api/todos/{id}", todoId))
                .andExpect(status().isOk());
    }
}

I want to explain a few common scenarios that are especially useful when writing unit tests.

The first one is:

// Act & Assert
mockMvc.perform(post("/api/todos")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(1L))
.andExpect(jsonPath("$.title").value("New Task"))
.andExpect(jsonPath("$.completed").value(false));

The logic behind it is "Simulating an HTTP POST request to your controller and asserting the HTTP response."

  • mockMvc = a fake HTTP client
  • .contentType(MediaType.APPLICATION_JSON) = sets the HTTP header
  • .content(objectMapper.writeValueAsString(request)) = set the request body.
  • The Assert Chain for couple of andExpect
  • .andExpect(jsonPath("$.id").value(1L)) = inspects the JSON response body.
  • $ = root object
  • $.id = id field

Next one is:

doNothing().when(todoService).deleteTodo(todoId);

It means that "When deleteTodo(todoId) is called on this mock, do nothing.", you can think we mock and method with do nothing.

In this section, I show you how to use the JUnit + Mockito with H2 in memory database to complete the unit tests for the todo list application, and also share the key concept of how to write the good unit test to you. In the next section, I will show you couple of useful command in developing the unit tests.

Command

In this section, I want to show you couple of useful command in developing the unit tests.

  • Run the whole unit tests
$ mvn clean test
  • Run one test class
$ mvn test -Dtest=TodoServiceTest,TodoControllerTest
  • Run multiple test classes
$ mvn test -Dtest=TodoServiceTest,TodoControllerTest
  • Run a single test method
$ mvn test -Dtest=TodoServiceTest#updateTodo_shouldUpdateExistingTodo

If you use the vscode to develop the Java Spring Boot project, you can install the Extension Pack for Java which including the following packages:

  • Language Support for Java
  • Debugger for Java
  • Maven for Java
  • Test Runner for Java

And you can navigate to you testing panel in the left side bar to trigger running the unit test via UI.

Takeaway

In this post, I walk you though how to write high-quality unit tests using the Arrange–Act–Assert (AAA) pattern, step by step. Unit testing is a critical part of the modern software development life cycle (SDLC): it helps you validate behavior early, refactor with confidence, and keep regression from slipping into production. By adopting AAA as a consistent testing guideline, you can make your test easier to read, maintain and ultimately make your software projects more reliable over time.