Testing Pyramid — Unit, Integration, End-to-End
Why Test?
You have built a blog API with authentication, file uploads, pagination, and relationships. How do you know it works? Manual testing with curl is fine for development, but it does not scale. When you change one thing, you need to retest everything — every endpoint, every edge case, every error path. Automated tests do this for you in seconds.
The Testing Pyramid
/\
/ \ End-to-End Tests
/ E2E\ Few, slow, test full system
/──────\
/ \ Integration Tests
/ Integr. \ Moderate, test component interactions
/────────────\
/ \ Unit Tests
/ Unit Tests \ Many, fast, test individual methods
/────────────────\
Unit Tests (the base) — Test a single class in isolation. Dependencies are mocked. Fast (milliseconds per test). You should have the most of these.
Integration Tests (the middle) — Test how multiple components work together. May involve a real database, Spring context, or HTTP calls. Slower (seconds per test). Fewer than unit tests.
End-to-End Tests (the top) — Test the entire system from the user’s perspective. Hit real endpoints with a real database. Slowest (seconds to minutes). Fewest in number.
What to Test in Our Blog API
| Layer | Test Type | What to Verify |
|---|---|---|
| Service | Unit | Business logic, validation rules, exception throwing |
| Repository | Integration | Custom queries, pagination, derived methods |
| Controller | Integration | HTTP status codes, request/response mapping, validation |
| Full flow | E2E | Register → login → create post → get post → delete post |
JUnit 5 & Mockito Refresher
JUnit 5 Basics
JUnit 5 is the standard testing framework for Java. It is included with spring-boot-starter-test.
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import static org.junit.jupiter.api.Assertions.*;
class CalculatorTest {
private Calculator calculator;
// Runs before EACH test method — creates a fresh instance
@BeforeEach
void setUp() {
calculator = new Calculator();
}
// @Test marks a method as a test case
@Test
@DisplayName("Adding two positive numbers returns correct sum")
void add_withPositiveNumbers_returnsSum() {
// Arrange — set up test data
int a = 3, b = 5;
// Act — call the method under test
int result = calculator.add(a, b);
// Assert — verify the result
assertEquals(8, result);
}
@Test
@DisplayName("Dividing by zero throws ArithmeticException")
void divide_byZero_throwsException() {
assertThrows(ArithmeticException.class, () -> calculator.divide(10, 0));
}
}
Common Assertions
// Value assertions
assertEquals(expected, actual); // Values are equal
assertNotEquals(unexpected, actual); // Values are not equal
assertTrue(condition); // Condition is true
assertFalse(condition); // Condition is false
assertNull(object); // Object is null
assertNotNull(object); // Object is not null
// Exception assertions
assertThrows(ExpectedType.class, () -> {
// Code that should throw
});
// Collection assertions
assertEquals(3, list.size());
assertTrue(list.contains(element));
assertTrue(list.isEmpty());
Mockito Basics
Mockito creates mock objects — fake implementations of dependencies that return controlled values. This lets you test a class without its real dependencies (no database, no HTTP, no file system).
import org.mockito.Mock;
import org.mockito.InjectMocks;
import static org.mockito.Mockito.*;
// when() — configure what a mock returns
when(mockRepository.findById(1L)).thenReturn(Optional.of(user));
// verify() — check that a method was called
verify(mockRepository, times(1)).save(any(User.class));
// any() — matches any argument
when(mockRepository.save(any(Post.class))).thenReturn(savedPost);
// never() — verify a method was NOT called
verify(mockRepository, never()).delete(any());
Unit Testing Service Layer with @Mock and @InjectMocks
Unit tests for the service layer verify business logic without touching the database.
Testing PostService
File: src/test/java/com/example/blogapi/service/PostServiceTest.java
package com.example.blogapi.service;
import com.example.blogapi.dto.CreatePostRequest;
import com.example.blogapi.dto.PostResponse;
import com.example.blogapi.exception.BadRequestException;
import com.example.blogapi.exception.ResourceNotFoundException;
import com.example.blogapi.mapper.CommentMapper;
import com.example.blogapi.mapper.PostMapper;
import com.example.blogapi.model.*;
import com.example.blogapi.repository.*;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
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.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
// @ExtendWith enables Mockito annotations (@Mock, @InjectMocks)
@ExtendWith(MockitoExtension.class)
class PostServiceTest {
// @Mock creates a mock (fake) for each dependency.
// The mock returns default values (null, 0, false) unless configured with when().
@Mock private PostRepository postRepository;
@Mock private UserRepository userRepository;
@Mock private CategoryRepository categoryRepository;
@Mock private TagRepository tagRepository;
@Mock private CommentRepository commentRepository;
@Mock private PostMapper postMapper;
@Mock private CommentMapper commentMapper;
// @InjectMocks creates a real PostService and injects the mocks above
// via constructor injection.
@InjectMocks
private PostService postService;
// Shared test data
private User testAuthor;
private Category testCategory;
private Post testPost;
private PostResponse testPostResponse;
@BeforeEach
void setUp() {
// Create test objects used across multiple tests
testAuthor = new User("alice", "alice@example.com", "$2a$10$hash");
testAuthor.setId(1L);
testAuthor.setFullName("Alice Johnson");
testAuthor.setActive(true);
testCategory = new Category("Tutorial", "tutorial");
testCategory.setId(1L);
testPost = new Post("Test Post", "test-post", "This is test content for the post.");
testPost.setId(1L);
testPost.setAuthor(testAuthor);
testPost.setCategory(testCategory);
testPost.setStatus("DRAFT");
testPostResponse = new PostResponse();
testPostResponse.setId(1L);
testPostResponse.setTitle("Test Post");
testPostResponse.setStatus("DRAFT");
}
// @Nested groups related tests together — improves readability
@Nested
@DisplayName("getPostById")
class GetPostById {
@Test
@DisplayName("should return post when found")
void whenPostExists_returnsPostResponse() {
// Arrange
when(postRepository.findByIdWithAllDetails(1L)).thenReturn(Optional.of(testPost));
when(commentRepository.countByPostId(1L)).thenReturn(5L);
when(postMapper.toResponse(testPost, 5L)).thenReturn(testPostResponse);
// Act
PostResponse result = postService.getPostById(1L);
// Assert
assertNotNull(result);
assertEquals(1L, result.getId());
assertEquals("Test Post", result.getTitle());
// Verify repository was called exactly once
verify(postRepository, times(1)).findByIdWithAllDetails(1L);
}
@Test
@DisplayName("should throw ResourceNotFoundException when not found")
void whenPostDoesNotExist_throwsException() {
// Arrange
when(postRepository.findByIdWithAllDetails(999L)).thenReturn(Optional.empty());
// Act & Assert
ResourceNotFoundException exception = assertThrows(
ResourceNotFoundException.class,
() -> postService.getPostById(999L)
);
assertTrue(exception.getMessage().contains("999"));
verify(postMapper, never()).toResponse(any(), anyLong());
}
}
@Nested
@DisplayName("createPost")
class CreatePost {
private CreatePostRequest validRequest;
@BeforeEach
void setUp() {
validRequest = new CreatePostRequest();
validRequest.setTitle("New Post Title");
validRequest.setContent("This is the content of the new post with enough characters.");
validRequest.setExcerpt("Short excerpt");
validRequest.setAuthorId(1L);
validRequest.setCategoryId(1L);
validRequest.setTagIds(List.of(1L, 2L));
}
@Test
@DisplayName("should create post with valid data")
void withValidData_createsSuccessfully() {
// Arrange
when(userRepository.findById(1L)).thenReturn(Optional.of(testAuthor));
when(categoryRepository.findById(1L)).thenReturn(Optional.of(testCategory));
when(postRepository.existsBySlug(anyString())).thenReturn(false);
Tag tag1 = new Tag("Java", "java"); tag1.setId(1L);
Tag tag2 = new Tag("Spring", "spring"); tag2.setId(2L);
when(tagRepository.findAllById(List.of(1L, 2L))).thenReturn(List.of(tag1, tag2));
when(postRepository.save(any(Post.class))).thenAnswer(invocation -> {
Post saved = invocation.getArgument(0);
saved.setId(10L);
return saved;
});
when(postMapper.toResponse(any(Post.class))).thenReturn(testPostResponse);
// Act
PostResponse result = postService.createPost(validRequest);
// Assert
assertNotNull(result);
verify(postRepository).save(any(Post.class));
verify(tagRepository).findAllById(List.of(1L, 2L));
}
@Test
@DisplayName("should throw when author not found")
void withInvalidAuthor_throwsResourceNotFoundException() {
// Arrange
when(userRepository.findById(1L)).thenReturn(Optional.empty());
// Act & Assert
assertThrows(ResourceNotFoundException.class,
() -> postService.createPost(validRequest));
verify(postRepository, never()).save(any());
}
@Test
@DisplayName("should throw when author is inactive")
void withInactiveAuthor_throwsBadRequestException() {
// Arrange
testAuthor.setActive(false);
when(userRepository.findById(1L)).thenReturn(Optional.of(testAuthor));
// Act & Assert
assertThrows(BadRequestException.class,
() -> postService.createPost(validRequest));
verify(postRepository, never()).save(any());
}
@Test
@DisplayName("should throw when tag IDs are invalid")
void withInvalidTags_throwsBadRequestException() {
// Arrange
when(userRepository.findById(1L)).thenReturn(Optional.of(testAuthor));
when(postRepository.existsBySlug(anyString())).thenReturn(false);
// Only 1 tag found out of 2 requested
Tag tag1 = new Tag("Java", "java"); tag1.setId(1L);
when(tagRepository.findAllById(List.of(1L, 2L))).thenReturn(List.of(tag1));
// Act & Assert
assertThrows(BadRequestException.class,
() -> postService.createPost(validRequest));
}
}
@Nested
@DisplayName("publishPost")
class PublishPost {
@Test
@DisplayName("should publish a draft post")
void withDraftPost_publishesSuccessfully() {
// Arrange
when(postRepository.findByIdWithDetails(1L)).thenReturn(Optional.of(testPost));
when(postMapper.toResponse(any(Post.class))).thenAnswer(invocation -> {
Post p = invocation.getArgument(0);
PostResponse resp = new PostResponse();
resp.setId(p.getId());
resp.setStatus(p.getStatus());
return resp;
});
// Act
PostResponse result = postService.publishPost(1L);
// Assert
assertEquals("PUBLISHED", result.getStatus());
}
@Test
@DisplayName("should throw when post is already published")
void withPublishedPost_throwsBadRequestException() {
// Arrange
testPost.setStatus("PUBLISHED");
when(postRepository.findByIdWithDetails(1L)).thenReturn(Optional.of(testPost));
// Act & Assert
BadRequestException exception = assertThrows(
BadRequestException.class,
() -> postService.publishPost(1L)
);
assertTrue(exception.getMessage().contains("PUBLISHED"));
}
@Test
@DisplayName("should throw when post has no category")
void withoutCategory_throwsBadRequestException() {
// Arrange
testPost.setCategory(null);
when(postRepository.findByIdWithDetails(1L)).thenReturn(Optional.of(testPost));
// Act & Assert
assertThrows(BadRequestException.class,
() -> postService.publishPost(1L));
}
}
@Nested
@DisplayName("deletePost")
class DeletePost {
@Test
@DisplayName("should delete existing post")
void whenPostExists_deletesSuccessfully() {
when(postRepository.findByIdWithDetails(1L)).thenReturn(Optional.of(testPost));
doNothing().when(postRepository).delete(testPost);
assertDoesNotThrow(() -> postService.deletePost(1L));
verify(postRepository).delete(testPost);
}
@Test
@DisplayName("should throw when post not found")
void whenPostNotFound_throwsException() {
when(postRepository.findByIdWithDetails(999L)).thenReturn(Optional.empty());
assertThrows(ResourceNotFoundException.class,
() -> postService.deletePost(999L));
verify(postRepository, never()).delete(any());
}
}
}
Running the Tests
# Run all tests
./mvnw test
# Run a specific test class
./mvnw test -Dtest=PostServiceTest
# Run a specific test method
./mvnw test -Dtest="PostServiceTest#whenPostExists_returnsPostResponse"
@DataJpaTest — Testing Repository Layer with Embedded DB
@DataJpaTest starts only the JPA-related components (entities, repositories, Hibernate) with an in-memory database — no web server, no controllers, no services.
Testing PostRepository
File: src/test/java/com/example/blogapi/repository/PostRepositoryTest.java
package com.example.blogapi.repository;
import com.example.blogapi.model.Category;
import com.example.blogapi.model.Post;
import com.example.blogapi.model.User;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
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 org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.test.context.ActiveProfiles;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
// @DataJpaTest configures an in-memory H2 database, scans for @Entity classes,
// and configures Spring Data JPA repositories. Nothing else is loaded.
@DataJpaTest
@ActiveProfiles("test") // Uses application-test.properties if it exists
class PostRepositoryTest {
// TestEntityManager provides JPA operations for setting up test data.
// It is similar to EntityManager but designed for tests.
@Autowired
private TestEntityManager entityManager;
@Autowired
private PostRepository postRepository;
private User author;
private Category category;
@BeforeEach
void setUp() {
// Create test data using TestEntityManager (bypasses repository layer)
author = new User("testuser", "test@example.com", "$2a$10$hash");
author.setFullName("Test User");
author.setRole("ROLE_AUTHOR");
author.setActive(true);
entityManager.persist(author);
category = new Category("Tutorial", "tutorial");
entityManager.persist(category);
// Create multiple posts
createPost("Spring Boot Basics", "spring-boot-basics",
"Content about Spring Boot...", "PUBLISHED");
createPost("Advanced JPA", "advanced-jpa",
"Content about JPA...", "PUBLISHED");
createPost("Draft Post", "draft-post",
"This is a draft...", "DRAFT");
createPost("Docker Guide", "docker-guide",
"Content about Docker...", "PUBLISHED");
// Flush and clear to ensure data is in the database
entityManager.flush();
entityManager.clear();
}
private Post createPost(String title, String slug, String content, String status) {
Post post = new Post(title, slug, content);
post.setAuthor(author);
post.setCategory(category);
post.setStatus(status);
return entityManager.persist(post);
}
@Test
@DisplayName("findByStatus should return only posts with matching status")
void findByStatus_returnsMatchingPosts() {
Page<Post> published = postRepository.findByStatus("PUBLISHED",
PageRequest.of(0, 10, Sort.by("createdAt").descending()));
assertThat(published.getContent()).hasSize(3);
assertThat(published.getContent())
.allMatch(post -> "PUBLISHED".equals(post.getStatus()));
}
@Test
@DisplayName("findBySlug should return the correct post")
void findBySlug_returnsCorrectPost() {
Optional<Post> result = postRepository.findBySlug("spring-boot-basics");
assertThat(result).isPresent();
assertThat(result.get().getTitle()).isEqualTo("Spring Boot Basics");
}
@Test
@DisplayName("findBySlug should return empty for non-existent slug")
void findBySlug_withNonExistentSlug_returnsEmpty() {
Optional<Post> result = postRepository.findBySlug("non-existent");
assertThat(result).isEmpty();
}
@Test
@DisplayName("searchByKeyword should find posts matching title")
void searchByKeyword_matchesTitle() {
Page<Post> results = postRepository.searchByKeyword("Spring",
PageRequest.of(0, 10));
assertThat(results.getContent()).hasSize(1);
assertThat(results.getContent().get(0).getTitle()).contains("Spring");
}
@Test
@DisplayName("searchByKeyword should find posts matching content")
void searchByKeyword_matchesContent() {
Page<Post> results = postRepository.searchByKeyword("Docker",
PageRequest.of(0, 10));
assertThat(results.getContent()).hasSize(1);
}
@Test
@DisplayName("searchByKeyword should be case-insensitive")
void searchByKeyword_isCaseInsensitive() {
Page<Post> results = postRepository.searchByKeyword("spring",
PageRequest.of(0, 10));
assertThat(results.getContent()).isNotEmpty();
}
@Test
@DisplayName("existsBySlug should return true for existing slug")
void existsBySlug_withExisting_returnsTrue() {
assertThat(postRepository.existsBySlug("spring-boot-basics")).isTrue();
}
@Test
@DisplayName("existsBySlug should return false for non-existing slug")
void existsBySlug_withNonExisting_returnsFalse() {
assertThat(postRepository.existsBySlug("non-existent")).isFalse();
}
@Test
@DisplayName("countByStatus should count correctly")
void countByStatus_returnsCorrectCount() {
assertThat(postRepository.countByStatus("PUBLISHED")).isEqualTo(3);
assertThat(postRepository.countByStatus("DRAFT")).isEqualTo(1);
assertThat(postRepository.countByStatus("ARCHIVED")).isEqualTo(0);
}
@Test
@DisplayName("pagination should return correct page")
void findByStatus_withPagination_returnsCorrectPage() {
// Request page 0 with size 2
Page<Post> page0 = postRepository.findByStatus("PUBLISHED",
PageRequest.of(0, 2));
assertThat(page0.getContent()).hasSize(2);
assertThat(page0.getTotalElements()).isEqualTo(3);
assertThat(page0.getTotalPages()).isEqualTo(2);
assertThat(page0.isFirst()).isTrue();
assertThat(page0.isLast()).isFalse();
// Request page 1
Page<Post> page1 = postRepository.findByStatus("PUBLISHED",
PageRequest.of(1, 2));
assertThat(page1.getContent()).hasSize(1);
assertThat(page1.isLast()).isTrue();
}
}
Adding H2 for Tests
Add H2 as a test dependency so @DataJpaTest can use an in-memory database:
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
Create src/test/resources/application-test.properties:
# Use H2 in-memory database for tests
spring.datasource.url=jdbc:h2:mem:testdb;MODE=MYSQL
spring.datasource.driver-class-name=org.h2.Driver
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
# Disable Flyway for tests using H2 (Flyway runs against MariaDB syntax)
spring.flyway.enabled=false
@WebMvcTest — Testing Controllers in Isolation
@WebMvcTest starts only the web layer (controllers, filters, exception handlers) without the service or repository layers. Services are mocked.
Testing PostController
File: src/test/java/com/example/blogapi/controller/PostControllerTest.java
package com.example.blogapi.controller;
import com.example.blogapi.dto.CreatePostRequest;
import com.example.blogapi.dto.PostResponse;
import com.example.blogapi.exception.GlobalExceptionHandler;
import com.example.blogapi.exception.ResourceNotFoundException;
import com.example.blogapi.security.JwtTokenProvider;
import com.example.blogapi.service.PostService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.DisplayName;
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.bean.MockBean;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.web.servlet.MockMvc;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
// Only loads PostController and its direct dependencies (exception handler, security config).
// All services are replaced with mocks.
@WebMvcTest(PostController.class)
class PostControllerTest {
@Autowired
private MockMvc mockMvc; // Simulates HTTP requests without starting a real server
@Autowired
private ObjectMapper objectMapper; // Serializes Java objects to JSON
@MockBean // Creates a mock and registers it in the Spring context
private PostService postService;
@MockBean
private JwtTokenProvider jwtTokenProvider; // Mock security dependencies
@Test
@DisplayName("GET /api/posts/{id} returns 200 when post exists")
@WithMockUser // Simulates an authenticated user
void getPost_whenExists_returns200() throws Exception {
// Arrange
PostResponse response = new PostResponse();
response.setId(1L);
response.setTitle("Test Post");
response.setStatus("PUBLISHED");
when(postService.getPostById(1L)).thenReturn(response);
// Act & Assert
mockMvc.perform(get("/api/posts/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.title").value("Test Post"))
.andExpect(jsonPath("$.status").value("PUBLISHED"));
}
@Test
@DisplayName("GET /api/posts/{id} returns 404 when post not found")
@WithMockUser
void getPost_whenNotFound_returns404() throws Exception {
when(postService.getPostById(999L))
.thenThrow(new ResourceNotFoundException("Post", "id", 999L));
mockMvc.perform(get("/api/posts/999"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.status").value(404))
.andExpect(jsonPath("$.message").value("Post not found with id: 999"));
}
@Test
@DisplayName("POST /api/posts returns 201 with valid data")
@WithMockUser(roles = "AUTHOR") // Simulate an AUTHOR user
void createPost_withValidData_returns201() throws Exception {
// Arrange
CreatePostRequest request = new CreatePostRequest();
request.setTitle("New Post Title");
request.setContent("This is the content of the new post with enough characters.");
request.setAuthorId(1L);
PostResponse response = new PostResponse();
response.setId(10L);
response.setTitle("New Post Title");
response.setStatus("DRAFT");
when(postService.createPost(any(CreatePostRequest.class))).thenReturn(response);
// Act & Assert
mockMvc.perform(post("/api/posts")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value(10))
.andExpect(jsonPath("$.title").value("New Post Title"));
}
@Test
@DisplayName("POST /api/posts returns 400 for validation errors")
@WithMockUser(roles = "AUTHOR")
void createPost_withInvalidData_returns400() throws Exception {
// Send empty request body — title and content are @NotBlank
CreatePostRequest request = new CreatePostRequest();
mockMvc.perform(post("/api/posts")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.status").value(400))
.andExpect(jsonPath("$.validationErrors.title").exists())
.andExpect(jsonPath("$.validationErrors.content").exists());
}
@Test
@DisplayName("POST /api/posts returns 401 without authentication")
void createPost_withoutAuth_returns401() throws Exception {
// No @WithMockUser — request is unauthenticated
mockMvc.perform(post("/api/posts")
.contentType(MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isUnauthorized());
}
@Test
@DisplayName("DELETE /api/posts/{id} returns 204 on success")
@WithMockUser(roles = "ADMIN")
void deletePost_returns204() throws Exception {
mockMvc.perform(delete("/api/posts/1"))
.andExpect(status().isNoContent());
}
}
@SpringBootTest — Full Integration Tests
@SpringBootTest loads the entire Spring application context — all beans, all configurations, the real database (or test database). These tests verify that all components work together correctly.
package com.example.blogapi;
import com.example.blogapi.dto.AuthResponse;
import com.example.blogapi.dto.LoginRequest;
import com.example.blogapi.dto.RegisterRequest;
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.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.ActiveProfiles;
import static org.assertj.core.api.Assertions.assertThat;
// RANDOM_PORT starts the full app on a random available port
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
class AuthIntegrationTest {
// TestRestTemplate sends real HTTP requests to the running application
@Autowired
private TestRestTemplate restTemplate;
@Test
@DisplayName("Full registration and login flow")
void registerAndLogin_fullFlow() {
// Step 1: Register
RegisterRequest registerRequest = new RegisterRequest();
registerRequest.setUsername("integrationuser");
registerRequest.setEmail("integration@example.com");
registerRequest.setPassword("securepass123");
registerRequest.setFullName("Integration User");
ResponseEntity<AuthResponse> registerResponse = restTemplate.postForEntity(
"/api/auth/register", registerRequest, AuthResponse.class);
assertThat(registerResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED);
assertThat(registerResponse.getBody()).isNotNull();
assertThat(registerResponse.getBody().getUsername()).isEqualTo("integrationuser");
// Step 2: Login
LoginRequest loginRequest = new LoginRequest();
loginRequest.setUsername("integrationuser");
loginRequest.setPassword("securepass123");
ResponseEntity<AuthResponse> loginResponse = restTemplate.postForEntity(
"/api/auth/login", loginRequest, AuthResponse.class);
assertThat(loginResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(loginResponse.getBody()).isNotNull();
assertThat(loginResponse.getBody().getAccessToken()).isNotBlank();
assertThat(loginResponse.getBody().getRefreshToken()).isNotBlank();
// Step 3: Login with wrong password
LoginRequest badLogin = new LoginRequest();
badLogin.setUsername("integrationuser");
badLogin.setPassword("wrongpassword");
ResponseEntity<String> failedLogin = restTemplate.postForEntity(
"/api/auth/login", badLogin, String.class);
assertThat(failedLogin.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
}
}
Testing with TestContainers (MariaDB)
H2 is convenient but not identical to MariaDB. Some queries, functions, or behaviors may differ. TestContainers runs a real MariaDB instance in a Docker container for tests.
Add Dependencies
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>mariadb</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
Integration Test with Real MariaDB
package com.example.blogapi.repository;
import com.example.blogapi.model.Post;
import com.example.blogapi.model.User;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.MariaDBContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest
@Testcontainers // Enables TestContainers support
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) // Don't use embedded DB
class PostRepositoryMariaDBTest {
// Start a real MariaDB container.
// TestContainers pulls the Docker image and starts the container before tests.
@Container
static MariaDBContainer<?> mariaDB = new MariaDBContainer<>("mariadb:11")
.withDatabaseName("testdb")
.withUsername("testuser")
.withPassword("testpass");
// Configure Spring to use the containerized MariaDB
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", mariaDB::getJdbcUrl);
registry.add("spring.datasource.username", mariaDB::getUsername);
registry.add("spring.datasource.password", mariaDB::getPassword);
registry.add("spring.jpa.hibernate.ddl-auto", () -> "create-drop");
registry.add("spring.flyway.enabled", () -> "false");
}
@Autowired
private PostRepository postRepository;
@Autowired
private UserRepository userRepository;
@Test
void shouldSaveAndRetrievePost_withRealMariaDB() {
// This test runs against a REAL MariaDB instance
User author = new User("testuser", "test@example.com", "hash");
author.setRole("ROLE_AUTHOR");
author.setActive(true);
author = userRepository.save(author);
Post post = new Post("MariaDB Test", "mariadb-test", "Testing with real MariaDB!");
post.setAuthor(author);
post.setStatus("PUBLISHED");
post = postRepository.save(post);
assertThat(post.getId()).isNotNull();
assertThat(postRepository.findBySlug("mariadb-test")).isPresent();
assertThat(postRepository.countByStatus("PUBLISHED")).isEqualTo(1);
}
}
TestContainers requires Docker to be running. Tests are slower than H2 (container startup takes a few seconds) but test against the actual database engine your application uses in production.
MockMvc — Simulating HTTP Requests
MockMvc Deep Dive
MockMvc simulates HTTP requests without starting a real HTTP server. It is faster than TestRestTemplate and provides detailed assertion capabilities.
// GET request with parameters
mockMvc.perform(get("/api/posts")
.param("page", "0")
.param("size", "10")
.param("status", "PUBLISHED"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content").isArray())
.andExpect(jsonPath("$.content.length()").value(10))
.andExpect(jsonPath("$.totalElements").isNumber());
// POST request with JSON body
mockMvc.perform(post("/api/posts")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"title": "Test Post",
"content": "Test content with enough characters.",
"authorId": 1
}
"""))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").exists())
.andExpect(jsonPath("$.status").value("DRAFT"));
// DELETE request
mockMvc.perform(delete("/api/posts/1"))
.andExpect(status().isNoContent())
.andExpect(content().string("")); // Empty body
// Request with JWT token header
mockMvc.perform(get("/api/posts/1")
.header("Authorization", "Bearer " + validToken))
.andExpect(status().isOk());
// File upload
mockMvc.perform(multipart("/api/posts/1/cover-image")
.file(new MockMultipartFile(
"file", "photo.jpg", "image/jpeg", imageBytes)))
.andExpect(status().isCreated());
Common JsonPath Assertions
// Field existence and value
.andExpect(jsonPath("$.id").exists())
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.title").value("Test Post"))
// Null check
.andExpect(jsonPath("$.deletedAt").doesNotExist())
// Type checks
.andExpect(jsonPath("$.id").isNumber())
.andExpect(jsonPath("$.title").isString())
.andExpect(jsonPath("$.tags").isArray())
// Array size
.andExpect(jsonPath("$.tags.length()").value(3))
// Nested objects
.andExpect(jsonPath("$.author.name").value("Alice"))
// Array element
.andExpect(jsonPath("$.tags[0]").value("Java"))
Test Data Setup with @BeforeEach and Test Fixtures
@BeforeEach for Fresh Data
Each test should be independent — one test’s data should not affect another. Use @BeforeEach to reset state:
@BeforeEach
void setUp() {
// Reset mocks between tests (Mockito does this automatically with @ExtendWith)
// Create fresh test objects
testUser = new User("alice", "alice@example.com", "hash");
testUser.setId(1L);
}
Test Fixture Builder Pattern
For complex entities, a builder pattern keeps test setup clean:
class PostTestFixture {
public static Post createDraft(User author) {
Post post = new Post("Draft Post", "draft-post", "Draft content here...");
post.setId(1L);
post.setAuthor(author);
post.setStatus("DRAFT");
return post;
}
public static Post createPublished(User author, Category category) {
Post post = createDraft(author);
post.setTitle("Published Post");
post.setSlug("published-post");
post.setStatus("PUBLISHED");
post.setCategory(category);
return post;
}
public static CreatePostRequest createValidRequest() {
CreatePostRequest request = new CreatePostRequest();
request.setTitle("Valid Post Title");
request.setContent("This is valid content that is long enough for validation.");
request.setAuthorId(1L);
return request;
}
}
Use in tests:
@Test
void publishPost_withDraft_succeeds() {
Post draft = PostTestFixture.createDraft(testAuthor);
draft.setCategory(testCategory);
when(postRepository.findByIdWithDetails(1L)).thenReturn(Optional.of(draft));
// ...
}
@Sql for Database Tests
For integration tests, pre-load data with SQL:
@Test
@Sql("/test-data/posts.sql") // Runs this SQL before the test
void findPublishedPosts_returnsCorrectCount() {
List<Post> posts = postRepository.findByStatus("PUBLISHED");
assertThat(posts).hasSize(3);
}
File: src/test/resources/test-data/posts.sql
INSERT INTO users (id, username, email, password_hash, role, is_active, created_at, updated_at)
VALUES (1, 'testuser', 'test@example.com', '$2a$10$hash', 'ROLE_AUTHOR', true, NOW(), NOW());
INSERT INTO posts (id, title, slug, content, status, author_id, created_at, updated_at)
VALUES
(1, 'Post 1', 'post-1', 'Content 1', 'PUBLISHED', 1, NOW(), NOW()),
(2, 'Post 2', 'post-2', 'Content 2', 'PUBLISHED', 1, NOW(), NOW()),
(3, 'Post 3', 'post-3', 'Content 3', 'DRAFT', 1, NOW(), NOW());
Hands-on: Write Tests for Blog API (Unit + Integration)
Run the Full Test Suite
# Run all tests
./mvnw test
# Run with verbose output
./mvnw test -Dmaven.test.failure.ignore=false
# Generate a test report
./mvnw surefire-report:report
# Open target/site/surefire-report.html in a browser
Expected Output
[INFO] Results:
[INFO]
[INFO] Tests run: 24, Failures: 0, Errors: 0, Skipped: 0
[INFO]
[INFO] BUILD SUCCESS
Exercises
- Test AuthService: Write unit tests for
register()(success, duplicate username, duplicate email) andlogin()(success, wrong password, non-existent user). - Test CommentService: Write unit tests for
addComment()(success, post not found, invalid parent comment). Test that replies are correctly linked to parent comments. - Test repository custom queries with @DataJpaTest: Test
findByAuthorId,findByStatuswith pagination (verify page metadata — totalElements, totalPages, isFirst, isLast). - Write a full integration test: Using
@SpringBootTestandTestRestTemplate, test the complete flow: register → login → create post → get post → update post → publish → delete. - Add test coverage reporting: Add the JaCoCo Maven plugin and generate a coverage report. Aim for at least 80% line coverage on the service layer.
“xml “
Summary
This lecture gave you a complete testing strategy for your Spring Boot application:
- Testing pyramid: Many fast unit tests, moderate integration tests, few end-to-end tests. Each layer verifies different things.
- Unit tests with Mockito:
@Mockcreates fakes,@InjectMockswires them into the real service. Test business logic without a database. Usewhen()/thenReturn()andverify(). - @DataJpaTest: Tests repositories with an in-memory H2 database (or TestContainers for real MariaDB). Verifies custom queries, pagination, and data integrity.
- @WebMvcTest: Tests controllers in isolation. Services are mocked with
@MockBean. UsesMockMvcto simulate HTTP requests and verify status codes, headers, and JSON responses. - @SpringBootTest: Full integration tests with all components. Uses
TestRestTemplatefor real HTTP calls. Verifies end-to-end flows. - TestContainers: Runs a real MariaDB in Docker for tests. Catches database-specific issues that H2 misses. Requires Docker.
- MockMvc: Simulates HTTP requests with precise control over method, headers, body, and parameters. JsonPath assertions verify response structure.
- Test fixtures:
@BeforeEachresets state. Builder patterns create test data cleanly.@Sqlpre-loads database data.
What is Next
In Lecture 16, we will focus on Performance Optimization — Caching & Query Tuning — identifying bottlenecks, adding cache layers with Caffeine, optimizing slow queries with EXPLAIN, database indexing strategies, and connection pool tuning.
Quick Reference
| Concept | Description |
|---|---|
@ExtendWith(MockitoExtension.class) |
Enables Mockito annotations in JUnit 5 |
@Mock |
Creates a mock object |
@InjectMocks |
Creates real object with mocks injected |
@MockBean |
Creates a mock and registers it in Spring context |
when().thenReturn() |
Configure mock return value |
verify() |
Check that a mock method was called |
@DataJpaTest |
Tests JPA repositories with embedded database |
@WebMvcTest |
Tests controllers with mocked services |
@SpringBootTest |
Full integration test with all components |
MockMvc |
Simulates HTTP requests without real server |
TestRestTemplate |
Sends real HTTP requests in integration tests |
@WithMockUser |
Simulates an authenticated user in tests |
TestContainers |
Runs real databases in Docker for tests |
@Nested |
Groups related tests for better organization |
@DisplayName |
Human-readable test name |
jsonPath() |
Asserts JSON response structure and values |
@Sql |
Runs SQL scripts before a test |
| Arrange-Act-Assert | Test structure: setup → execute → verify |
@BeforeEach |
Runs before each test method |
| JaCoCo | Code coverage reporting tool |
