Skip to main content

Unit and Integration Testing with JUnit 5 and Mockito

Ensuring quality and reliability in backend architectures requires validating that individual units fulfill their contracts and that layer integration functions seamlessly. In the Spring Boot ecosystem, the standard testing stack combines JUnit 5 (Jupiter) as the testing framework with Mockito for dependency mocking.


1. Concepts and Theoretical Foundations

Unit Testing vs. Integration Testing

CriterionUnit TestingIntegration Testing
ScopeSingle service class or method in complete isolation.Multiple layers (Controller + Service + Repository + DB).
Spring ContextNo ApplicationContext loaded (instant execution).Loads full Spring context (@SpringBootTest).
DependenciesSubstituted with mock objects (@Mock).Real beans injected or in-memory DB used (H2/Testcontainers).
SpeedTens of tests per second (< 100 ms).Seconds per test suite due to context bootstrapping.
ObjectiveBusiness logic, edge branches, and exception paths.Transactions, JPA mappings, SQL queries, and configuration.

Layer Architecture and Mockito Isolation

Diagram Component Overview

  1. @Mock (Simulated Dependency): Instantiates a mock representation of AccountRepository. It does not execute underlying database code; it responds strictly with stubs programmed via when(...).
  2. @InjectMocks (Target Under Test): Instantiates BankService and automatically injects all declared @Mock fields.
  3. Integration Context: Bootstraps the real Spring container, executing real SQL statements against an in-memory test database while verifying transactional boundaries.

Arrange - Act - Assert (AAA) Pattern

Well-structured unit tests follow three clear steps:

  • Arrange: Set up test fixtures and define mock expectations (when(...).thenReturn(...)).
  • Act: Execute the specific method under test.
  • Assert: Validate outputs (assertEquals, assertTrue) and verify mock interactions (verify(...)).

2. Practical Guide: Step-by-Step

1

Verify Dependencies in pom.xml

The spring-boot-starter-test artifact automatically bundles JUnit 5, Mockito, AssertJ, and Spring Test.

pom.xml
<dependencies>
<!-- Includes JUnit 5, Mockito, and Spring Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>

<!-- In-memory database for integration tests -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
2

Implement the Service Class Under Test

Constructor injection facilitates passing mock dependencies without reflection overhead.

src/main/java/com/icesi/service/BankService.java
package com.icesi.service;

import com.icesi.model.Account;
import com.icesi.repository.AccountRepository;
import org.springframework.stereotype.Service;

import java.util.Optional;

@Service
public class BankService {

private final AccountRepository accountRepository;

public BankService(AccountRepository accountRepository) {
this.accountRepository = accountRepository;
}

/**
* Transfers funds between accounts.
* @return true if successful, false if balance is insufficient
*/
public boolean transfer(Long fromId, Long toId, double amount) {
if (amount <= 0) {
throw new IllegalArgumentException("Transfer amount must be positive");
}

Account from = accountRepository.findById(fromId)
.orElseThrow(() -> new IllegalArgumentException("Source account not found"));
Account to = accountRepository.findById(toId)
.orElseThrow(() -> new IllegalArgumentException("Destination account not found"));

if (from.getBalance() < amount) {
return false;
}

from.setBalance(from.getBalance() - amount);
to.setBalance(to.getBalance() + amount);

accountRepository.save(from);
accountRepository.save(to);
return true;
}
}
3

Write Unit Tests with Mockito

Annotate the class with @ExtendWith(MockitoExtension.class). Avoid loading Spring context annotations to keep tests instantaneous.

src/test/java/com/icesi/service/BankServiceTest.java
package com.icesi.service;

import com.icesi.model.Account;
import com.icesi.repository.AccountRepository;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import java.util.Optional;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;

