Testing Spring Boot Applications Demystified
Testing Spring Boot Applications Demystified
Philip Riecks
This is a Leanpub book. Leanpub empowers authors and publishers with the
Lean Publishing process. Lean Publishing is the act of publishing an
in-progress ebook using lightweight tools and many iterations to get reader
feedback, pivot until you have the right book and build traction once you do.
Stratospheric
Contents
Changelog . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 148
Introduction & Motivation
In this ebook, we’ll demystify testing Spring Boot applications with clear
explanations, practical insights, and actionable best practices.
You’ll learn to avoid common pitfalls, implement proven testing recipes, and
leverage Spring Boot’s powerful testing capabilities to write comprehensive
and effective tests.
By the end of this book, you’ll transform testing from a frustrating af-
terthought into an enjoyable daily practice that enhances your development
workflow.
Under the slogan Testing Spring Boot Applications Made Simple, Philip
provides recipes and tips & tricks to make Spring Boot developers more
Introduction & Motivation 2
Apart from blogging, he’s a course instructor for various Java-related online
courses, runs a YouTube channel, has co-authored Stratospheric - From Zero
to Production with Spring Boot on AWS and regularly visits international
conferences as a speaker.
Find out more about Philip on the Testing Spring Boot Applications Made
Simple blog and follow him on X or LinkedIn.
Introduction & Motivation 3
Effective testing isn’t just about quality assurance - it’s about creating a solid
foundation that supports continuous improvement and innovation.
prevent these issues by catching potential problems before they reach pro-
duction.
Spring Boot offers exceptional testing support that extends far beyond basic
testing tools like JUnit and Mockito.
By applying the principles and practices in this book, you’ll transform testing
from a necessary evil into a competitive advantage that enables you to deliver
higher-quality software more efficiently.
This eBook is for Java Spring Boot developers who are already familiar with
the Spring framework, Spring Boot, and Java.
It is designed for those who have been developing Spring Boot applications
for some time and are now looking to break out of potentially bad testing
patterns.
By learning what Spring Boot and Java have to offer in terms of testing, you
will become more productive and confident in your daily work.
Prerequisites
This book aims to teach you how to effectively test Spring Boot applications,
leveraging what Java and Spring Boot specifically offer in terms of testing.
Goals
Our goal is to help you expand your existing knowledge to new testing li-
braries and frameworks. By the end of this book, you will be more productive
and confident in your daily work, ultimately becoming a better developer.
Agenda
We’ll start with understanding how our build tools execute tests, explore
testing best practices, and master unit testing without any Spring context.
This chapter focuses on pure Java testing fundamentals that form the back-
bone of any well-tested Spring Boot application.
Before diving into the technical details of testing Spring Boot applications,
it’s crucial to define our testing strategy and discuss testing in general.
There are many testing strategies in the literature, such as the testing
pyramid, the testing honeycomb, or the testing trophy. Each of these models
offers a different perspective on how testing should be approached.
For example:
It’s challenging to define a universal testing strategy upfront that fits all
applications.
The literature identifies many testing types, such as unit tests, white box
tests, black box tests, integration tests, web tests, end-to-end tests, fast
Chapter 1: Testing Fundamentals in Spring Boot Projects 10
1. Unit Tests:
2. Integration Tests:
3. End-to-End Tests:
These conventions will guide our discussion and examples throughout the
book.
Chapter 1: Testing Fundamentals in Spring Boot Projects 11
When building our Spring Boot project, someone has to take care to select,
run and interpret the result of our tests. We only want to deploy a code
change if all our tests have passed.
With Java, that’s the job of the build tool. Maven and Gradle are the two most
popular build tools for Java projects.
Understanding how these tools handle tests is crucial for effective test-
ing. While Spring Boot provides excellent defaults, knowing the underlying
mechanisms helps us optimize our test execution and troubleshoot issues.
Let’s explore how both Maven and Gradle manage our tests, starting with
Maven.
This separation ensures a clear boundary between production and test code.
Dependency Scoping
1 <dependency>
2 <groupId>org.springframework.boot</groupId>
3 <artifactId>spring-boot-starter-test</artifactId>
4 <scope>test</scope>
5 </dependency>
This Maven dependency declaration adds Spring Boot’s testing starter to your
project. The <scope>test</scope> is crucial - it tells Maven this dependency is
only needed during testing, not in production. This keeps your deployed ap-
plication smaller and more secure by excluding testing libraries like Mockito,
AssertJ, and JUnit from the final JAR file.
1. Maven Surefire Plugin - Executes unit tests during the test phase
2. Maven Failsafe Plugin - Executes integration tests during the
integration-test and verify phases
• Fast feedback: Unit tests run first, failing fast if basic functionality is
broken
• Build optimization: Skip slow integration tests during development
with mvn test
• **/Test*.java
• **/*TestCase.java
• **/IT*.java
This consistency makes it immediately clear what type of test you’re looking
at.
Dependency Configuration
Chapter 1: Testing Fundamentals in Spring Boot Projects 14
1 dependencies {
2 testImplementation 'org.junit.jupiter:junit-jupiter'
3 testImplementation 'org.mockito:mockito-core'
4 testImplementation 'org.assertj:assertj-core'
5 }
1 test {
2 testLogging {
3 events "passed", "skipped", "failed"
4 exceptionFormat "full"
5 showStandardStreams = false
6 }
7 }
Both Maven and Gradle delegate test execution to a test runner. With Spring
Boot, this is JUnit 5’s Jupiter engine by default.
Maven Lifecycle:
• compile -> test (unit tests) -> package � verify (integration tests)
• Running mvn test only executes unit tests
• Running mvn verify executes both unit and integration tests
Gradle Tasks:
These principles apply to any Java project and form the foundation of a solid
testing strategy.
1 class CalculatorTest {
2 @Test
3 void shouldAddTwoNumbers() {
4 // Arrange - Set up test data
5 Calculator calculator = new Calculator();
6 int a = 5;
7 int b = 3;
8
9 // Act - Execute the method under test
10 int result = calculator.add(a, b);
11
12 // Assert - Verify the result
13 assertEquals(8, result);
14 }
15 }
Chapter 1: Testing Fundamentals in Spring Boot Projects 16
We’ll use both patterns throughout this book, as it provides a clear structure
for tests.
The best unit tests don’t require any production framework (aka. Spring).
They test pure Java code. Let’s build a price calculator to demonstrate this
principle.
These constants define our business rules: 8% tax, 10% discount for pur-
chases over $100.
This method validates inputs, calculates the subtotal, applies any discount,
then adds tax. The order matters - we discount before taxing.
Now let’s write comprehensive tests. First, test the happy path without
discount:
1 class PriceCalculatorTest {
2 private PriceCalculator calculator = new PriceCalculator();
3
4 @Test
5 void shouldCalculatePriceWithoutDiscount() {
6 // Given a purchase under the discount threshold
7 double basePrice = 20.0;
8 int quantity = 4; // Total: $80
9
10 // When calculating final price
11 double finalPrice = calculator.calculateFinalPrice(
12 basePrice, quantity);
13
14 // Then tax is applied but no discount
15 // Expected: 80 * 1.08 = 86.40
16 assertEquals(86.40, finalPrice, 0.01);
17 }
This test verifies that purchases under $100 don’t receive a discount. The
third parameter to assertEquals is the delta - we allow 1 cent difference for
floating-point precision.
1 @Test
2 void shouldApplyDiscountForLargePurchases() {
3 // Given a purchase over the discount threshold
4 double basePrice = 50.0;
5 int quantity = 3; // Total: $150
6
7 // When calculating final price
8 double finalPrice = calculator.calculateFinalPrice(
9 basePrice, quantity);
10
11 // Then both discount and tax are applied
12 // Expected: 150 * 0.9 * 1.08 = 145.80
13 assertEquals(145.80, finalPrice, 0.01);
14 }
This verifies the correct order of operations: discount first, then tax.
1 @Test
2 void shouldThrowExceptionForInvalidPrice() {
3 // Negative price should throw exception
4 assertThrows(IllegalArgumentException.class,
5 () -> calculator.calculateFinalPrice(-10, 5));
6
7 // Zero quantity should also throw exception
8 assertThrows(IllegalArgumentException.class,
9 () -> calculator.calculateFinalPrice(10, 0));
10 }
These tests run instantly because they don’t depend on any external re-
sources or frameworks.
Assertion Libraries:
Mocking Frameworks:
Additional Utilities:
1 <dependency>
2 <groupId>org.springframework.boot</groupId>
3 <artifactId>spring-boot-starter-test</artifactId>
4 <scope>test</scope>
5 </dependency>
Basic Assertions
1 class StringUtilsTest {
2 @Test
3 void testStringManipulation() {
4 // No more public methods required in JUnit 5
5 String input = " Hello World ";
6 String result = StringUtils.trimAndUpperCase(input);
7
8 // Multiple assertions
9 assertNotNull(result);
10 assertEquals("HELLO WORLD", result);
11 assertTrue(result.startsWith("HELLO"));
12 assertFalse(result.contains(" "));
13 }
14
15 @Test
16 void testMultipleAssertions() {
17 String text = "Spring Boot Testing";
18
19 // Group assertions - all are executed even if one fails
20 assertAll("text properties",
21 () -> assertEquals(19, text.length()),
22 () -> assertTrue(text.contains("Boot")),
23 () -> assertTrue(text.startsWith("Spring")),
24 () -> assertFalse(text.isEmpty())
25 );
26 }
27 }
Exception Testing
Chapter 1: Testing Fundamentals in Spring Boot Projects 21
1 class ValidationServiceTest {
2 private ValidationService validator = new ValidationService();
3
4 @Test
5 void shouldThrowExceptionForInvalidEmail() {
6 // Simple exception assertion
7 assertThrows(ValidationException.class,
8 () -> validator.validateEmail("invalid-email"));
9 }
10
11 @Test
12 void shouldValidateExceptionDetails() {
13 // Capture exception for detailed assertions
14 ValidationException exception = assertThrows(
15 ValidationException.class,
16 () -> validator.validateEmail("@example.com")
17 );
18
19 assertEquals("INVALID_EMAIL", exception.getErrorCode());
20 assertTrue(exception.getMessage().contains("missing local part"));
21 }
22 }
If you’re coming from JUnit 4, you might be familiar with @RunWith and custom
runners.
JUnit Jupiter (JUnit 5) replaces this concept with a more flexible and pow-
erful extension model. Instead of being limited to a single runner, you can
compose multiple extensions together.
1 @RunWith(MockitoJUnitRunner.class)
2 public class MyTest {
3 // Can't add Spring runner here too!
4 }
1 @ExtendWith({MockitoExtension.class, SpringExtension.class})
2 class MyTest {
3 // Both Mockito and Spring features available
4 }
This composability is crucial for real-world testing where you often need
features from multiple frameworks.
Parameter Resolution:
Test Execution:
Exception Handling:
Chapter 1: Testing Fundamentals in Spring Boot Projects 23
Here’s a practical example - a timing extension that warns about slow tests:
Chapter 1: Testing Fundamentals in Spring Boot Projects 24
1 @ExtendWith(TimingExtension.class)
2 class PerformanceTests {
3 @Test
4 void potentiallySlowTest() {
5 // Test code
6 }
7 }
The extension model is what makes JUnit 5 so powerful for integration with
modern frameworks and tools.
Parameterized Tests
Parameterized tests let us run the same test logic with different inputs. This
is one of JUnit 5’s most powerful features for reducing test duplication.
1 class EmailValidatorTest {
2 private EmailValidator validator = new EmailValidator();
3
4 @ParameterizedTest
5 @ValueSource(strings = {
6 "user@example.com",
7 "john.doe@company.org",
8 "admin+tag@domain.co.uk"
9 })
10 void shouldAcceptValidEmails(String email) {
11 assertTrue(validator.isValid(email));
12 }
13 }
This single test method runs three times, once for each email address. JUnit
automatically injects each value as the email parameter.
1 @ParameterizedTest
2 @ValueSource(strings = {
3 "invalid.email",
4 "@no-local-part.com",
5 "no-at-sign.com",
6 "multiple@@at.com"
7 })
8 void shouldRejectInvalidEmails(String email) {
9 assertFalse(validator.isValid(email));
10 }
1 @ParameterizedTest
2 @CsvSource({
3 "user@example.com, true",
4 "invalid.email, false",
5 "admin@company.org, true",
6 "@missing.com, false"
7 })
8 void shouldValidateEmailsWithExpectedResults(
9 String email, boolean expected) {
10 assertEquals(expected, validator.isValid(email));
11 }
Each line in @CsvSource provides values for both parameters. This is much
cleaner than writing four separate test methods.
JUnit 5 provides lifecycle annotations that control when setup and teardown
code runs. If you’re coming from JUnit 4, here’s the mapping:
Let’s see how to use them effectively. First, we’ll set up a resource that’s
expensive to create:
Chapter 1: Testing Fundamentals in Spring Boot Projects 27
1 class DatabaseConnectionTest {
2 private static DatabasePool pool;
3 private Connection connection;
4
5 @BeforeAll
6 static void initializePool() {
7 // Runs once before all tests
8 pool = new DatabasePool(5);
9 }
10 }
The @BeforeAll method must be static because it runs before any test instance
is created. Use this for expensive one-time setup.
1 @BeforeEach
2 void getConnection() {
3 // Runs before each test
4 connection = pool.getConnection();
5 }
@BeforeEach runs before every test method, ensuring each test has a fresh
connection. This maintains test isolation.
1 @Test
2 void testDatabaseOperation() {
3 // Use the connection
4 assertNotNull(connection);
5 assertTrue(connection.isValid());
6 }
1 @AfterEach
2 void releaseConnection() {
3 // Runs after each test
4 if (connection != null) {
5 pool.release(connection);
6 }
7 }
This cleanup runs even if the test fails, preventing resource leaks.
1 @AfterAll
2 static void closePool() {
3 // Runs once after all tests
4 if (pool != null) {
5 pool.shutdown();
6 }
7 }
Like @BeforeAll, this must be static and runs just once after all tests complete.
Nested tests help organize related test cases together. This is particularly
useful when testing different aspects of the same class.
1 class StringCalculatorTest {
2 private StringCalculator calculator = new StringCalculator();
3
4 @Nested
5 @DisplayName("When calculating sum of numbers")
6 class SumCalculation {
7 // Tests for valid calculations go here
8 }
9 }
The @Nested annotation creates an inner test class. The @DisplayName provides a
human-readable description that appears in test reports.
1 @Test
2 @DisplayName("should return 0 for empty string")
3 void emptyStringReturnsZero() {
4 assertEquals(0, calculator.add(""));
5 }
6
7 @Test
8 @DisplayName("should return number for single value")
9 void singleNumberReturnsSameValue() {
10 assertEquals(5, calculator.add("5"));
11 }
Each test in the nested class has access to the outer class’s fields. This
reduces duplication while keeping tests organized.
1 @Test
2 @DisplayName("should sum comma-separated numbers")
3 void sumsCommaSeparatedNumbers() {
4 assertEquals(6, calculator.add("1,2,3"));
5 }
1 @Nested
2 @DisplayName("When handling invalid input")
3 class ErrorHandling {
4
5 @Test
6 @DisplayName("should throw exception for negative numbers")
7 void throwsExceptionForNegativeNumbers() {
8 Exception exception = assertThrows(
9 IllegalArgumentException.class,
10 () -> calculator.add("1,-2,3")
11 );
12 assertTrue(exception.getMessage()
13 .contains("Negatives not allowed"));
14 }
15 }
This organization makes test reports much clearer. Instead of a flat list of
test methods, you see a hierarchy that reflects the behavior being tested.
Chapter 1: Testing Fundamentals in Spring Boot Projects 30
1 class ProductServiceTest {
2 private ProductService service = new ProductService();
3
4 @Test
5 void testBasicAssertions() {
6 Product product = service.createProduct("Laptop", 999.99);
7
8 // JUnit assertion
9 assertEquals("Laptop", product.getName());
10
11 // AssertJ - more fluent and readable
12 assertThat(product.getName()).isEqualTo("Laptop");
13 assertThat(product.getPrice()).isEqualTo(999.99);
14 assertThat(product.getId()).isNotNull();
15 assertThat(product.isActive()).isTrue();
16 }
17 }
String Assertions
AssertJ provides rich string assertions that make tests more expressive. Let’s
start with basic checks:
1 @Test
2 void testStringAssertions() {
3 String result = "Spring Boot Testing Guide";
4
5 assertThat(result)
6 .isNotNull()
7 .isNotEmpty()
8 .hasSize(25);
9 }
These assertions verify the string exists, has content, and has the expected
length. The method chaining makes multiple assertions readable.
1 assertThat(result)
2 .startsWith("Spring")
3 .endsWith("Guide")
4 .contains("Boot", "Testing")
5 .doesNotContain("JUnit");
The contains method accepts multiple values and checks that all are present.
This is cleaner than multiple separate assertions.
1 assertThat(result)
2 .matches(".*Boot.*")
3 .isEqualToIgnoringCase("spring boot testing guide");
Collection Assertions
AssertJ excels at collection assertions. Let’s start with a simple list of num-
bers:
1 @Test
2 void testCollectionAssertions() {
3 List<Integer> numbers = List.of(1, 2, 3, 4, 5);
4
5 assertThat(numbers)
6 .hasSize(5)
7 .contains(1, 3, 5)
8 .containsExactly(1, 2, 3, 4, 5);
9 }
checks that specified elements are present (in any order), while
containsExactly verifies all elements in the exact order.
1 assertThat(numbers)
2 .containsSequence(2, 3, 4)
3 .doesNotContain(0, 6);
1 assertThat(numbers)
2 .allMatch(n -> n > 0)
3 .anyMatch(n -> n % 2 == 0)
4 .noneMatch(n -> n > 10);
These read like English: all numbers are positive, at least one is even, and
none exceed 10.
1 @Test
2 void testObjectCollectionAssertions() {
3 List<Person> team = List.of(
4 new Person("Alice", 30),
5 new Person("Bob", 25),
6 new Person("Charlie", 35)
7 );
8
9 assertThat(team)
10 .extracting(Person::getName)
11 .containsExactlyInAnyOrder("Bob", "Alice", "Charlie");
12 }
This extracts just the names for assertion, making the test more focused and
readable.
1 assertThat(team)
2 .extracting(Person::getName, Person::getAge)
3 .containsExactly(
4 tuple("Alice", 30),
5 tuple("Bob", 25),
6 tuple("Charlie", 35)
7 );
The tuple method groups multiple values for comparison. This is cleaner than
writing separate assertions for each property.
Map Assertions
1 @Test
2 void testMapAssertions() {
3 Map<String, Integer> inventory = Map.of(
4 "apples", 10,
5 "bananas", 5,
6 "oranges", 8
7 );
8
9 assertThat(inventory)
10 .hasSize(3)
11 .containsKey("apples")
12 .containsKeys("bananas", "oranges")
13 .doesNotContainKey("grapes")
14 .containsEntry("apples", 10)
15 .containsValue(5);
16 }
Custom Assertions
Chapter 1: Testing Fundamentals in Spring Boot Projects 34
1 @Test
2 void testCustomAssertions() {
3 // Using satisfies for complex assertions
4 Order order = new Order("ORD-123", 150.00);
5 order.addItem("Laptop", 1, 100.00);
6 order.addItem("Mouse", 2, 25.00);
7
8 assertThat(order)
9 .satisfies(o -> {
10 assertThat(o.getId()).startsWith("ORD-");
11 assertThat(o.getTotalAmount()).isEqualTo(150.00);
12 assertThat(o.getItems()).hasSize(2);
13 assertThat(o.getStatus()).isEqualTo(OrderStatus.PENDING);
14 });
15 }
Mockito is essential for creating test doubles to isolate the unit under test.
Let’s explore how to use it effectively.
Basic Mocking
1 class OrderServiceTest {
2
3 @Test
4 void testWithManualMocks() {
5 // Create mock objects
6 PaymentGateway paymentGateway = mock(PaymentGateway.class);
7 EmailService emailService = mock(EmailService.class);
The mock() method creates a fake implementation that we can control. These
mocks have no real behavior until we define it.
1 // Define behavior
2 when(paymentGateway.processPayment(100.0, "VISA"))
3 .thenReturn(new PaymentResult(true, "TXN-123"));
This tells the mock: “when processPayment is called with these specific
arguments, return this result”.
The service uses our mocked dependencies, completely isolated from real
implementations.
1 // Verify interactions
2 verify(paymentGateway).processPayment(100.0, "VISA");
3 verify(emailService).sendOrderConfirmation(any());
The verify() method checks that methods were called with expected argu-
ments. The any() matcher accepts any argument of the correct type.
1 // Assert result
2 assertThat(order.getTransactionId()).isEqualTo("TXN-123");
3 assertThat(order.getStatus()).isEqualTo(OrderStatus.CONFIRMED);
Mockito annotations make tests cleaner. First, enable Mockito with the
extension:
Chapter 1: Testing Fundamentals in Spring Boot Projects 36
1 @ExtendWith(MockitoExtension.class)
2 class OrderServiceAnnotationTest {
3
4 @Mock
5 private PaymentGateway paymentGateway;
6
7 @Mock
8 private EmailService emailService;
9
10 @InjectMocks
11 private OrderService orderService;
The @Mock annotation creates mocks, while @InjectMocks creates the service and
automatically injects the mocks into it. This eliminates boilerplate code.
1 @Test
2 void shouldProcessOrderSuccessfully() {
3 // Given
4 when(paymentGateway.processPayment(anyDouble(), anyString()))
5 .thenReturn(new PaymentResult(true, "TXN-456"));
6 }
The anyDouble() and anyString() matchers accept any value of their type. This
makes tests less brittle when exact values don’t matter.
1 // When
2 Order order = orderService.placeOrder(50.0, "MASTERCARD");
3
4 // Then
5 assertThat(order.isConfirmed()).isTrue();
6 verify(emailService).sendOrderConfirmation(order);
1 @Test
2 void shouldHandlePaymentFailure() {
3 // Given - payment fails
4 when(paymentGateway.processPayment(anyDouble(), anyString()))
5 .thenReturn(new PaymentResult(false, null));
6 }
1 verify(emailService, never()).sendOrderConfirmation(any());
The never() verification ensures a method wasn’t called - crucial for testing
error paths.
1 @Test
2 void testConsecutiveCalls() {
3 when(random.nextInt(100))
4 .thenReturn(42) // First call
5 .thenReturn(17) // Second call
6 .thenReturn(99); // Third call
7 }
1 assertEquals(42, random.nextInt(100));
2 assertEquals(17, random.nextInt(100));
3 assertEquals(99, random.nextInt(100));
Throwing Exceptions
1 @Test
2 void testExceptionThrowing() {
3 when(userRepository.findById(999L))
4 .thenThrow(new UserNotFoundException("User not found"));
5 }
1 assertThrows(UserNotFoundException.class,
2 () -> userService.getUser(999L));
Argument Captors
1 @Test
2 void testArgumentCapture() {
3 ArgumentCaptor<Email> emailCaptor =
4 ArgumentCaptor.forClass(Email.class);
5 }
The capture() method grabs the argument passed to sendEmail(). Now inspect
it:
This ensures the email was constructed correctly with user data.
Spies wrap real objects, allowing you to override specific methods while
keeping others real:
1 @Test
2 void testWithSpy() {
3 // Create a real object
4 List<String> list = new ArrayList<>();
5 List<String> spyList = spy(list);
6 }
Now get() uses the real implementation, but size() returns our stubbed value:
Chapter 1: Testing Fundamentals in Spring Boot Projects 40
1 @Test
2 void testWithAnswer() {
3 when(calculator.add(anyInt(), anyInt()))
4 .thenAnswer(invocation -> {
5 int a = invocation.getArgument(0);
6 int b = invocation.getArgument(1);
7 return a + b;
8 });
9 }
The lambda receives the method invocation and can access all arguments.
This mock now behaves like a real calculator:
Let’s apply everything we’ve learned to write comprehensive unit tests for a
realistic service:
1 import org.springframework.stereotype.Service;
2
3 @Service
4 public class ShoppingCart {
5 private final Map<String, CartItem> items = new HashMap<>();
6 private final PricingService pricingService;
7
8 public ShoppingCart(PricingService pricingService) {
9 this.pricingService = pricingService;
10 }
11 }
The cart depends on a PricingService for product prices. This dependency will
be mocked in tests.
The merge method elegantly handles both new items and quantity updates. If
the product exists, quantities are combined.
Removal is straightforward:
Now let’s write comprehensive tests. First, set up the test class:
1 @ExtendWith(MockitoExtension.class)
2 class ShoppingCartTest {
3
4 @Mock
5 private PricingService pricingService;
6
7 @InjectMocks
8 private ShoppingCart cart;
We mock the PricingService and inject the mock into our ShoppingCart instance.
This allows us to isolate the cart’s logic from external dependencies.
Now let’s test adding items using nested classes for organization:
Chapter 1: Testing Fundamentals in Spring Boot Projects 43
1 @Nested
2 @DisplayName("Adding items to cart")
3 class AddingItems {
4
5 @Test
6 @DisplayName("should add single item successfully")
7 void addSingleItem() {
8 // When
9 cart.addItem("PROD-001", 2);
10
11 // Then
12 assertThat(cart.getItemCount()).isEqualTo(2);
13 }
14 }
This simple test verifies basic functionality. No mocking needed since we’re
not calculating prices.
1 @Test
2 @DisplayName("should accumulate quantities for same product")
3 void accumulateQuantities() {
4 // When
5 cart.addItem("PROD-001", 2);
6 cart.addItem("PROD-001", 3);
7
8 // Then
9 assertThat(cart.getItemCount()).isEqualTo(5);
10 }
This verifies our merge logic combines quantities for the same product.
1 @Test
2 @DisplayName("should reject negative quantities")
3 void rejectNegativeQuantity() {
4 // When & Then
5 assertThrows(IllegalArgumentException.class,
6 () -> cart.addItem("PROD-001", -1));
7 }
1 @ParameterizedTest
2 @ValueSource(ints = {0, -1, -10})
3 @DisplayName("should reject non-positive quantities")
4 void rejectInvalidQuantities(int quantity) {
5 assertThrows(IllegalArgumentException.class,
6 () -> cart.addItem("PROD-001", quantity));
7 }
1 @Nested
2 @DisplayName("Calculating totals")
3 class CalculatingTotals {
4
5 @Test
6 @DisplayName("should calculate total for single item")
7 void calculateSingleItemTotal() {
8 // Given
9 when(pricingService.getPrice("PROD-001"))
10 .thenReturn(10.0);
11 }
12 }
We stub the pricing service to return a known price. This isolates our test
from external dependencies.
Chapter 1: Testing Fundamentals in Spring Boot Projects 45
1 // When
2 cart.addItem("PROD-001", 3);
3 double total = cart.calculateTotal();
4
5 // Then
6 assertThat(total).isEqualTo(30.0);
7 verify(pricingService).getPrice("PROD-001");
The verification ensures our service was called correctly. The math is simple:
3 items × $10 = $30.
1 @Test
2 @DisplayName("should calculate total for multiple items")
3 void calculateMultipleItemsTotal() {
4 // Given
5 when(pricingService.getPrice("PROD-001"))
6 .thenReturn(10.0);
7 when(pricingService.getPrice("PROD-002"))
8 .thenReturn(25.0);
9 }
1 // When
2 cart.addItem("PROD-001", 2);
3 cart.addItem("PROD-002", 1);
4 double total = cart.calculateTotal();
5
6 // Then
7 assertThat(total).isEqualTo(45.0);
1 @Test
2 @DisplayName("should return zero for empty cart")
3 void emptyCartTotal() {
4 // When
5 double total = cart.calculateTotal();
6
7 // Then
8 assertThat(total).isEqualTo(0.0);
9 verifyNoInteractions(pricingService);
10 }
verifyNoInteractions ensures the pricing service isn’t called for an empty cart -
an important optimization check.
Finally, test item removal at the class level:
1 @Test
2 @DisplayName("should remove items from cart")
3 void removeItems() {
4 // Given
5 cart.addItem("PROD-001", 5);
6 cart.addItem("PROD-002", 3);
1 // When
2 cart.removeItem("PROD-001");
3
4 // Then
5 assertThat(cart.getItemCount()).isEqualTo(3);
After removing the first product (5 items), only the second product’s 3 items
remain. This verifies complete removal, not just quantity reduction.
1 // Bad
2 @Test
3 void test1() { }
4
5 // Good
6 @Test
7 void shouldThrowExceptionWhenQuantityIsNegative() { }
8
9 // Better - using DisplayName
10 @Test
11 @DisplayName("should throw IllegalArgumentException when quantity is negative")
12 void negativeQuantityValidation() { }
Each test should verify a single behavior. Here’s what not to do:
This test does too much: creates, updates, and deletes a user. If it fails, which
operation caused the problem?
Each test has a single reason to fail, making debugging much easier.
Test data builders reduce duplication and make tests more readable. First,
create the builder:
1 class UserTestDataBuilder {
2 private String name = "Default Name";
3 private String email = "default@example.com";
4 private int age = 25;
1 // Usage in tests
2 @Test
3 void testWithBuilder() {
4 User youngUser = new UserTestDataBuilder()
5 .withAge(18)
6 .build();
7 }
This approach makes tests concise and highlights what’s important for each
test case.
This tests Java’s basic functionality, not your logic. It adds no value.
Summary
• Maven uses Surefire for unit tests and Failsafe for integration tests
• Gradle provides similar separation with custom test tasks
• Following naming conventions (*Test.java and *IT.java) ensures proper
test detection
Testing Toolkit:
In the next chapter, we’ll explore Spring Boot’s powerful test slicing annota-
tions that let us test specific layers of our application in isolation.
Remember: the best tests are those that give us confidence in our code
without being brittle or slow.
Start with pure unit tests wherever possible, and only add complexity when
truly needed.
Chapter 2: Testing with a Sliced
Application Context
One of Spring Boot’s most powerful testing features is the ability to test
“slices” of our application.
Instead of loading the entire application context, we can focus on testing spe-
cific layers in isolation, resulting in faster tests and clearer test intentions.
When we write tests for our Spring Boot applications, we often don’t need
the entire application context.
Spring Boot’s test slices load only the relevant parts of the application
context for specific testing scenarios.
Spring Boot provides numerous test slice annotations, each targeting spe-
cific layers:
Chapter 2: Testing with a Sliced Application Context 55
The @WebMvcTest annotation is perhaps the most commonly used test slice,
allowing us to test Spring MVC controllers efficiently.
Before we dive into using @WebMvcTest, it’s important to understand why tradi-
tional unit testing of controllers falls short.
This is why Spring Boot provides @WebMvcTest - to test controllers with the
necessary web infrastructure while still keeping tests focused and fast.
Let’s start with a typical Spring MVC controller. First, we’ll define the con-
troller class with its dependency:
1 @Controller
2 public class BookshelfController {
3 private final BookshelfService bookshelfService;
4
5 public BookshelfController(BookshelfService bookshelfService) {
6 this.bookshelfService = bookshelfService;
7 }
8 }
1 @GetMapping("/books")
2 public String listBooks(Model model) {
3 model.addAttribute("books", bookshelfService.findAllBooks());
4 return "listBooks";
5 }
This method handles GET requests to /books, adds the book list to the model,
and returns the view name. Spring will resolve this to a template file.
1 @GetMapping("/books/add")
2 public String showAddBookForm() {
3 return "addBook";
4 }
This simply returns the view name for the add book form.
1 @PostMapping("/books/add")
2 public String addBook(
3 @RequestParam String title,
4 @RequestParam String author,
5 @RequestParam String isbn,
6 @RequestParam(required = false) String genre,
7 @RequestParam(required = false) String description) {
8
9 bookshelfService.addBook(title, author, isbn, genre, description);
10 return "redirect:/books";
11 }
This method accepts form parameters, calls the service to add the book, and
redirects to the book list. The required = false parameters are optional.
1 @WebMvcTest(BookshelfController.class)
2 class BookshelfControllerTest {
3 @Autowired
4 private MockMvc mockMvc;
5
6 @MockBean
7 private BookshelfService bookshelfService;
8 }
The @WebMvcTest annotation loads only the web layer components needed
to test our controller. MockMvc allows us to perform HTTP requests without
starting a real server, while @MockBean creates a mock of our service.
1 @Test
2 void shouldListAllBooks() throws Exception {
3 // Given - prepare test data
4 List<Book> books = List.of(
5 new Book("Effective Java", "Joshua Bloch", "978-0134685991"),
6 new Book("Clean Code", "Robert Martin", "978-0132350884")
7 );
8 when(bookshelfService.findAllBooks()).thenReturn(books);
9 }
We create test data and configure our mock service to return it when
findAllBooks() is called.
This performs a GET request to /books and verifies: the HTTP status is 200 OK,
the correct view name is returned, and the model contains our book list.
1 @Test
2 void shouldShowAddBookForm() throws Exception {
3 // When & Then
4 mockMvc.perform(get("/books/add"))
5 .andExpect(status().isOk())
6 .andExpect(view().name("addBook"));
7 }
This endpoint doesn’t interact with the service, so we only verify the re-
sponse status and view name.
Form submissions require testing POST requests with parameters. Let’s start
by testing a successful book addition:
1 @Test
2 void shouldAddNewBook() throws Exception {
3 // Given - prepare mock response
4 Book newBook = new Book("Clean Code", "Robert Martin", "978-0132350884");
5 newBook.setId(1L);
6
7 when(bookshelfService.addBook(anyString(), anyString(), anyString()))
8 .thenReturn(newBook);
9 }
We configure the mock to return a book when addBook is called with any string
parameters.
The param() method simulates form fields. We expect a redirect status (3xx)
and verify the redirect URL.
This ensures our controller passed the correct values to the service.
1 @Test
2 void shouldShowValidationErrors() throws Exception {
3 // When & Then - empty title should trigger validation
4 mockMvc.perform(post("/books/add")
5 .param("title", "")
6 .param("author", "Robert Martin")
7 .param("isbn", "978-0132350884"))
8 .andExpect(status().isOk())
9 .andExpect(view().name("addBook"))
10 .andExpect(model().attributeHasFieldErrors("book", "title"));
With an empty title, we expect the form to be redisplayed (status 200 OK)
with validation errors.
The never() verification ensures that when validation fails, the service
method isn’t invoked.
Chapter 2: Testing with a Sliced Application Context 62
REST API testing requires handling JSON content and HTTP status codes.
1 @Nested
2 @DisplayName("POST /api/books endpoint tests")
3 class CreateBookTests {
Nested classes help organize related tests together, making test reports more
readable.
1 @Test
2 @DisplayName("Should return 201 Created when valid book data is provided")
3 void shouldReturnCreatedWhenValidBookData() throws Exception {
Java’s text blocks (triple quotes) make JSON more readable in tests.
1 when(bookService.createBook(any())).thenReturn(1L);
1 mockMvc.perform(post("/api/books")
2 .contentType(MediaType.APPLICATION_JSON)
3 .content(validBookJson))
4 .andExpect(status().isCreated())
5 .andExpect(header().exists("Location"))
6 .andExpect(header().string("Location",
7 Matchers.containsString("/api/books/1")));
We verify: HTTP 201 Created status, presence of Location header, and that
the Location contains the new book’s URL.
1 @Test
2 @DisplayName("Should return 400 Bad Request when invalid book data is provided")
3 void shouldReturnBadRequestWhenInvalidBookData() throws Exception {
4 String invalidBookJson = """
5 {
6 "isbn": "",
7 "title": "",
8 "author": "Test Author",
9 "publishedDate": "2025-01-01"
10 }
11 """;
1 mockMvc.perform(post("/api/books")
2 .contentType(MediaType.APPLICATION_JSON)
3 .content(invalidBookJson))
4 .andExpect(status().isBadRequest());
Validation should prevent the request from reaching the service layer.
1 @Test
2 @DisplayName("Should return 400 Bad Request when publishedDate is in the future")
3 void shouldReturnBadRequestWhenPublishedDateInFuture() throws Exception {
4 LocalDate futureDate = LocalDate.now().plusDays(1);
1 mockMvc.perform(post("/api/books")
2 .contentType(MediaType.APPLICATION_JSON)
3 .content(futureDateJson))
4 .andExpect(status().isBadRequest());
5
6 verify(bookService, times(0)).createBook(any());
Books can’t have future publication dates, so this should return 400 Bad
Request.
Controllers often use path variables and query parameters. Let’s test a search
endpoint:
Chapter 2: Testing with a Sliced Application Context 65
1 @Test
2 void shouldSearchBooksByTitle() throws Exception {
3 // Given - prepare search results
4 List<Book> searchResults = List.of(
5 new Book("Clean Code", "Robert Martin", "978-0132350884"),
6 new Book("Clean Architecture", "Robert Martin", "978-0134494166")
7 );
8 }
We create test data representing books that match our search query.
1 when(bookshelfService.searchBooks("clean")).thenReturn(searchResults);
The service will return our test books when searching for “clean”.
1 .andExpect(model().attribute("books", searchResults))
2 .andExpect(model().attribute("query", "clean"));
The controller should pass both the search results and the original query to
the view.
1 @Test
2 void shouldHandleBookNotFound() throws Exception {
3 // Given - service throws exception
4 when(bookshelfService.findById(99L))
5 .thenThrow(new BookNotFoundException("Book with id 99 not found"));
6 }
The controller should catch the exception and display an error page with the
message. Note the status is still 200 OK because we’re returning an error
view, not an error status.
1 @Test
2 void shouldHandleInvalidBookId() throws Exception {
3 // When & Then
4 mockMvc.perform(get("/books/invalid"))
5 .andExpect(status().isBadRequest());
6 }
MVC controllers often prepare complex models for view rendering. Let’s test
a book detail page:
Chapter 2: Testing with a Sliced Application Context 67
1 @Test
2 void shouldDisplayBookDetails() throws Exception {
3 // Given - create a detailed book object
4 Book book = new Book("Clean Code", "Robert Martin", "978-0132350884");
5 book.setId(1L);
6 book.setDescription("A handbook of agile software craftsmanship");
7 }
We create a book with all details that the view might display.
1 when(bookshelfService.findById(1L)).thenReturn(book);
1 .andExpect(model().attribute("book", book))
2 .andExpect(model().attributeExists("relatedBooks"));
We verify both that the book is in the model and that other expected at-
tributes exist.
1 @Test
2 void shouldDisplayPaginatedBookList() throws Exception {
3 // Given - create 25 test books
4 List<Book> books = IntStream.range(1, 26)
5 .mapToObj(i -> new Book("Book " + i, "Author " + i, "ISBN-" + i))
6 .collect(Collectors.toList());
7 }
1 when(bookshelfService.findAllBooks(0, 10))
2 .thenReturn(books.subList(0, 10));
1 .andExpect(model().attributeExists("books"))
2 .andExpect(model().attributeExists("currentPage"))
3 .andExpect(model().attributeExists("totalPages"));
The controller should provide pagination metadata for the view to render
page navigation.
When controllers are secured, we need to include security in our tests. First,
set up the test with security configuration:
Chapter 2: Testing with a Sliced Application Context 69
1 @WebMvcTest(BookController.class)
2 @Import(SecurityConfig.class)
3 class BookControllerTest {
4
5 }
6 @Autowired
7 private MockMvc mockMvc;
8
9 @MockBean
10 private BookService bookService;
1 @Nested
2 @DisplayName("DELETE /api/books/{id} endpoint tests")
3 class DeleteBookTests {
Nested classes group related tests, making test reports more readable. This
inner class will contain all DELETE endpoint tests, clearly showing which
security scenarios we’re covering.
Test 1: No authentication
1 @Test
2 @DisplayName("Should return 401 when no authentication")
3 void shouldReturnUnauthorizedWhenNoAuthentication()
4 throws Exception {
5
6 mockMvc.perform(delete("/api/books/1"))
7 .andExpect(status().isUnauthorized());
1 <dependency>
2 <groupId>org.springframework.security</groupId>
3 <artifactId>spring-security-test</artifactId>
4 <scope>test</scope>
5 </dependency>
1 verify(bookService, times(0)).deleteBook(any());
1 @Test
2 @WithMockUser(roles = "USER")
3 @DisplayName("Should return 403 with insufficient privileges")
4 void shouldReturnForbiddenWhenInsufficientPrivileges()
5 throws Exception {
1 mockMvc.perform(delete("/api/books/1"))
2 .andExpect(status().isForbidden());
3
4 verify(bookService, times(0)).deleteBook(any());
403 (Forbidden) differs from 401 - the user is authenticated but not autho-
rized. Again, the service isn’t called, confirming authorization is enforced
before business logic.
1 @Test
2 @WithMockUser(roles = "ADMIN")
3 @DisplayName("Should return 204 when admin deletes book")
4 void shouldReturnNoContentWhenAdminAndBookExists()
5 throws Exception {
Now we test the happy path - an admin user who should have access. The
ADMIN role passes security checks.
1 when(bookService.deleteBook(1L)).thenReturn(true);
2
3 mockMvc.perform(delete("/api/books/1"))
4 .andExpect(status().isNoContent());
The service returns true indicating successful deletion. 204 (No Content) is
the standard HTTP status for successful DELETE operations with no response
body.
1 verify(bookService, times(1)).deleteBook(1L);
Unlike the previous tests, this verifies the service WAS called exactly once.
Security passed, so business logic executed.
1 @Test
2 @WithMockUser(roles = "ADMIN")
3 @DisplayName("Should return 404 when book doesn't exist")
4 void shouldReturnNotFoundWhenBookDoesNotExist()
5 throws Exception {
1 when(bookService.deleteBook(999L)).thenReturn(false);
2
3 mockMvc.perform(delete("/api/books/999"))
4 .andExpect(status().isNotFound());
5
6 verify(bookService, times(1)).deleteBook(999L);
Let’s start with a typical Spring Data JPA repository. First, the basic structure:
Chapter 2: Testing with a Sliced Application Context 73
1 @Repository
2 public interface BookRepository extends JpaRepository<Book, Long> {
3 Optional<Book> findByIsbn(String isbn);
4
5 List<Book> findByAuthorContainingIgnoreCase(String author);
6 }
These are derived query methods. Spring Data generates the SQL
based on method names. findByIsbn creates an exact match query, while
ContainingIgnoreCase performs a case-insensitive partial match.
This custom query searches both title and author fields. The LOWER() function
ensures case-insensitive matching. The Page return type enables pagination
support.
1 @Modifying
2 @Query("UPDATE Book b SET b.borrowCount = b.borrowCount + 1 WHERE b.id = :id")
3 void incrementBorrowCount(@Param("id") Long id);
The @Modifying annotation tells Spring this query changes data. Without it,
Spring assumes SELECT queries and will throw an exception. This query
atomically increments a counter, avoiding race conditions.
ensures that all tests start with a clean database state, eliminating the need
to manually delete test data between tests.
1 @DataJpaTest
2 class BookRepositoryTest {
3
4 @Autowired
5 private TestEntityManager entityManager;
6
7 @Autowired
8 private BookRepository bookRepository;
9 }
1 @Test
2 void shouldFindBookByIsbn() {
3 // Given - create test data
4 Book book = new Book();
5 book.setTitle("Effective Java");
6 book.setAuthor("Joshua Bloch");
7 book.setIsbn("978-0134685991");
8 }
We create a book entity with all required fields. The ISBN follows the real
format for books.
1 entityManager.persistAndFlush(book);
1 // When
2 Optional<Book> found = bookRepository.findByIsbn("978-0134685991");
3
4 // Then
5 assertThat(found).isPresent();
6 assertThat(found.get().getTitle()).isEqualTo("Effective Java");
We verify both that a book was found and that it’s the correct book. The test
is automatically rolled back after completion, keeping tests isolated.
Using TestEntityManager
1 @Test
2 void shouldFindBooksByAuthor() {
3 // Given - create books by different authors
4 Book book1 = new Book("Clean Code", "Robert C. Martin", "978-0132350884");
5 Book book2 = new Book("The Clean Coder", "Robert C. Martin", "978-0137081073");
6 Book book3 = new Book("Effective Java", "Joshua Bloch", "978-0134685991");
7
8 }
1 entityManager.persist(book1);
2 entityManager.persist(book2);
3 entityManager.persist(book3);
4 entityManager.flush();
The persist() method stages entities for insertion. The flush() forces Hiber-
nate to execute the SQL immediately.
1 // When
2 List<Book> martinBooks = bookRepository
3 .findByAuthorContainingIgnoreCase("martin");
1 // Then
2 assertThat(martinBooks).hasSize(2);
3 assertThat(martinBooks).extracting(Book::getTitle)
4 .containsExactlyInAnyOrder("Clean Code", "The Clean Coder");
Let’s test our custom JPQL search query. First, create test data:
1 @Test
2 void shouldSearchBooksAcrossTitleAndAuthor() {
3 // Given - books with "Spring" in title
4 entityManager.persist(new Book("Spring in Action",
5 "Craig Walls", "978-1617294945"));
6 entityManager.persist(new Book("Spring Boot in Action",
7 "Craig Walls", "978-1617292545"));
8 entityManager.persist(new Book("Effective Java",
9 "Joshua Bloch", "978-0134685991"));
10 entityManager.flush();
11 }
Two books have “Spring” in the title, one doesn’t. This tests our search
functionality.
1 // When
2 Page<Book> results = bookRepository.searchBooks("spring",
3 PageRequest.of(0, 10));
1 // Then
2 assertThat(results.getTotalElements()).isEqualTo(2);
3 assertThat(results.getContent()).extracting(Book::getTitle)
4 .containsExactlyInAnyOrder("Spring in Action",
5 "Spring Boot in Action");
The Page object contains both the results and metadata like total count.
1 @Test
2 void shouldIncrementBorrowCount() {
3 // Given - book with initial borrow count
4 Book book = new Book("Test Book", "Test Author", "123-456");
5 book.setBorrowCount(5);
6 Book saved = entityManager.persistAndFlush(book);
7 }
1 // When
2 bookRepository.incrementBorrowCount(saved.getId());
3 entityManager.flush();
4 entityManager.clear(); // Clear persistence context
1 // Then
2 Book updated = entityManager.find(Book.class, saved.getId());
3 assertThat(updated.getBorrowCount()).isEqualTo(6);
Without clear(), we’d get the cached entity with the old value. Now we force
a fresh database read to verify the update worked.
Introduction to Testcontainers
Why Testcontainers?
1. Production Parity: Test against the same database version you use in
production
2. No Setup Required: Containers start automatically and are cleaned up
after tests
3. Isolation: Each test can have its own container instance
4. Cross-Platform: Works on any system that supports Docker
1 <dependency>
2 <groupId>org.testcontainers</groupId>
3 <artifactId>testcontainers</artifactId>
4 <version>1.19.0</version>
5 <scope>test</scope>
6 </dependency>
1 <dependency>
2 <groupId>org.testcontainers</groupId>
3 <artifactId>postgresql</artifactId>
4 <version>1.19.0</version>
5 <scope>test</scope>
6 </dependency>
Testcontainers in Action
1 @DataJpaTest
2 @Testcontainers
3 @AutoConfigureTestDatabase(replace =
4 AutoConfigureTestDatabase.Replace.NONE)
5 class BookRepositoryTest {
1 @Container
2 @ServiceConnection
3 static PostgreSQLContainer<?> postgres =
4 new PostgreSQLContainer<>("postgres:16-alpine")
5 .withDatabaseName("testdb")
6 .withUsername("test")
7 .withPassword("test");
1 @Autowired
2 private BookRepository cut;
The variable name cut (Class Under Test) clearly identifies what we’re testing.
1 @Nested
2 @DisplayName("findByIsbn tests")
3 class FindByIsbnTests {
1 @Test
2 @DisplayName("Should find book by ISBN when it exists")
3 void shouldFindBookByIsbnWhenExists() {
4 // Arrange
5 String isbn = "9781234567890";
6 Book book = new Book(isbn, "Test Book",
7 "Test Author", LocalDate.now());
8 }
We use descriptive test names with @DisplayName. The ISBN follows the real for-
mat (13 digits starting with 978 or 979). Using LocalDate.now() for publication
date is fine in tests where the exact date doesn’t matter.
1 book.setStatus(BookStatus.AVAILABLE);
2 book.setThumbnailUrl("https://example.com/cover.jpg");
3 cut.save(book);
We set up a complete book object with all properties to ensure our repository
handles full entities correctly. The save() method returns the persisted entity
with generated ID, though we don’t need it here.
1 // Act
2 Optional<Book> result = cut.findByIsbn(isbn);
3
4 // Assert
5 assertThat(result).isPresent();
1 assertThat(result.get())
2 .satisfies(foundBook -> {
3 assertThat(foundBook.getIsbn()).isEqualTo(isbn);
4 assertThat(foundBook.getTitle()).isEqualTo("Test Book");
5 assertThat(foundBook.getStatus())
6 .isEqualTo(BookStatus.AVAILABLE);
7 });
The satisfies() method groups related assertions on the same object. If any
assertion fails, you’ll see exactly which property was wrong. This is cleaner
than multiple separate assertions and provides better error messages than
comparing the entire object.
1 @Test
2 @DisplayName("Should return empty when book doesn't exist")
3 void shouldReturnEmptyWhenBookDoesNotExist() {
4 // Arrange - Save a different book
5 Book book = new Book("9781234567890", "Test Book",
6 "Test Author", LocalDate.now());
7 cut.save(book);
We save one book to ensure the repository isn’t just returning empty because
the table is empty. This makes the test more robust - it verifies the query
correctly filters by ISBN.
1 // Act
2 Optional<Book> result = cut.findByIsbn("9780987654321");
3
4 // Assert
5 assertThat(result).isEmpty();
1 @Test
2 @DisplayName("Should ensure test isolation")
3 void shouldEnsureTestIsolation() {
4 // No books should exist at start
5 assertThat(cut.count()).isZero();
6
7 // Add a book
8 Book book = new Book("9781234567890", "Test Book",
9 "Test Author", LocalDate.now());
10 cut.save(book);
11
12 // Verify it was added
13 assertThat(cut.count()).isEqualTo(1);
14 }
This meta-test verifies our test setup works correctly. It confirms that
@Transactional cleans the database and that each test starts fresh. If this test
fails, it indicates a problem with test isolation that could cause flaky tests.
1. Keep slices focused: Each slice should test one architectural concern
2. Document usage: Provide clear documentation on when to use each slice
3. Minimize overlap: Avoid creating slices that duplicate existing function-
ality
4. Consider performance: Only include necessary auto-configurations
5. Maintain consistency: Follow Spring Boot’s patterns and conventions
By mastering test slices, we can write faster, more focused tests that clearly
communicate their intent while maintaining the benefits of Spring Boot’s
auto-configuration.
Chapter 3: Testing with @SpringBootTest
While test slices provide focused and fast testing for individual layers, some-
times we need to test our application as a whole.
Full context tests serve a different purpose than sliced tests. They verify that
all components work together correctly, ensuring our application functions
as an integrated system.
1 @SpringBootTest(
2 webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT
3 )
4 class BookshelfApplicationIT {
5 // Test implementation
6 }
The first two tests have identical configurations, so they share one context.
The second test runs much faster because it reuses the cached context.
@MockBean creates a new context because Spring must replace the real bean
with a mock. Understanding these rules is crucial for optimizing test suite
performance.
Start with a base test class that all integration tests can extend:
Chapter 3: Testing with @SpringBootTest 87
1 @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
2 @Testcontainers
3 @ActiveProfiles("test")
4 public abstract class BaseIntegrationTest {
This base class establishes common configuration for all integration tests.
1 @Container
2 @ServiceConnection
3 static PostgreSQLContainer<?> postgres =
4 new PostgreSQLContainer<>("postgres:16-alpine")
5 .withDatabaseName("testdb")
6 .withUsername("test")
7 .withPassword("test");
The static PostgreSQL container starts once and is reused across all test
classes. @ServiceConnection (Spring Boot 3.1+) automatically configures the
datasource. This base class maximizes context caching since all subclasses
share identical configuration.
This test class inherits the base configuration and gets fast context startup.
Chapter 3: Testing with @SpringBootTest 88
This second test class also inherits the configuration. Spring recognizes the
identical setup and reuses the same context. The first test class creates the
context (slow), but all subsequent classes reuse it (fast). This pattern can
reduce test suite runtime from minutes to seconds.
Each @MockBean creates a unique context because Spring must create a custom
configuration with that specific mock.
1 @SpringBootTest
2 public abstract class OptimizedContextCachingIT {
3
4 @TestConfiguration
5 static class MockConfiguration {
6 @Bean
7 @Primary
8 public OpenLibraryApiClient mockOpenLibraryApiClient() {
9 return mock(OpenLibraryApiClient.class);
10 }
11 }
12 }
This approach:
Use this sparingly, only when a test genuinely corrupts the context (like
modifying a singleton bean’s state). Each use adds significant time to your
test suite. Consider refactoring the test to avoid state modification instead.
Sometimes we need to customize the context for specific tests. Here’s how
to override security configuration:
1 @SpringBootTest
2 @Import(TestSecurityConfiguration.class)
3 class SecuredBookshelfIntegrationTest {
4
5 @TestConfiguration
6 public static class TestSecurityConfiguration {
@TestConfiguration allows custom beans just for tests without affecting produc-
tion code.
1 @Bean
2 @Primary
3 public PasswordEncoder testPasswordEncoder() {
4 return NoOpPasswordEncoder.getInstance(); // For testing only!
5 }
6 }
Here we replace the production password encoder (likely BCrypt) with NoOp-
PasswordEncoder, which doesn’t encrypt passwords.
1 @SpringBootTest
2 @TestPropertySource(
3 locations = "classpath:test-specific.properties",
4 properties = {
5 "spring.datasource.url=jdbc:h2:mem:testdb",
6 "app.book.import.enabled=false"
7 }
8 )
9 class BookshelfConfigurationIT {
inline properties. The inline properties take precedence over the file.
1 @Value("${app.book.import.enabled}")
2 private boolean bookImportEnabled;
3
4 @Test
5 void shouldDisableBookImportInTests() {
6 assertThat(bookImportEnabled).isFalse();
7 }
The @Value annotation injects the property value so we can verify it’s correctly
set.
Chapter 3: Testing with @SpringBootTest 92
We use real Spring beans for internal components to test the actual applica-
tion flow.
1 @MockitoBean
2 private OpenLibraryApiClient openLibraryApiClient;
We mock the external API client to avoid depending on external services that
might be slow, unreliable, or have rate limits. @MockitoBean provides better
integration with Mockito features than @MockBean.
1 @Test
2 @DisplayName("Should create book with metadata from external API")
3 void shouldCreateBookWithMetadata() {
4 // Arrange
5 String isbn = "9780134685991";
Using a real ISBN and actual book data makes the test more realistic and can
help catch edge cases with data validation.
1 when(openLibraryApiClient.getBookByIsbn(isbn))
2 .thenReturn(metadata);
This simulates what the real API would return but gives us complete control
over the response. We can test different scenarios by changing the mock’s
response.
1 // Act
2 Long bookId = bookService.createBook(request);
3
4 // Assert - Check service layer
5 assertThat(bookId).isNotNull();
This integration test verifies the complete flow: service receives request,
calls external API (our mock), processes the response, and saves to database.
The thumbnail URL assertion confirms that data from the external API was
correctly integrated into our domain model.
Testing the full stack from HTTP request to database using TestRestTemplate.
1 @Test
2 @DisplayName("Should create book via REST API")
3 void shouldCreateBookViaApi() {
4 // Arrange - JSON request body
5 String requestBody = """
6 {
7 "isbn": "9780134685991",
8 "title": "Effective Java",
9 "author": "Joshua Bloch",
10 "publishedDate": "2018-01-06"
11 }
12 """;
Java text blocks (triple quotes) make JSON readable in tests. The JSON struc-
ture matches what a real client would send. We use actual book data to
make the test realistic and catch any issues with date formatting or special
characters.
We set the Content-Type to tell the server we’re sending JSON. Basic authen-
tication is added for secured endpoints.
1 HttpEntity<String> request =
2 new HttpEntity<>(requestBody, headers);
The HttpEntity combines the body and headers into a single request object.
This mimics exactly what a real client would send.
1 // Act
2 ResponseEntity<Void> response = restTemplate.exchange(
3 baseUrl + "/api/books",
4 HttpMethod.POST,
5 request,
6 Void.class
7 );
Finally, we verify that the book was actually saved to the database with the
correct data.
Introduction to WireMock
1 <dependency>
2 <groupId>org.wiremock</groupId>
3 <artifactId>wiremock-standalone</artifactId>
4 <version>2.35.0</version>
5 <scope>test</scope>
6 </dependency>
The test scope ensures WireMock is only available during testing, not in
production.
1 class OpenLibraryApiClientTest {
2 @RegisterExtension
3 static WireMockExtension wireMockServer =
4 WireMockExtension.newInstance()
5 .options(wireMockConfig().dynamicPort())
6 .build();
7
8 private OpenLibraryApiClient cut;
1 @BeforeEach
2 void setUp() {
3 WebClient webClient = WebClient.builder()
4 .baseUrl(wireMockServer.baseUrl())
5 .build();
6 cut = new OpenLibraryApiClient(webClient);
7 }
Key points:
1 @Test
2 @DisplayName("Should return book metadata when API returns valid response")
3 void shouldReturnBookMetadataWhenApiReturnsValidResponse() {
4 // Arrange
5 String isbn = "9780132350884";
6
7 wireMockServer.stubFor(
8 get("/isbn/" + isbn)
9 .willReturn(aResponse()
10 .withHeader(HttpHeaders.CONTENT_TYPE,
11 MediaType.APPLICATION_JSON_VALUE)
12 .withBodyFile(isbn + "-success.json"))
13 );
1 {
2 "isbn_13": ["9780132350884"],
3 "title": "Clean Code",
4 "publishers": ["Prentice Hall"],
5 "number_of_pages": 431
6 }
1 // Act
2 BookMetadataResponse result = cut.getBookByIsbn(isbn);
3
4 // Assert
5 assertThat(result).isNotNull();
6 assertThat(result.title()).isEqualTo("Clean Code");
7 assertThat(result.getMainIsbn()).isEqualTo("9780132350884");
8 assertThat(result.getPublisher()).isEqualTo("Prentice Hall");
9 assertThat(result.numberOfPages()).isEqualTo(431);
1 @Test
2 @DisplayName("Should handle server error when API returns 500")
3 void shouldHandleServerErrorWhenApiReturns500() {
4 // Arrange
5 String isbn = "9999999999";
6
7 wireMockServer.stubFor(
8 get("/isbn/" + isbn)
9 .willReturn(aResponse()
10 .withStatus(500)
11 .withHeader(HttpHeaders.CONTENT_TYPE,
12 MediaType.APPLICATION_JSON_VALUE)
13 .withBody("{\"error\": \"Internal Server Error\"}"))
14 );
1 @Test
2 @DisplayName("Should handle slow response from API")
3 void shouldHandleSlowResponseFromApi() {
4 String isbn = "9780132350884";
5
6 wireMockServer.stubFor(
7 get("/isbn/" + isbn)
8 .willReturn(aResponse()
9 .withHeader(HttpHeaders.CONTENT_TYPE,
10 MediaType.APPLICATION_JSON_VALUE)
11 .withBodyFile(isbn + "-success.json")
12 .withFixedDelay(100)) // 100ms delay
13 );
1. Use sparingly: Full context tests are slower, reserve them for critical
integration scenarios
Chapter 3: Testing with @SpringBootTest 101
2. Prefer test slices: When testing specific layers, use focused test slices
3. Manage test data carefully: Use @Sql, @Transactional, or custom cleanup
strategies
4. Mock external dependencies: Use @MockBean for external services to en-
sure test reliability
5. Profile your tests: Use different profiles for different testing scenarios
6. Monitor context caching: Group tests with similar configurations to
maximize context reuse
7. Keep tests independent: Each test should be able to run in isolation
However, even with the best tools, it’s easy to fall into common traps that
undermine test effectiveness.
In this chapter, we’ll identify these pitfalls and establish best practices that
lead to maintainable, reliable test suites.
Testing anti-patterns are practices that seem reasonable at first but ul-
timately harm test quality and maintainability. Let’s examine the most
common ones and learn how to avoid them.
The Problem: Excessive mocking creates tests that pass but don’t reflect real
system behavior. When we mock everything, we’re essentially testing our
mocks, not our code.
This test class demonstrates a common mistake: creating too many mock
objects. The @Mock annotation tells Mockito to create fake versions of these
dependencies.
When we have this many mocks, it’s a sign that our test is trying to control
too much.
Each mock requires setup and verification, making the test complex and
fragile.
1 @Test
2 void shouldBorrowBook() {
3 // Setup 10+ when() statements
4 when(validator.canBorrow(any(), any())).thenReturn(true);
5 when(bookRepository.findById(any()))
6 .thenReturn(Optional.of(new Book()));
7 // ... more stubbing
The when() method tells our mocks what to return when specific methods are
called. The any() matcher means “accept any argument.”
Chapter 4: Testing Pitfalls and Best Practices 104
With many mocks, we need many of these setup statements. This setup code
often becomes longer than the actual test, obscuring what we’re trying to
verify.
The actual business logic we’re testing is just one line! The verify() method
checks that our mock was called, but this doesn’t tell us if the book borrowing
actually worked correctly.
We’re testing that methods were called, not that the system behaves cor-
rectly. This is like checking that a chef used a knife, but not tasting if the
food is good.
The Solution: Use integration tests for complex workflows and mock only
external boundaries.
This improved approach uses @SpringBootTest to load the full application con-
text with real components. The @Transactional annotation ensures each test
runs in a transaction that gets rolled back, keeping tests isolated.
We use @Autowired to inject real Spring beans, not mocks. The only mock is
@MockBean for the email service - an external system we don’t want to actually
call during tests.
This anti-pattern uses Mockito’s spy() to create a partial mock of our service.
A spy is a real object that we can track method calls on.
These verify() calls check that specific internal methods were called. This
couples our test to the implementation. If we later combine these methods or
rename them, the test fails even though the book return still works correctly.
Tests should care about outcomes, not the steps taken to achieve them.
This is the worst anti-pattern: using Java reflection to access private fields.
The getDeclaredField() method retrieves a private field, and setAccessible(true)
We don’t care how the service calculates late fees internally - we only care
that it produces the correct result.
We interact with the service through its public API only. The returnBook()
The method returns a result object that contains all the information about
the return operation.
We verify the behavior by checking the returned result. The assertions test
what actually matters: Was the book marked as overdue?
Was the late fee calculated correctly? We don’t care if the service used one
method or ten methods internally - we only care about the correct outcome.
Finally, we verify that the database was updated correctly. We fetch the book
again and check that its borrowed status is now false.
This tests the complete behavior: the book is no longer marked as borrowed
in our persistent storage. This is what matters to the business logic.
The Problem: Tests that break with minor, unrelated changes indicate tight
coupling to implementation.
This test creates a book object with specific details. The problem we’re about
to see is that the test will be too strict about how these details should be
formatted, making it fragile and prone to breaking when non-functional
changes occur.
This assertion expects an exact string match, including specific spacing, line
breaks (\n), and field order. If someone adds an extra space, changes the
order of fields, or adds a colon, this test breaks even though the information
is still correct. This makes refactoring painful.
This improved test has the same setup but will use more flexible assertions.
We still want to verify that all the important information is present, but we
won’t be strict about the exact formatting.
This test will survive formatting changes while still ensuring correctness.
The Problem: Slow tests discourage frequent execution and delay feedback.
Chapter 4: Testing Pitfalls and Best Practices 111
Generating 10,000 books is excessive for most tests. If you’re testing that
bulk import works, you might only need 10-50 books to prove the concept.
Large datasets slow down test execution and often don’t provide additional
value.
Thread.sleep() is one of the worst test anti-patterns. It makes the test wait for
5 real seconds, regardless of whether the operation completes faster.
Over many tests, these delays add up to minutes or hours of wasted time.
There are better ways to handle asynchronous operations.
The Solution: Use appropriate test sizes and avoid real delays.
This improved version uses @DataJpaTest, which only loads JPA-related com-
ponents (repositories, EntityManager, DataSource). This is much faster than
loading the entire application context.
The test name also indicates we’re testing behavior (multiple genres) rather
than volume.
We only need two books to test that different genres are handled correctly.
The saveAll() method efficiently saves multiple entities in one operation.
This minimal dataset proves our functionality works without the overhead
of thousands of records.
We verify the results immediately after saving. Since we’re using a real
database transaction (thanks to @DataJpaTest), the data is available instantly.
No delays needed!
Spring’s context caching can reduce test execution time by 50-90%. When
Spring loads an application context for a test, it caches it and reuses it for
other tests with identical configuration. This saves the expensive initializa-
tion process.
Chapter 4: Testing Pitfalls and Best Practices 114
This abstract base class defines a common configuration that all integra-
tion tests can inherit. By using the same annotations (@SpringBootTest and
@ActiveProfiles), Spring recognizes these tests share the same context re-
quirements and reuses the cached context. This is a crucial performance
optimization.
Spring loads the context once for the first test and reuses it for the second.
Without this pattern, each test class might create its own context, doubling
the startup time.
Chapter 4: Testing Pitfalls and Best Practices 115
Context pollution occurs when tests modify shared state, forcing Spring to
create new contexts. This dramatically slows down test execution.
The @DirtiesContext annotation tells Spring that this test modifies the applica-
tion context in a way that could affect other tests.
Spring must discard the context and create a new one for subsequent tests.
This is very expensive - often adding 5-30 seconds per occurrence!
Instead of modifying the real cache and dirtying the context, we mock the
CacheManager. Each test gets a fresh, isolated cache instance. The @BeforeEach
method runs before each test, ensuring clean state without the expensive
context recreation. This approach is hundreds of times faster.
There are two distinct approaches to parallelize test execution, each with
different trade-offs:
Understanding both modes helps us choose the right strategy for our specific
needs.
Build tools like Maven and Gradle can spawn multiple JVM processes to run
tests in parallel. Each fork is a completely isolated Java process with its own
memory space.
1 <plugin>
2 <artifactId>maven-surefire-plugin</artifactId>
3 <configuration>
4 <forkCount>4</forkCount>
5 <reuseForks>true</reuseForks>
6 <argLine>-Xmx1024m</argLine>
7 </configuration>
8 </plugin>
1 <plugin>
2 <artifactId>maven-failsafe-plugin</artifactId>
3 <configuration>
4 <forkCount>2</forkCount>
5 <reuseForks>false</reuseForks>
6 </configuration>
7 </plugin>
Gradle Configuration:
1 test {
2 maxParallelForks = Runtime.runtime.availableProcessors().intdiv(2) ?: 1
3 forkEvery = 50 // Create new fork every 50 tests
4
5 jvmArgs = ['-Xmx1024m', '-XX:+UseG1GC']
6 }
Disadvantages:
Test runners like JUnit 5 can execute tests using multiple threads within
the same JVM. This approach shares memory and loaded contexts between
threads.
1 junit.jupiter.execution.parallel.config.strategy=fixed
2 junit.jupiter.execution.parallel.config.fixed.parallelism=4
This creates exactly 4 threads regardless of available CPU cores. Useful for
consistent behavior across different environments.
1 @Execution(ExecutionMode.CONCURRENT)
2 class ParallelTest {
3 // This class runs tests in parallel
4 }
5
6 @Execution(ExecutionMode.SAME_THREAD)
7 class SequentialTest {
8 // This class runs tests sequentially
9 }
Use @Execution to override the default behavior for specific test classes. This is
essential for tests that can’t run in parallel due to shared resources.
1 class DatabaseTest {
2 @Test
3 @ResourceLock(value = "database", mode = ResourceAccessMode.READ)
4 void readOnlyTest() {
5 // Multiple tests can hold READ locks simultaneously
6 }
7
8 @Test
9 @ResourceLock(value = "database", mode = ResourceAccessMode.READ_WRITE)
10 void modifyingTest() {
11 // Exclusive access - no other test can run
12 }
13 }
JUnit 5’s @ResourceLock prevents conflicts when tests share resources. READ
locks allow concurrent access, while READ_WRITE locks ensure exclusive
access.
1 <plugin>
2 <artifactId>maven-surefire-plugin</artifactId>
3 <configuration>
4 <!-- Build tool level: 2 JVM forks -->
5 <forkCount>2</forkCount>
6 <reuseForks>true</reuseForks>
7
8 <!-- Test runner level: Enable JUnit 5 parallelism -->
9 <properties>
10 <configurationParameters>
11 junit.jupiter.execution.parallel.enabled=true
12 junit.jupiter.execution.parallel.mode.default=concurrent
13 </configurationParameters>
14 </properties>
15 </configuration>
16 </plugin>
Benefits:
Risks:
JUnit 5’s @Tag annotation categorizes tests. Fast unit tests that run in millisec-
onds get tagged as “fast”. Integration tests that load Spring contexts or use
databases get tagged as “slow”.
This allows developers to run fast tests frequently during development and
save slow tests for pre-commit or CI runs.
These Maven commands use the tag system to run specific test groups. Dur-
ing development, running only fast tests provides quick feedback (seconds
instead of minutes).
The CI pipeline runs all tests to ensure complete coverage. This selective
execution strategy keeps developers productive while maintaining quality.
Chapter 4: Testing Pitfalls and Best Practices 123
The Object Mother pattern creates a central place for generating test objects.
The Faker library generates realistic test data (names, addresses, ISBNs) auto-
matically.
Scenario-specific factory:
Test fixtures create complete scenarios with related data. They’re especially
useful for integration tests that need realistic data setups.
Chapter 4: Testing Pitfalls and Best Practices 125
1 @Component
2 @Profile("test")
3 public class TestScenarios {
4 @Autowired
5 private BookRepository bookRepository;
6 @Autowired
7 private UserRepository userRepository;
1 @SpringBootTest
2 class BookBorrowingTest {
3 @Autowired
4 private TestScenarios scenarios;
5 @Autowired
6 private BookshelfService bookshelfService;
1 @Test
2 void shouldPreventBorrowingWithOverdueBooks() {
3 // Given - Complete scenario in one line
4 Scenario scenario = scenarios.libraryWithOverdueBooks();
5 Book availableBook = BookMother.simple();
1 // When/Then
2 assertThatThrownBy(() ->
3 bookshelfService.borrowBook(
4 availableBook.getId(),
5 scenario.getUser().getId()
6 )
7 ).isInstanceOf(BorrowingDeniedException.class)
8 .hasMessageContaining("overdue books");
1 @Sql("/test-data/books.sql")
2 @Test
3 void testWithSqlData() { }
1 @Component
2 @Profile("test")
3 public class TestDataLoader {
4 @EventListener(ApplicationReadyEvent.class)
5 public void loadTestData() {
6 if (bookRepository.count() == 0) {
7 bookRepository.saveAll(
8 BookMother.withGenre("Technology", 5)
9 );
10 }
11 }
12 }
This component loads test data when the application starts in test mode.
The @EventListener reacts to Spring’s ApplicationReadyEvent, ensuring all beans
are initialized first.
The count check prevents duplicate data if the context is reused. This ap-
proach is useful for shared test databases or when using test containers.
1 @Value("classpath:test-data/books.csv")
2 private Resource booksCsv;
3
4 @PostConstruct
5 void loadFromCsv() {
6 // Use Spring Batch or custom CSV reader
7 }
For large test datasets (thousands of records), CSV files are more maintain-
able than code. Spring’s @Value annotation with classpath: prefix loads files
from src/test/resources.
Cleanup Procedures
1 @SpringBootTest
2 @Transactional
3 class TransactionalTest {
4 @Test
5 void testWithAutoRollback() {
6 // Changes automatically rolled back
7 }
8 }
This is the cleanest approach for database tests. Spring wraps each test
method in a transaction and rolls it back after completion.
Your test can insert, update, or delete data, and the database returns to
its original state automatically. This is fast and ensures perfect isolation
between tests.
1 @Component
2 @Profile("test")
3 class TestDataCleaner {
4 @Transactional
5 public void cleanBorrowingData() {
6 jdbcTemplate.update("DELETE FROM borrowing_history");
7 jdbcTemplate.update("UPDATE books SET borrowed = false");
8 }
9 }
1 @Testcontainers
2 class FreshDatabaseTest {
3 @Container
4 static PostgreSQLContainer<?> postgres =
5 new PostgreSQLContainer<>();
6 }
Perfect for tests that modify database structure or need specific PostgreSQL
versions.
Testing Security
1 @SpringBootTest
2 @AutoConfigureMockMvc
3 class SecurityTest {
4 @Autowired
5 private MockMvc mockMvc;
1 @Test
2 void anonymousUserCannotBorrow() throws Exception {
3 mockMvc.perform(post("/books/1/borrow"))
4 .andExpect(status().is3xxRedirection())
5 .andExpect(redirectedUrlPattern("**/login"));
6 }
This test verifies that unauthenticated users cannot access protected end-
points. The perform() method simulates a POST request.
1 @Test
2 @WithMockUser(roles = "USER")
3 void userCanBorrowBooks() throws Exception {
4 mockMvc.perform(post("/books/1/borrow"))
5 .andExpect(status().is3xxRedirection());
6 }
@WithMockUser creates a mock authenticated user with the USER role. This
simulates a logged-in user without needing real authentication.
The test verifies that authenticated users can access the borrowing endpoint.
The 3xx status here indicates successful processing with a redirect (common
in web applications).
1 @Test
2 @WithMockUser(roles = "USER")
3 void userCannotAccessAdmin() throws Exception {
4 mockMvc.perform(get("/admin/users"))
5 .andExpect(status().isForbidden());
6 }
1 @WithMockUser(roles = "LIBRARIAN")
2 public @interface WithMockLibrarian { }
Custom annotations make tests more readable and reduce duplication. This
meta-annotation combines @WithMockUser with a specific role.
Now tests can use @WithMockLibrarian instead of repeating the role configura-
tion. This follows the DRY principle and makes role changes easier.
1 @WithSecurityContext(factory = ReaderSecurityFactory.class)
2 public @interface WithMockReader {
3 String email() default "reader@example.com";
4 boolean hasOverdueBooks() default false;
5 }
This enables testing business rules that depend on user attributes beyond
simple roles.
Usage in tests:
1 @Test
2 @WithMockLibrarian
3 void librarianCanManageBooks() {
4 // Test with librarian role
5 }
6
7 @Test
8 @WithMockReader(hasOverdueBooks = true)
9 void readerWithOverdueBooksCannotBorrow() {
10 // Test with custom principal
11 }
These annotations make security tests expressive. The first test runs with
librarian privileges. The second creates a reader with overdue books, testing a
business rule that combines authentication (who you are) with authorization
(what you can do based on your state).
Chapter 4: Testing Pitfalls and Best Practices 133
1 @Service
2 public class BookManagementService {
3 @PreAuthorize("hasRole('LIBRARIAN')")
4 public Book addBook(String title, String author) {
5 // Implementation
6 }
1 @SpringBootTest
2 class MethodSecurityTest {
3 @Autowired
4 private BookManagementService service;
1 @Test
2 @WithMockUser(roles = "USER")
3 void userCannotAddBooks() {
4 assertThrows(AccessDeniedException.class,
5 () -> service.addBook("Title", "Author")
6 );
7 }
1 @Test
2 @WithMockUser(roles = "LIBRARIAN")
3 void librarianCanAddBooks() {
4 assertDoesNotThrow(
5 () -> service.addBook("Title", "Author")
6 );
7 }
FIRST Principles
1 @Test
2 void shouldCalculateLateFee() {
3 // Arrange (Given)
4 BorrowingRecord record = createOverdueRecord(7);
5
6 // Act (When)
7 BigDecimal fee = calculator.calculateLateFee(record);
8
9 // Assert (Then)
10 assertThat(fee).isEqualTo("3.50"); // $0.50 × 7 days
11 }
1 // Pattern: should_ExpectedBehavior_When_StateUnderTest
2 void should_ThrowException_When_BookNotFound()
3 void should_ReturnBook_When_UserIsAuthorized()
1 // Pattern: methodName_StateUnderTest_ExpectedBehavior
2 void borrowBook_WithOverdueBooks_ThrowsException()
3 void calculateFee_SevenDaysLate_Returns3Dollars50()
Key Takeaways
From isolated unit tests to full-scale integration tests, we’ve covered the
tools, techniques, and best practices that will make you a more effective and
confident Spring Boot developer.
Testing is not just a technical practice but a mindset that transforms how we
approach software development.
By investing time in writing good tests, you’re not just verifying your code
works today - you’re building a foundation that supports the evolution of
your application tomorrow.
Conclusion: The Future of Testing in Spring Boot 137
Key Takeaways
As you apply what you’ve learned in this book, keep these key principles in
mind:
1. Test the right things at the right level: Choose the appropriate testing
approach based on what you’re trying to verify. Unit tests for business
logic, integration tests for component interactions, and end-to-end
tests for critical user journeys.
2. Focus on behavior, not implementation: Write tests that verify what
your code does, not how it does it. This makes your tests more resilient
to refactoring.
3. Use Spring Boot’s testing tools effectively: Take advantage of the
specialized testing annotations, slices, and utilities that Spring Boot
provides to make your tests more focused and efficient.
4. Maintain a fast feedback cycle: Organize your tests to provide quick
feedback. Fast unit tests should run on every code change, while slower
integration tests can run less frequently.
5. Keep your tests clean and maintainable: Test code deserves the same
level of care as production code. Refactor tests when needed, use mean-
ingful names, and avoid duplication.
Continuous Learning
The testing landscape continues to evolve, with new tools and techniques
emerging regularly. To stay current:
Remember that testing is both a technical skill and an art. As you gain
experience, you’ll develop intuition about what to test and how to test it most
effectively.
After everything we’ve covered, here’s the bottom line: Testing Spring Boot
applications is simple once you know the tools.
Once you understand when and how to use each tool, testing becomes
straightforward and even enjoyable.
Conclusion: The Future of Testing in Spring Boot 139
This technique is particularly valuable for critical business logic where you
need absolute confidence in your test suite’s ability to catch regressions.
These tests are slower and more brittle than unit or integration tests, but
they’re invaluable for testing JavaScript-heavy frontends, complex user in-
teractions, and cross-browser compatibility. They’re particularly important
for applications with rich user interfaces or complex business workflows that
span multiple pages.
Contract Testing
Tools like Pact or Spring Cloud Contract allow you to define contracts between
services and verify that both providers and consumers adhere to these con-
tracts.
Conclusion: The Future of Testing in Spring Boot 141
Each of these advanced testing topics could fill books of their own. The
foundation you’ve built with unit testing, integration testing, and the Spring
Boot testing toolkit prepares you to explore these specialized areas when
your projects require them.
Keep learning, keep experimenting, and most importantly, keep testing. The
investment in testing skills pays dividends throughout your entire develop-
ment career.
The world of Spring Boot testing continues to evolve. Here are some trends
to watch:
This trend will continue with more specialized containers and improved
integration with cloud services.
Conclusion: The Future of Testing in Spring Boot 142
3. AI-Assisted Testing
Testing failure modes and resilience will become more mainstream, with de-
velopers intentionally introducing failures to verify that systems can recover
properly.
1. Start small: Pick one testing technique from this book and apply it
tomorrow
Conclusion: The Future of Testing in Spring Boot 143
2. Practice deliberately: Write at least one test every day for the next week
3. Share knowledge: Teach a colleague one testing trick you’ve learned
4. Build momentum: As testing becomes easier, you’ll naturally write
more tests
When your entire team embraces testing, everyone moves faster and ships
with confidence.
Remember:
This isn’t a dream—it’s the natural result of applying what you’ve learned.
Take Your Skills to the Next Level with the Testing Spring Boot
Applications Masterclass
This book has given you a solid foundation in Spring Boot testing. But what
if you want to go deeper?
That’s where the Testing Spring Boot Applications Masterclass comes in.
While this book provides the essential knowledge, the Masterclass takes you
on a complete journey:
Next Steps
Now that you have the knowledge and tools needed to write comprehensive
and effective tests for your Spring Boot applications, it’s time to put them
into practice. Here are some concrete next steps:
Conclusion: The Future of Testing in Spring Boot 146
1. Audit your current test suite: Identify gaps in coverage and areas where
you can apply the techniques from this book.
2. Implement test slices: Start using @WebMvcTest, @DataJpaTest, and other
slices to make your tests faster and more focused.
3. Refactor existing tests: Apply the best practices to improve readability
and maintainability of your current tests.
4. Establish testing standards: Work with your team to create testing
guidelines based on the patterns in this book.
5. Measure and improve: Track metrics like test execution time and flaki-
ness, then work to improve them.
Further Resources
To continue your journey in testing Spring Boot applications, here are valu-
able resources:
Final Thoughts
Good tests are the foundation of maintainable software. They give us confi-
dence to refactor, deploy, and evolve our applications.
May your tests be fast, your builds be green, and your applications be bug-
free!
Joyful testing,
Philip
Changelog
• 1.0 (2023-07-04): Initial Release
• 2.0 (2025-06-03): Second Release, drastically restructured the book into
four main chapters and updating it for Java 21 and Spring Boot 3.5