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
| Criterion | Unit Testing | Integration Testing |
|---|---|---|
| Scope | Single service class or method in complete isolation. | Multiple layers (Controller + Service + Repository + DB). |
| Spring Context | No ApplicationContext loaded (instant execution). | Loads full Spring context (@SpringBootTest). |
| Dependencies | Substituted with mock objects (@Mock). | Real beans injected or in-memory DB used (H2/Testcontainers). |
| Speed | Tens of tests per second (< 100 ms). | Seconds per test suite due to context bootstrapping. |
| Objective | Business logic, edge branches, and exception paths. | Transactions, JPA mappings, SQL queries, and configuration. |
Layer Architecture and Mockito Isolation
Diagram Component Overview
@Mock(Simulated Dependency): Instantiates a mock representation ofAccountRepository. It does not execute underlying database code; it responds strictly with stubs programmed viawhen(...).@InjectMocks(Target Under Test): InstantiatesBankServiceand automatically injects all declared@Mockfields.- 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
Verify Dependencies in pom.xml
The spring-boot-starter-test artifact automatically bundles JUnit 5, Mockito, AssertJ, and Spring Test.
<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>
Implement the Service Class Under Test
Constructor injection facilitates passing mock dependencies without reflection overhead.
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;
}
}
Write Unit Tests with Mockito
Annotate the class with @ExtendWith(MockitoExtension.class). Avoid loading Spring context annotations to keep tests instantaneous.
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);
}
}
Configure an Isolated Test Profile
Create a separate properties configuration to avoid modifying local development or production databases.
# 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
Write Integration Tests with @SpringBootTest
Use @SpringBootTest alongside @ActiveProfiles("test") for end-to-end database testing.
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());
}
}
Execute Tests from the Terminal or IDE
Use the Maven Wrapper (mvnw or mvnw.cmd) to run test suites cleanly across any development environment.
- Windows (PowerShell / CMD)
- macOS & Linux (Bash / Zsh)
- From IDE (IntelliJ IDEA / VS Code)
# 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
# Ensure executable permissions on Unix systems
chmod +x mvnw
# Run all test suites
./mvnw test
# Run a specific test class
./mvnw test -Dtest=BankServiceTest
# Run a single test method
./mvnw test -Dtest=BankServiceTest#shouldTransferFundsSuccessfully
# Package while skipping tests
./mvnw package -DskipTests
IntelliJ IDEA:
Click the green run arrow next to the test class or method declaration (Ctrl + Shift + F10 on Windows, Ctrl + Shift + R on macOS).
VS Code:
Install the Extension Pack for Java and Test Runner for Java. Click "Run Test" or "Debug Test" buttons displayed directly above each @Test method.
Test Reports:
Maven outputs comprehensive Surefire XML/text reports into the target/surefire-reports/ folder.
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
| Assertion | Purpose | Example |
|---|---|---|
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
| Method | Purpose | Example |
|---|---|---|
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)); |