@ExtendWith(MockitoExtension.class)
class BankServiceTest {

@Mock
private AccountRepository accountRepository;

@InjectMocks
private BankService bankService;

@Test
@DisplayName("Should transfer funds successfully when balance is sufficient")
void shouldTransferFundsSuccessfully() {
// 1. Arrange
Account sourceAccount = new Account(1L, "Carol", 100.0);
Account destinationAccount = new Account(2L, "Juan", 50.0);

when(accountRepository.findById(1L)).thenReturn(Optional.of(sourceAccount));
when(accountRepository.findById(2L)).thenReturn(Optional.of(destinationAccount));

// 2. Act
boolean result = bankService.transfer(1L, 2L, 30.0);

// 3. Assert
assertTrue(result);
assertEquals(70.0, sourceAccount.getBalance());
assertEquals(80.0, destinationAccount.getBalance());

verify(accountRepository, times(1)).save(sourceAccount);
verify(accountRepository, times(1)).save(destinationAccount);
}

@Test
@DisplayName("Should return false and skip saving when balance is insufficient")
void shouldReturnFalseWhenInsufficientBalance() {
// Arrange
Account sourceAccount = new Account(1L, "Carol", 20.0);
Account destinationAccount = new Account(2L, "Juan", 50.0);

when(accountRepository.findById(1L)).thenReturn(Optional.of(sourceAccount));
when(accountRepository.findById(2L)).thenReturn(Optional.of(destinationAccount));

// Act
boolean result = bankService.transfer(1L, 2L, 100.0);

// Assert
assertFalse(result);
assertEquals(20.0, sourceAccount.getBalance());
verify(accountRepository, never()).save(any(Account.class));
}

@Test
@DisplayName("Should throw IllegalArgumentException when amount is negative")
void shouldThrowExceptionWhenAmountIsNegative() {
assertThrows(IllegalArgumentException.class, () -> {
bankService.transfer(1L, 2L, -10.0);
});

verifyNoInteractions(accountRepository);
}
}
4

Configure an Isolated Test Profile

Create a separate properties configuration to avoid modifying local development or production databases.

src/test/resources/application-test.properties
# In-memory H2 database
spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=

# Hibernate schema auto-generation
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=true
5

Write Integration Tests with @SpringBootTest

Use @SpringBootTest alongside @ActiveProfiles("test") for end-to-end database testing.

src/test/java/com/icesi/integration/AccountRepositoryIntegrationTest.java
package com.icesi.integration;

import com.icesi.model.Account;
import com.icesi.repository.AccountRepository;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.transaction.annotation.Transactional;

import java.util.Optional;

import static org.junit.jupiter.api.Assertions.*;

@SpringBootTest
@ActiveProfiles("test")
@Transactional
class AccountRepositoryIntegrationTest {

@Autowired
private AccountRepository accountRepository;

@Test
@DisplayName("Should persist and retrieve account in H2 database")
void shouldPersistAndRetrieveAccount() {
Account account = new Account(null, "Diana", 450.0);

Account saved = accountRepository.save(account);
assertNotNull(saved.getId());

Optional<Account> retrieved = accountRepository.findById(saved.getId());
assertTrue(retrieved.isPresent());
assertEquals("Diana", retrieved.get().getOwnerName());
assertEquals(450.0, retrieved.get().getBalance());
}
}
6

Execute Tests from the Terminal or IDE

Use the Maven Wrapper (mvnw or mvnw.cmd) to run test suites cleanly across any development environment.

Execute all project tests
# Run all unit and integration tests
.\mvnw.cmd test

# Run a specific test class
.\mvnw.cmd test -Dtest=BankServiceTest

# Run a single test method
.\mvnw.cmd test -Dtest=BankServiceTest#shouldTransferFundsSuccessfully

# Package while skipping tests
.\mvnw.cmd package -DskipTests
Why use the Maven Wrapper (mvnw)?

The mvnw wrapper guarantees that all developers and automated CI/CD runners execute tests against identical Maven versions without requiring prior system-level installation.


3. Reference Cheatsheet: JUnit 5 and Mockito

Key JUnit 5 Assertions

AssertionPurposeExample
assertEquals(expected, actual)Verifies exact value equality.assertEquals(100.0, account.getBalance());
assertTrue(condition)Checks that boolean expression evaluates to true.assertTrue(result);
assertFalse(condition)Checks that boolean expression evaluates to false.assertFalse(isBlocked);
assertNull(obj) / assertNotNull(obj)Validates nullability of an object reference.assertNotNull(saved.getId());
assertThrows(Class, Executable)Confirms executable block throws expected exception.assertThrows(IllegalArgumentException.class, () -> service.pay(-5));
assertAll(Executables...)Executes grouped assertions and reports all failures together.assertAll(() -> assertTrue(a), () -> assertEquals(2, b));

Key Mockito Methods

MethodPurposeExample
when(mock.m()).thenReturn(v)Configures stubbed return value.when(repo.findById(1L)).thenReturn(Optional.of(acc));
when(mock.m()).thenThrow(ex)Stubs mock call to throw exception.when(repo.save(any())).thenThrow(new RuntimeException());
verify(mock, times(n)).m()Verifies method was invoked n times.verify(repo, times(1)).save(acc);
verify(mock, never()).m()Confirms method was never invoked.verify(repo, never()).delete(any());
verifyNoInteractions(mock)Confirms zero interactions occurred with the mock.verifyNoInteractions(repo);
any(Class)Matcher accepting any argument of specified type.verify(repo).save(any(Account.class));

4. Self-Assessment Quiz

Cargando cuestionario...