[go: up one dir, main page]

0% found this document useful (0 votes)
77 views153 pages

Testing Spring Boot Applications Demystified

Uploaded by

YouFroggerz
Copyright
© © All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as PDF, TXT or read online on Scribd
0% found this document useful (0 votes)
77 views153 pages

Testing Spring Boot Applications Demystified

Uploaded by

YouFroggerz
Copyright
© © All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as PDF, TXT or read online on Scribd
You are on page 1/ 153

Testing Spring Boot Applications Demystified

Write Better Tests, Ship Faster: A Practical Guide to Spring


Boot Testing

Philip Riecks

This book is available at


https://leanpub.com/testing-spring-boot-applications-demystified

This version was published on 2025-06-03

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.

© 2023 - 2025 Philip Riecks


Also By Philip Riecks
Testing Spring Boot Applications Masterclass Workbook

Java Testing Toolbox

Stratospheric
Contents

Introduction & Motivation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1


About this Book . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1
About the Author . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1
Why Testing Matters . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3
What You’ll Learn in This Book . . . . . . . . . . . . . . . . . . . . . . . . 4
Who This Book Is For . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5
Agenda . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6

Chapter 1: Testing Fundamentals in Spring Boot Projects . . . . . . . . 8


Defining a Testing Strategy . . . . . . . . . . . . . . . . . . . . . . . . . . 8
Build Tool Configuration . . . . . . . . . . . . . . . . . . . . . . . . . . . . 11
Unit Testing Fundamentals . . . . . . . . . . . . . . . . . . . . . . . . . . 15
The Testing Toolkit . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 18
Writing Effective Unit Tests . . . . . . . . . . . . . . . . . . . . . . . . . . 40
Best Practices for Unit Testing . . . . . . . . . . . . . . . . . . . . . . . . 46
Summary . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 51

Chapter 2: Testing with a Sliced Application Context . . . . . . . . . . . 53


Understanding Test Slices . . . . . . . . . . . . . . . . . . . . . . . . . . . 53
Testing Web Controllers with @WebMvcTest . . . . . . . . . . . . . . . 56
Testing Data Access with @DataJpaTest . . . . . . . . . . . . . . . . . . 72

Chapter 3: Testing with @SpringBootTest . . . . . . . . . . . . . . . . . . . . . 84


Full Application Context Testing . . . . . . . . . . . . . . . . . . . . . . . 84
Testing Different Application Layers Together . . . . . . . . . . . . . . 92
Best Practices for @SpringBootTest . . . . . . . . . . . . . . . . . . . . . 100
CONTENTS

Chapter 4: Testing Pitfalls and Best Practices . . . . . . . . . . . . . . . . 102


Common Testing Anti-Patterns . . . . . . . . . . . . . . . . . . . . . . . 102
Test Performance Optimization . . . . . . . . . . . . . . . . . . . . . . . . 113
Test Data Management . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 123
Testing Security . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 129
Best Practices Summary . . . . . . . . . . . . . . . . . . . . . . . . . . . . 134

Conclusion: The Future of Testing in Spring Boot . . . . . . . . . . . . . 136


Embracing a Testing Culture . . . . . . . . . . . . . . . . . . . . . . . . . . 136
Key Takeaways . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 137
Continuous Learning . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 137
The Simple Truth About Spring Boot Testing . . . . . . . . . . . . . . . 138
What We Couldn’t Cover: The Extended Testing Universe . . . . . . . 139
Looking Ahead: Emerging Trends . . . . . . . . . . . . . . . . . . . . . . 141
Ready to Master Spring Boot Testing? . . . . . . . . . . . . . . . . . . . . 144
Next Steps . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 145
Further Resources . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 146

Changelog . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 148
Introduction & Motivation

About this Book

Whether you’re a seasoned developer or just starting your journey with


Spring Boot, this ebook will unravel the complexities of testing Spring Boot
applications and empower you to become more productive and confident in
your development efforts.

Testing is fundamental to software development, helping you catch bugs,


ensure functionality, and build confidence in your application’s reliability.
However, testing Spring Boot applications can feel like navigating a complex
maze - from managing dependencies to crafting effective tests that accu-
rately reflect your code’s behavior.

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.

About the Author

Under the slogan Testing Spring Boot Applications Made Simple, Philip
provides recipes and tips & tricks to make Spring Boot developers more
Introduction & Motivation 2

productive and confident with their code changes.

He’s on a mission to transform the perception of testing from a frustrated


afterthought to an enjoyable daily practice.

Philip speaking at Devoxx Belgium

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

Why Testing Matters

Moving Beyond Common Misconceptions

In software development, testing is often reduced to an afterthought, hastily


addressed before submitting a pull request or simply to meet a predefined
code coverage threshold. Both approaches - rushed testing and testing solely
for metrics - miss the true value of a robust testing strategy.

The True Value of Testing

Well-designed tests provide three critical benefits:

• Increased Confidence: Tests enable fearless refactoring by catching is-


sues early, allowing you to make changes with certainty that you haven’t
broken existing functionality.
• Improved Documentation: Tests serve as living documentation, making
it easier for developers to understand your code’s intended behavior and
purpose.
• Enhanced Productivity: A reliable test suite significantly boosts produc-
tivity by catching bugs early, reducing debugging time, and preventing
regressions.

Effective testing isn’t just about quality assurance - it’s about creating a solid
foundation that supports continuous improvement and innovation.

The Hidden Costs of Inadequate Testing

Every production outage translates to tangible costs - financial losses, dam-


aged reputation, and lost customer trust. A comprehensive test suite helps
Introduction & Motivation 4

prevent these issues by catching potential problems before they reach pro-
duction.

Why Testing Skills Remain Underdeveloped

Despite its importance, testing rarely receives adequate attention in educa-


tional settings or professional development. Universities and conferences
typically focus on language features or frameworks rather than testing fun-
damentals.

Consequently, poor testing practices perpetuate within teams, with newcom-


ers inheriting and reproducing flawed approaches.

Spring Boot’s Testing Superpowers

Spring Boot offers exceptional testing support that extends far beyond basic
testing tools like JUnit and Mockito.

Its comprehensive testing tools allow you to:

• Test slices of your application context for faster feedback cycles


• Simplify integration testing with Docker containers and embedded
databases
• Verify web endpoints without deploying your application
• Test security configurations and access controls effectively
• Validate data access components without complex setup

However, many developers remain unaware of these capabilities and how to


leverage them for more effective and efficient testing.

What You’ll Learn in This Book

After reading this book, you’ll be able to:


Introduction & Motivation 5

1. Design and implement a comprehensive testing strategy for your Spring


Boot applications
2. Write clear, maintainable tests that provide meaningful feedback
3. Leverage Spring Boot’s powerful testing features to test different appli-
cation layers
4. Identify and avoid common testing anti-patterns
5. Optimize your tests for better performance and faster feedback cycles
6. Confidently make changes to your codebase, knowing your tests will
catch potential issues

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.

Who This Book Is For

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

• Spring Boot Knowledge: You should already be comfortable with the


fundamental features of Spring and Spring Boot, especially its auto-
configuration (we recommend the article How Spring Boot’s Autocon-
figurations Work from Marco Behler)
Introduction & Motivation 6

• Java Proficiency: This book assumes you have a solid understanding of


the Java language. We won’t be covering specific Java language features
in detail.
• Testing Experience: Ideally, you have some basic experience with test-
ing, specifically with JUnit and Mockito.

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

Here’s what you can expect in the following chapters:

• Introduction & Motivation: Understand the Motivation Behind Testing


and an Overview of the book and its objectives.
• Chapter 1: Testing with Spring Boot Fundamentals: Covering the basics
of unit testing with Spring Boot.
• Chapter 2: Testing with a Sliced Application Context: Exploring how to
test specific slices of your application.
• Chapter 3: Testing with @SpringBootTest: Write integration tests with a full-
blown application context.
• Chapter 4: Testing Pitfalls and Best Practices: Identifying common test-
ing pitfalls and best practices.
• Conclusion: Wrapping up with an outlook on the future of testing in
Spring Boot applications.
Introduction & Motivation 7

By following this agenda, you will gain a comprehensive understanding of


how to test Spring Boot applications effectively.

Let’s get started!


Chapter 1: Testing Fundamentals in
Spring Boot Projects
Let’s build a strong foundation for testing in Spring Boot projects.

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.

Defining a Testing Strategy

The Importance of a Testing Strategy

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.

Our Approach to Testing

We don’t advocate for any specific model.

Instead, our central goal is to gain confidence in deploying changes to pro-


duction and receiving feedback about code changes as quickly as possible.
Chapter 1: Testing Fundamentals in Spring Boot Projects 9

Since applications differ widely, there is no one-size-fits-all testing strat-


egy.

For example:

• Algorithmic Libraries: A cryptographic encryption library may achieve


confidence with a broad set of unit tests.
• CRUD Applications: RESTful microservices will require more integration
tests, ensuring the application works.
• Fullstack Applications: Applications that interact with a front-end, have
checkout integrations (e.g., with PayPal), and fetch data from remote
services will require a different testing strategy to cover all use cases.

Customizing Your Testing Strategy

It’s challenging to define a universal testing strategy upfront that fits all
applications.

The primary aim is to achieve confidence in the deployment process. While


confidence is hard to measure and quantify, it requires experiences and
listening to your team to reflect on your confidence level when making
changes.

Whether it’s a well-written unit tests, a covering integration tests or a


full-blown end-to-end tests that brings more confidence in your particular
scenario, depends on your specific needs.

Establishing Common Testing Terms

A crucial step is to define a common understanding of different testing types


within your team or organization.

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

tests, and slow tests, etc.

Having a shared language for these terms is vital.

A Simple Three-Step Approach

We advocate for a straightforward categorization of tests:

1. Unit Tests:

• Characteristics: Fast and isolated, with no external dependencies (e.g.,


file systems, databases).
• Tools: Typically use Mockito and JUnit.
• Scope: Do not interact with the Spring context.

2. Integration Tests:

• Characteristics: Slower than unit tests and may involve infrastructure


such as databases, messaging queues, or remote systems.
• Tools: Often use the Spring test context and interact with multiple beans.

3. End-to-End Tests:

• Characteristics: The slowest of all categories, involving interaction with


the application as a whole. Testing full user journeys, tests might be
scheduled nightly.
• Scope: Includes user interactions (for web applications) or full interac-
tion with a RESTful web service, ensuring the application works as an
integrated system.

These conventions will guide our discussion and examples throughout the
book.
Chapter 1: Testing Fundamentals in Spring Boot Projects 11

Build Tool Configuration

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.

Maven Testing Configuration

Test Structure and Organization

Maven follows a strict convention for organizing tests:

• Test classes: src/test/java - All test classes must be placed here


• Test resources: src/test/resources - Configuration files, test data, and
other resources
• Production code: src/main/java - Your actual application code
• Production resources: src/main/resources - Application configuration files

This separation ensures a clear boundary between production and test code.

Dependency Scoping

The <scope>test</scope> declaration is critical for keeping our production arti-


facts lean:
Chapter 1: Testing Fundamentals in Spring Boot Projects 12

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.

This scoping mechanism ensures test dependencies never reach production,


keeping our deployable artifacts smaller and more secure.

The Two-Plugin Strategy

Maven employs two separate plugins for different test types:

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

Why Two Plugins?

This separation provides several benefits:

• Fast feedback: Unit tests run first, failing fast if basic functionality is
broken
• Build optimization: Skip slow integration tests during development
with mvn test

• Parallel execution: Configure different parallelization strategies for


each test type
• Resource management: Integration tests can have different JVM set-
tings and timeouts
Chapter 1: Testing Fundamentals in Spring Boot Projects 13

Test Naming Conventions

Surefire Plugin (Unit Tests):

• **/Test*.java

• **/*Test.java -> Recommended


• **/*Tests.java

• **/*TestCase.java

Failsafe Plugin (Integration Tests):

• **/IT*.java

• **/*IT.java -> Recommended


• **/*ITCase.java

Best Practice: Stick to one convention per test type:

• Unit tests: BookServiceTest.java


• Integration tests: BookServiceIT.java

This consistency makes it immediately clear what type of test you’re looking
at.

Gradle Testing Configuration

Test Structure in Gradle

Gradle follows the same directory structure as Maven:

• src/test/java - Test classes


• src/test/resources - Test resources

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 }

The testImplementation configuration ensures dependencies are only available


during testing.

Gradle Test Configuration Best Practices

1. Configure Test Logging

1 test {
2 testLogging {
3 events "passed", "skipped", "failed"
4 exceptionFormat "full"
5 showStandardStreams = false
6 }
7 }

TODO: *IT task with Gradle

Understanding Test Execution

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:

• gradle test - Runs unit tests


• gradle integrationTest - Runs integration tests
Chapter 1: Testing Fundamentals in Spring Boot Projects 15

Unit Testing Fundamentals

Before diving into Spring-specific testing, let’s master the fundamentals of


unit testing in Java.

These principles apply to any Java project and form the foundation of a solid
testing strategy.

What Makes a Good Unit Test?

A unit test should be:

• Fast: Executes in milliseconds, not seconds


• Isolated: Tests a single unit of code without external dependencies
• Repeatable: Produces the same result every time
• Self-validating: Either passes or fails with no manual interpretation
• Timely: Written close to the production code

The AAA Pattern

Every unit test should follow the Arrange-Act-Assert pattern:

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

Alternatively, we can use the Given-When-Then pattern.

We’ll use both patterns throughout this book, as it provides a clear structure
for tests.

Testing Without Spring Context

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.

First, we’ll define our business constants:

1 public class PriceCalculator {


2 private static final double TAX_RATE = 0.08;
3 private static final double DISCOUNT_THRESHOLD = 100.0;
4 private static final double DISCOUNT_RATE = 0.10;

These constants define our business rules: 8% tax, 10% discount for pur-
chases over $100.

Next, our main calculation method:

1 public double calculateFinalPrice(double basePrice, int quantity) {


2 if (basePrice <= 0 || quantity <= 0) {
3 throw new IllegalArgumentException(
4 "Price and quantity must be positive");
5 }
6
7 double subtotal = basePrice * quantity;
8 double discountedPrice = applyDiscount(subtotal);
9 return applyTax(discountedPrice);
10 }

This method validates inputs, calculates the subtotal, applies any discount,
then adds tax. The order matters - we discount before taxing.

Our discount logic checks if the purchase qualifies:


Chapter 1: Testing Fundamentals in Spring Boot Projects 17

1 private double applyDiscount(double price) {


2 if (price > DISCOUNT_THRESHOLD) {
3 return price * (1 - DISCOUNT_RATE);
4 }
5 return price;
6 }

Only purchases over $100 get the 10% discount.

Finally, we apply tax to the discounted price:

1 private double applyTax(double price) {


2 return price * (1 + TAX_RATE);
3 }

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.

Next, test the discount scenario:


Chapter 1: Testing Fundamentals in Spring Boot Projects 18

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.

Finally, test error handling:

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.

The Testing Toolkit

While Spring Boot provides the spring-boot-starter-test dependency, let’s un-


derstand the core testing libraries it includes and how to use them effectively
for unit testing.
Chapter 1: Testing Fundamentals in Spring Boot Projects 19

What’s Inside spring-boot-starter-test?

The spring-boot-starter-test is a comprehensive testing starter that brings


together the most essential testing libraries for Spring Boot applications.

Here’s what it includes transitively:

Core Testing Framework:

• JUnit 5 (JUnit Jupiter): The modern testing framework for Java

Assertion Libraries:

• AssertJ: Fluent assertion library with rich, readable assertions


• Hamcrest: Matcher library for building complex test conditions

Mocking Frameworks:

• Mockito: The most popular mocking framework for Java

Spring Test Support:

• Spring Test: Core Spring testing features including TestContext frame-


work
• Spring Boot Test: Auto-configuration support for tests

Additional Utilities:

• JSONAssert: For testing JSON responses


• JsonPath: XPath-like syntax for JSON
• XMLUnit: For XML testing (if needed)

This means when we add just one dependency:


Chapter 1: Testing Fundamentals in Spring Boot Projects 20

1 <dependency>
2 <groupId>org.springframework.boot</groupId>
3 <artifactId>spring-boot-starter-test</artifactId>
4 <scope>test</scope>
5 </dependency>

We get a complete testing toolkit without managing individual library ver-


sions. Spring Boot manages version compatibility, ensuring all these li-
braries work well together.

JUnit 5: The Modern Testing Framework

JUnit 5 (Jupiter) is the backbone of Java testing. Key improvements include:

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 }

Understanding JUnit Jupiter Extensions (Coming from JUnit 4)

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.

In JUnit 4, you were limited to one runner:

1 @RunWith(MockitoJUnitRunner.class)
2 public class MyTest {
3 // Can't add Spring runner here too!
4 }

In JUnit 5, you can combine multiple extensions:


Chapter 1: Testing Fundamentals in Spring Boot Projects 22

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.

JUnit 5 Extension Points

The extension model provides several callback interfaces that frameworks


can implement:

Test Lifecycle Callbacks:

• BeforeAllCallback - Runs before all tests in a class


• BeforeEachCallback - Runs before each test method
• BeforeTestExecutionCallback - Runs immediately before test execution
• AfterTestExecutionCallback - Runs immediately after test execution
• AfterEachCallback - Runs after each test method
• AfterAllCallback - Runs after all tests in a class

Parameter Resolution:

• ParameterResolver - Provides parameters to test methods and constructors

Test Execution:

• ExecutionCondition - Controls whether tests should run


• TestInstancePostProcessor - Processes test instances after creation
• TestInstancePreDestroyCallback - Cleanup before test instance destruction

Exception Handling:
Chapter 1: Testing Fundamentals in Spring Boot Projects 23

• TestExecutionExceptionHandler - Handles exceptions thrown during test exe-


cution
• LifecycleMethodExecutionExceptionHandler - Handles exceptions in lifecycle
methods

Let’s see how popular frameworks use these extension points:

Mockito Extension Example:

1 public class MockitoExtension implements


2 BeforeEachCallback, AfterEachCallback, ParameterResolver {
3
4 @Override
5 public void beforeEach(ExtensionContext context) {
6 // Initialize @Mock fields
7 MockitoAnnotations.openMocks(context.getTestInstance());
8 }
9 }

The Mockito extension uses BeforeEachCallback to initialize mocks before each


test and ParameterResolver to inject mocks as method parameters.

Spring Extension Example:

1 public class SpringExtension implements


2 BeforeAllCallback, TestInstancePostProcessor {
3
4 @Override
5 public void postProcessTestInstance(Object testInstance,
6 ExtensionContext context) {
7 // Inject Spring beans into test instance
8 ApplicationContext ctx = getApplicationContext(context);
9 ctx.getAutowireCapableBeanFactory().autowireBean(testInstance);
10 }
11 }

Spring uses TestInstancePostProcessor to perform dependency injection after


JUnit creates the test instance.

Creating Your Own Extension:

Here’s a practical example - a timing extension that warns about slow tests:
Chapter 1: Testing Fundamentals in Spring Boot Projects 24

1 public class TimingExtension implements


2 BeforeTestExecutionCallback, AfterTestExecutionCallback {
3
4 @Override
5 public void beforeTestExecution(ExtensionContext context) {
6 context.getStore(NAMESPACE).put("start", System.currentTimeMillis());
7 }
8
9 @Override
10 public void afterTestExecution(ExtensionContext context) {
11 long start = context.getStore(NAMESPACE).remove("start", Long.class);
12 long duration = System.currentTimeMillis() - start;
13
14 if (duration > 1000) {
15 System.out.printf("Slow test: %s took %d ms%n",
16 context.getDisplayName(), duration);
17 }
18 }
19 }

Use it with @ExtendWith:

1 @ExtendWith(TimingExtension.class)
2 class PerformanceTests {
3 @Test
4 void potentiallySlowTest() {
5 // Test code
6 }
7 }

Extension Store for State Management:

Extensions can store state using the ExtensionContext’s Store:

1 Store store = context.getStore(


2 ExtensionContext.Namespace.create(getClass(), context.getMethod())
3 );
4 store.put("key", value);
5 Object value = store.get("key");

This store is namespaced and hierarchical, allowing extensions to maintain


state without conflicts.

Common Framework Extensions:

• MockitoExtension: Initializes mocks and injects them


Chapter 1: Testing Fundamentals in Spring Boot Projects 25

• SpringExtension: Manages Spring context and dependency injection


• TempDirectory: Provides temporary directories for file testing
• TestContainersExtension: Manages Docker containers for integration
tests
• WireMockExtension: Sets up mock HTTP servers

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.

First, let’s see a simple example with @ValueSource:

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.

We can also test invalid cases:


Chapter 1: Testing Fundamentals in Spring Boot Projects 26

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 }

For more complex scenarios, @CsvSource lets us provide multiple parameters:

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.

Test Lifecycle Annotations

JUnit 5 provides lifecycle annotations that control when setup and teardown
code runs. If you’re coming from JUnit 4, here’s the mapping:

• @BeforeClass -> @BeforeAll


• @Before -> @BeforeEach
• @After -> @AfterEach
• @AfterClass -> @AfterAll

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.

Next, we’ll get a connection before each test:

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.

Now our actual test can use the connection:

1 @Test
2 void testDatabaseOperation() {
3 // Use the connection
4 assertNotNull(connection);
5 assertTrue(connection.isValid());
6 }

After each test, we release the connection:


Chapter 1: Testing Fundamentals in Spring Boot Projects 28

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.

Finally, we shut down the pool after all tests:

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 for Better Organization

Nested tests help organize related test cases together. This is particularly
useful when testing different aspects of the same class.

First, let’s create our outer test 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.

Inside our nested class, we can group related tests:


Chapter 1: Testing Fundamentals in Spring Boot Projects 29

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.

We can add more tests to the same nested class:

1 @Test
2 @DisplayName("should sum comma-separated numbers")
3 void sumsCommaSeparatedNumbers() {
4 assertEquals(6, calculator.add("1,2,3"));
5 }

For error cases, we create a separate nested class:

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

AssertJ: Fluent Assertions for Cleaner Tests

AssertJ provides more readable assertions than JUnit’s built-in ones:

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 }

AssertJ shines with complex assertions:

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.

We can also check string contents:


Chapter 1: Testing Fundamentals in Spring Boot Projects 31

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.

For pattern matching and case-insensitive comparisons:

1 assertThat(result)
2 .matches(".*Boot.*")
3 .isEqualToIgnoringCase("spring boot testing guide");

The matches method accepts regular expressions, while isEqualToIgnoringCase is


perfect for user input validation.

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 }

The difference between contains and containsExactly is important: contains

checks that specified elements are present (in any order), while
containsExactly verifies all elements in the exact order.

We can check for sequences and exclusions:


Chapter 1: Testing Fundamentals in Spring Boot Projects 32

1 assertThat(numbers)
2 .containsSequence(2, 3, 4)
3 .doesNotContain(0, 6);

containsSequence verifies that elements appear consecutively in the collection.

Predicate-based assertions are powerful for complex conditions:

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.

For collections of objects, extracting is invaluable:

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.

We can extract multiple properties at once:


Chapter 1: Testing Fundamentals in Spring Boot Projects 33

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: Isolating Units for True Unit Tests

Mockito is essential for creating test doubles to isolate the unit under test.
Let’s explore how to use it effectively.

Basic Mocking

First, let’s create mocks manually:

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.

Next, we define how our mocks should behave:


Chapter 1: Testing Fundamentals in Spring Boot Projects 35

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”.

Now we can test our service:

1 // Create service under test


2 OrderService orderService = new OrderService(
3 paymentGateway, emailService);
4
5 // Execute test
6 Order order = orderService.placeOrder(100.0, "VISA");

The service uses our mocked dependencies, completely isolated from real
implementations.

After execution, we verify the mocks were used correctly:

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.

Finally, we assert the result:

1 // Assert result
2 assertThat(order.getTransactionId()).isEqualTo("TXN-123");
3 assertThat(order.getStatus()).isEqualTo(OrderStatus.CONFIRMED);

Using Mockito Annotations

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.

For our success scenario, we use argument matchers:

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.

Execute and verify:

1 // When
2 Order order = orderService.placeOrder(50.0, "MASTERCARD");
3
4 // Then
5 assertThat(order.isConfirmed()).isTrue();
6 verify(emailService).sendOrderConfirmation(order);

For failure scenarios, we can test exception handling:


Chapter 1: Testing Fundamentals in Spring Boot Projects 37

1 @Test
2 void shouldHandlePaymentFailure() {
3 // Given - payment fails
4 when(paymentGateway.processPayment(anyDouble(), anyString()))
5 .thenReturn(new PaymentResult(false, null));
6 }

We expect an exception when payment fails:

1 // When & Then


2 assertThrows(PaymentFailedException.class,
3 () -> orderService.placeOrder(100.0, "VISA"));

And verify that no confirmation email was sent:

1 verify(emailService, never()).sendOrderConfirmation(any());

The never() verification ensures a method wasn’t called - crucial for testing
error paths.

Advanced Mocking Techniques

Stubbing Consecutive Calls

Sometimes a method returns different values on successive calls. Mockito


handles this elegantly:

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 }

Each call returns the next value in sequence:


Chapter 1: Testing Fundamentals in Spring Boot Projects 38

1 assertEquals(42, random.nextInt(100));
2 assertEquals(17, random.nextInt(100));
3 assertEquals(99, random.nextInt(100));

This is useful for testing retry logic or state-dependent behavior.

Throwing Exceptions

To test error handling, mocks can throw exceptions:

1 @Test
2 void testExceptionThrowing() {
3 when(userRepository.findById(999L))
4 .thenThrow(new UserNotFoundException("User not found"));
5 }

Now when the service calls this method, it receives an exception:

1 assertThrows(UserNotFoundException.class,
2 () -> userService.getUser(999L));

This verifies your service properly handles repository exceptions.

Argument Captors

Sometimes you need to inspect complex objects passed to mocks. First,


create a captor:

1 @Test
2 void testArgumentCapture() {
3 ArgumentCaptor<Email> emailCaptor =
4 ArgumentCaptor.forClass(Email.class);
5 }

Execute the code that should trigger the mock:


Chapter 1: Testing Fundamentals in Spring Boot Projects 39

1 // Execute the method


2 userService.registerUser("john@example.com", "John Doe");
3
4 // Capture the argument
5 verify(emailService).sendEmail(emailCaptor.capture());

The capture() method grabs the argument passed to sendEmail(). Now inspect
it:

1 // Verify the captured email


2 Email sentEmail = emailCaptor.getValue();
3 assertThat(sentEmail.getTo()).isEqualTo("john@example.com");
4 assertThat(sentEmail.getSubject()).contains("Welcome");
5 assertThat(sentEmail.getBody()).contains("John Doe");

This ensures the email was constructed correctly with user data.

Using Spies for Partial Mocking

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 }

Real methods work normally:

1 // Use real method


2 spyList.add("one");
3 spyList.add("two");

But you can stub specific methods:

1 // Stub specific method


2 when(spyList.size()).thenReturn(100);

Now get() uses the real implementation, but size() returns our stubbed value:
Chapter 1: Testing Fundamentals in Spring Boot Projects 40

1 // Real method called


2 assertEquals("one", spyList.get(0));
3
4 // Stubbed method called
5 assertEquals(100, spyList.size());

Answer for Dynamic Stubbing

For complex stubbing logic, use Answer to calculate returns dynamically:

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:

1 assertEquals(7, calculator.add(3, 4));


2 assertEquals(15, calculator.add(10, 5));

This is useful when return values depend on input parameters.

Writing Effective Unit Tests

Let’s apply everything we’ve learned to write comprehensive unit tests for a
realistic service:

Example: Shopping Cart Service

Let’s build a realistic shopping cart to demonstrate comprehensive unit


testing. First, our domain model:
Chapter 1: Testing Fundamentals in Spring Boot Projects 41

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.

Our add item method handles quantity validation:

1 public void addItem(String productId, int quantity) {


2 if (quantity <= 0) {
3 throw new IllegalArgumentException(
4 "Quantity must be positive");
5 }
6
7 items.merge(productId,
8 new CartItem(productId, quantity),
9 (existing, newItem) ->
10 new CartItem(productId, existing.quantity + quantity)
11 );
12 }

The merge method elegantly handles both new items and quantity updates. If
the product exists, quantities are combined.

Removal is straightforward:

1 public void removeItem(String productId) {


2 items.remove(productId);
3 }

Calculating the total requires the pricing service:


Chapter 1: Testing Fundamentals in Spring Boot Projects 42

1 public double calculateTotal() {


2 return items.values().stream()
3 .mapToDouble(item -> {
4 double price = pricingService.getPrice(item.productId);
5 return price * item.quantity;
6 })
7 .sum();
8 }

This is where mocking becomes essential - we don’t want real pricing


lookups in unit tests.

A helper method counts all items:

1 public int getItemCount() {


2 return items.values().stream()
3 .mapToInt(item -> item.quantity)
4 .sum();
5 }
6
7 private record CartItem(String productId, int quantity) {}

Using a record for CartItem keeps our code concise.

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.

Test that quantities accumulate correctly:

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.

Test error handling with a simple case:


Chapter 1: Testing Fundamentals in Spring Boot Projects 44

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 }

Use parameterized tests for multiple invalid inputs:

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 }

Now test price calculations where mocking becomes essential:

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.

For multiple items, we need multiple stubs:

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 }

Each product needs its own price stub.

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);

The calculation: (2 × $10) + (1 × $25) = $45.

Test the edge case of an empty cart:


Chapter 1: Testing Fundamentals in Spring Boot Projects 46

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);

First, we add two products with different quantities.

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.

Best Practices for Unit Testing

1. Test Naming Conventions

Use descriptive names that explain what the test does:


Chapter 1: Testing Fundamentals in Spring Boot Projects 47

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() { }

2. Keep Tests Independent

Tests should not depend on each other:

1 // Bad - depends on test execution order


2 class BadTestExample {
3 private static List<String> sharedList = new ArrayList<>();
4
5 @Test
6 void firstTest() {
7 sharedList.add("item");
8 }
9
10 @Test
11 void secondTest() {
12 // This fails if run before firstTest
13 assertEquals(1, sharedList.size());
14 }
15 }
16
17 // Good - each test is independent
18 class GoodTestExample {
19 private List<String> list;
20
21 @BeforeEach
22 void setUp() {
23 list = new ArrayList<>();
24 }
25
26 @Test
27 void shouldAddItemToList() {
28 list.add("item");
29 assertEquals(1, list.size());
30 }
31 }
Chapter 1: Testing Fundamentals in Spring Boot Projects 48

3. Test One Thing at a Time

Each test should verify a single behavior. Here’s what not to do:

1 // Bad - testing multiple behaviors


2 @Test
3 void testUserService() {
4 User user = userService.createUser("John", "john@example.com");
5 assertNotNull(user.getId());
6 assertEquals("John", user.getName());
7
8 userService.updateEmail(user.getId(), "newemail@example.com");
9 assertEquals("newemail@example.com", user.getEmail());
10
11 userService.deleteUser(user.getId());
12 assertNull(userService.findById(user.getId()));
13 }

This test does too much: creates, updates, and deletes a user. If it fails, which
operation caused the problem?

Instead, write focused tests:

1 // Good - test user creation


2 @Test
3 void shouldCreateUserWithValidData() {
4 User user = userService.createUser("John", "john@example.com");
5 assertNotNull(user.getId());
6 assertEquals("John", user.getName());
7 }

This tests only user creation. Clear and focused.


Chapter 1: Testing Fundamentals in Spring Boot Projects 49

1 // Good - test email update separately


2 @Test
3 void shouldUpdateUserEmail() {
4 User user = userService.createUser("John", "john@example.com");
5 userService.updateEmail(user.getId(), "newemail@example.com");
6 User updated = userService.findById(user.getId());
7 assertEquals("newemail@example.com", updated.getEmail());
8 }

Each test has a single reason to fail, making debugging much easier.

4. Use Test Data Builders

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;

Provide sensible defaults so tests only specify what matters to them.

Add fluent methods for each property:

1 public UserTestDataBuilder withName(String name) {


2 this.name = name;
3 return this;
4 }
5
6 public UserTestDataBuilder withEmail(String email) {
7 this.email = email;
8 return this;
9 }
10
11 public UserTestDataBuilder withAge(int age) {
12 this.age = age;
13 return this;
14 }

Each method returns this for chaining.

The build method creates the actual object:


Chapter 1: Testing Fundamentals in Spring Boot Projects 50

1 public User build() {


2 return new User(name, email, age);
3 }

Now tests can create users easily:

1 // Usage in tests
2 @Test
3 void testWithBuilder() {
4 User youngUser = new UserTestDataBuilder()
5 .withAge(18)
6 .build();
7 }

Only age is specified; other fields use defaults.

1 User seniorUser = new UserTestDataBuilder()


2 .withAge(65)
3 .withName("Senior User")
4 .build();

This approach makes tests concise and highlights what’s important for each
test case.

5. Don’t Test Framework Code

Avoid testing trivial code that’s unlikely to break:

1 // Bad - testing getter/setter


2 @Test
3 void testGetterSetter() {
4 User user = new User();
5 user.setName("John");
6 assertEquals("John", user.getName());
7 }

This tests Java’s basic functionality, not your logic. It adds no value.

Instead, test business logic:


Chapter 1: Testing Fundamentals in Spring Boot Projects 51

1 // Good - test business logic


2 @Test
3 void shouldCapitalizeUserName() {
4 User user = new User("john doe");
5 user.formatName();
6 assertEquals("John Doe", user.getName());
7 }

This tests your formatName() method’s capitalization logic - actual business


value that could break if implemented incorrectly.

Summary

In this chapter, we’ve covered the fundamentals of testing in Spring Boot


projects:

Build Tool Configuration:

• 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:

• JUnit 5 provides the foundation with improved assertions and lifecycle


management
• AssertJ offers fluent, readable assertions for all data types
• Mockito enables true unit testing by isolating dependencies
• All these tools work together seamlessly in Spring Boot projects

Unit Testing Best Practices:

• Write fast, isolated, repeatable tests


Chapter 1: Testing Fundamentals in Spring Boot Projects 52

• Follow the AAA (Arrange-Act-Assert) or Given/When/Then pattern


• Test one behavior per test method
• Use descriptive test names
• Keep tests independent of each other

These fundamentals apply to all testing in Spring Boot, whether you’re


writing pure unit tests or integration tests.

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.

Understanding Test Slices

The Concept of Context Slicing

When we write tests for our Spring Boot applications, we often don’t need
the entire application context.

Testing a REST controller doesn’t require JPA repositories, and testing a


repository doesn’t need web controllers.

Spring Boot’s test slices load only the relevant parts of the application
context for specific testing scenarios.

Think of test slices as focused views into our application:


Chapter 2: Testing with a Sliced Application Context 54

1 // Full context - loads everything


2 @SpringBootTest
3 class FullContextTest {
4 // Entire application context is loaded
5 }
6
7 // Sliced context - loads only web layer
8 @WebMvcTest
9 class WebLayerTest {
10 // Only web-related beans are loaded
11 }

Benefits of Focused Testing

Test slices provide several advantages:

1. Faster Test Execution: Loading fewer beans means quicker context


startup
2. Clearer Test Intent: The test annotation immediately communicates
what’s being tested
3. Reduced Memory Usage: Smaller contexts consume less memory
4. Better Isolation: Fewer components mean fewer potential side effects
5. Easier Debugging: Less noise when troubleshooting test failures

Available Test Slice Annotations

Spring Boot provides numerous test slice annotations, each targeting spe-
cific layers:
Chapter 2: Testing with a Sliced Application Context 55

Annotation Purpose Key


Auto-Configurations
@WebMvcTest Test Spring MVC Web layer, MockMvc
controllers
@WebFluxTest Test Spring WebFlux Reactive web layer
controllers
@DataJpaTest Test JPA repositories JPA, Hibernate,
DataSource
@DataMongoTest Test MongoDB MongoDB components
repositories
@JsonTest Test JSON serialization Jackson ObjectMapper
@RestClientTest Test REST clients RestTemplate,
WebClient
@DataRedisTest Test Redis operations Redis repositories
@JdbcTest Test JDBC operations JdbcTemplate,
DataSource

… and much more, consult the documentation for a complete list.

When to Use Sliced Testing vs. Full Context Testing

Use sliced tests when:

• Testing a specific layer in isolation


• We need fast feedback during development
• Testing component-specific behavior
• Validating configuration for a particular layer

Use full context tests (@SpringBootTest) when:

• Testing integration between multiple layers


• Validating the complete request-response flow
• Testing application startup and configuration
• Verifying production-like scenarios
Chapter 2: Testing with a Sliced Application Context 56

Testing Web Controllers with @WebMvcTest

The @WebMvcTest annotation is perhaps the most commonly used test slice,
allowing us to test Spring MVC controllers efficiently.

Why Unit Testing Controllers Is Not Effective

Before we dive into using @WebMvcTest, it’s important to understand why tradi-
tional unit testing of controllers falls short.

When we unit test a controller in isolation, we miss critical aspects of how


Spring Boot actually processes requests:

1. Request Mapping: Unit tests don’t verify that our @RequestMapping,

@GetMapping, or other mapping annotations are correctly configured. A


typo in the path or incorrect HTTP method won’t be caught.
2. Validation: Spring’s validation framework (@Valid, @Validated) requires
the full web infrastructure to work. Unit tests bypass this entirely, miss-
ing validation errors that would occur in production.
3. Type Conversion: Spring automatically converts request parameters
and path variables to the appropriate types. Unit tests don’t exercise
these conversions, potentially missing conversion failures.
4. Security: Security configurations, authentication, and authorization are
handled by Spring Security filters that don’t exist in unit tests. We can’t
verify that endpoints are properly secured.
5. Exception Handling: Global exception handlers (@ControllerAdvice) and
error mapping won’t be invoked in unit tests, leaving error handling
untested.
6. Content Negotiation: Spring’s content negotiation (handling different
media types) requires the web infrastructure to function properly.

Here’s an example of what we miss with unit testing:


Chapter 2: Testing with a Sliced Application Context 57

1 // This unit test passes but misses many issues


2 @Test
3 void unitTestMissesImportantBehavior() {
4 BookshelfController controller = new BookshelfController(mockService);
5
6 // This test doesn't verify:
7 // - Is the endpoint actually mapped to /books?
8 // - Does validation work on the parameters?
9 // - Is the endpoint secured?
10 // - Does the JSON serialization work correctly?
11 String result = controller.listBooks(new Model());
12
13 assertEquals("listBooks", result);
14 }

This is why Spring Boot provides @WebMvcTest - to test controllers with the
necessary web infrastructure while still keeping tests focused and fast.

Setting Up Controller Tests

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 }

This controller depends on a BookshelfService which will handle the business


logic. We inject it through the constructor for better testability.

Next, let’s add an endpoint to list all books:


Chapter 2: Testing with a Sliced Application Context 58

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.

For adding new books, we need a form display endpoint:

1 @GetMapping("/books/add")
2 public String showAddBookForm() {
3 return "addBook";
4 }

This simply returns the view name for the add book form.

Finally, we handle form submission with a POST endpoint:

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.

Now let’s test our controller with @WebMvcTest.

First, we set up the test class:


Chapter 2: Testing with a Sliced Application Context 59

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.

Let’s test the book listing endpoint:

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.

Now we perform the request and verify the response:

1 // When & Then


2 mockMvc.perform(get("/books"))
3 .andExpect(status().isOk())
4 .andExpect(view().name("listBooks"))
5 .andExpect(model().attribute("books", books));

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.

Testing the form display is simpler:


Chapter 2: Testing with a Sliced Application Context 60

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.

Testing Form Submissions

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.

Now let’s perform the POST request with form parameters:


Chapter 2: Testing with a Sliced Application Context 61

1 // When & Then


2 mockMvc.perform(post("/books/add")
3 .param("title", "Clean Code")
4 .param("author", "Robert Martin")
5 .param("isbn", "978-0132350884"))
6 .andExpect(status().is3xxRedirection())
7 .andExpect(redirectedUrl("/books"));

The param() method simulates form fields. We expect a redirect status (3xx)
and verify the redirect URL.

Finally, verify the service was called with correct parameters:

1 verify(bookshelfService).addBook("Clean Code", "Robert Martin", "978-0132350884");

This ensures our controller passed the correct values to the service.

We should also test validation errors:

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.

Verify the service was never called:

1 verify(bookshelfService, never()).addBook(anyString(), anyString(), anyString());

The never() verification ensures that when validation fails, the service
method isn’t invoked.
Chapter 2: Testing with a Sliced Application Context 62

Testing REST API Endpoints

REST API testing requires handling JSON content and HTTP status codes.

Let’s organize our tests using nested classes:

1 @Nested
2 @DisplayName("POST /api/books endpoint tests")
3 class CreateBookTests {

Nested classes help organize related tests together, making test reports more
readable.

First, let’s test successful book creation:

1 @Test
2 @DisplayName("Should return 201 Created when valid book data is provided")
3 void shouldReturnCreatedWhenValidBookData() throws Exception {

Prepare the JSON request body:

1 String validBookJson = """


2 {
3 "isbn": "9781234567890",
4 "title": "Test Book",
5 "author": "Test Author",
6 "publishedDate": "2023-01-01"
7 }
8 """;

Java’s text blocks (triple quotes) make JSON more readable in tests.

Configure the mock service:

1 when(bookService.createBook(any())).thenReturn(1L);

The service returns the ID of the created book.

Perform the POST request and verify the response:


Chapter 2: Testing with a Sliced Application Context 63

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.

Now let’s test validation errors with invalid data:

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 """;

Empty ISBN and title should trigger validation errors.

Perform the request and expect a bad request response:

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.

Let’s test a specific validation rule - future dates:


Chapter 2: Testing with a Sliced Application Context 64

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);

Calculate tomorrow’s date dynamically to ensure the test always uses a


future date.

Build the JSON with the future date:

1 String futureDateJson = String.format("""


2 {
3 "isbn": "9781234567890",
4 "title": "Test Book",
5 "author": "Test Author",
6 "publishedDate": "%s"
7 }
8 """, futureDate);

String.format() injects the calculated date into the JSON.

Test the validation:

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.

Testing Path Variables and Request Parameters

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.

Configure the mock service:

1 when(bookshelfService.searchBooks("clean")).thenReturn(searchResults);

The service will return our test books when searching for “clean”.

Perform the search request with query parameter:

1 // When & Then


2 mockMvc.perform(get("/books/search")
3 .param("query", "clean"))
4 .andExpect(status().isOk())
5 .andExpect(view().name("searchResults"));

The .param() method adds query parameters to the request.

Verify the model contains both results and the query:

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.

Testing exception handling is crucial for robust applications:


Chapter 2: Testing with a Sliced Application Context 66

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 }

We configure the mock to throw a custom exception when a non-existent


book is requested.

Test the error handling:

1 // When & Then


2 mockMvc.perform(get("/books/99"))
3 .andExpect(status().isOk())
4 .andExpect(view().name("error"))
5 .andExpect(model().attribute("message", "Book with id 99 not found"));

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.

Test invalid path variable types:

1 @Test
2 void shouldHandleInvalidBookId() throws Exception {
3 // When & Then
4 mockMvc.perform(get("/books/invalid"))
5 .andExpect(status().isBadRequest());
6 }

“invalid” can’t be converted to a Long, so Spring returns 400 Bad Request


automatically.

Testing Model Attributes and View Rendering

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.

Configure the service mock:

1 when(bookshelfService.findById(1L)).thenReturn(book);

Now test the controller:

1 // When & Then


2 mockMvc.perform(get("/books/1"))
3 .andExpect(status().isOk())
4 .andExpect(view().name("bookDetail"));

The path variable is replaced with 1.

Verify model attributes:

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.

For testing pagination, first create test data:


Chapter 2: Testing with a Sliced Application Context 68

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 }

We use Java streams to generate 25 test books efficiently.

Configure pagination mock:

1 when(bookshelfService.findAllBooks(0, 10))
2 .thenReturn(books.subList(0, 10));

The service returns the first 10 books for page 0.

Test with pagination parameters:

1 // When & Then


2 mockMvc.perform(get("/books")
3 .param("page", "0")
4 .param("size", "10"))
5 .andExpect(status().isOk())
6 .andExpect(view().name("listBooks"));

Pagination parameters are passed as query parameters.

Verify pagination model attributes:

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.

Testing Security Configuration

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;

The @Import(SecurityConfig.class) is crucial - without it, @WebMvcTest won’t load


security configurations and all requests would pass through unsecured. This
ensures our tests accurately reflect production security behavior.

Organize security tests with @Nested:

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());

This test has no @WithMockUser, simulating an anonymous request. Spring Se-


curity should immediately reject it with 401 (Unauthorized) before reaching
the controller. This verifies our security configuration is active.

The @WithMockUser annotation provides authentication for secured endpoints


and is coming from the spring-security-test dependency:
Chapter 2: Testing with a Sliced Application Context 70

1 <dependency>
2 <groupId>org.springframework.security</groupId>
3 <artifactId>spring-security-test</artifactId>
4 <scope>test</scope>
5 </dependency>

Verify the service wasn’t called:

1 verify(bookService, times(0)).deleteBook(any());

Crucial verification - the service should never be called when authentication


fails. This ensures security is enforced at the framework level, not in our
business logic.

Test 2: Insufficient privileges

1 @Test
2 @WithMockUser(roles = "USER")
3 @DisplayName("Should return 403 with insufficient privileges")
4 void shouldReturnForbiddenWhenInsufficientPrivileges()
5 throws Exception {

@WithMockUser(roles = "USER") creates an authenticated user with the USER role.


This tests authorization - the user is logged in but lacks the ADMIN role
required for deletion.

User role can’t delete books:

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.

Test 3: Admin can delete existing book


Chapter 2: Testing with a Sliced Application Context 71

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.

Mock successful deletion:

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.

Verify service was called:

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.

Test 4: Admin tries to delete non-existent book

1 @Test
2 @WithMockUser(roles = "ADMIN")
3 @DisplayName("Should return 404 when book doesn't exist")
4 void shouldReturnNotFoundWhenBookDoesNotExist()
5 throws Exception {

Mock deletion failure:


Chapter 2: Testing with a Sliced Application Context 72

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);

This comprehensive test demonstrates several important security testing


patterns:

1. Testing unauthenticated access: Verifying that endpoints are properly


secured
2. Testing insufficient privileges: Ensuring role-based access control
works correctly
3. Testing authorized access: Confirming that users with proper roles can
access endpoints
4. Verifying service interactions: Ensuring the service is only called when
authorization succeeds

Testing Data Access with @DataJpaTest

The @DataJpaTest annotation focuses on JPA components, providing a stream-


lined way to test repositories.

Repository Testing Strategies

When testing repositories, we focus on:

• Custom query methods


• Complex JPQL or native queries
• Repository specifications
• Transaction behavior

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.

For more complex queries, we use JPQL:

1 @Query("SELECT b FROM Book b WHERE LOWER(b.title) LIKE LOWER(CONCAT('%', :search\


2 , '%')) " +
3 "OR LOWER(b.author) LIKE LOWER(CONCAT('%', :search, '%'))")
4 Page<Book> searchBooks(@Param("search") String search, Pageable pageable);

This custom query searches both title and author fields. The LOWER() function
ensures case-insensitive matching. The Page return type enables pagination
support.

For data modification, we need @Modifying:

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.

Working with Test Databases

@DataJpaTest provides a focused testing environment for JPA components.

An important feature of @DataJpaTest is that it includes the @Transactional an-


notation. This means each test method runs within a transaction that’s
automatically rolled back after the test completes. This rollback behavior
Chapter 2: Testing with a Sliced Application Context 74

ensures that all tests start with a clean database state, eliminating the need
to manually delete test data between tests.

Let’s set up a basic test:

1 @DataJpaTest
2 class BookRepositoryTest {
3
4 @Autowired
5 private TestEntityManager entityManager;
6
7 @Autowired
8 private BookRepository bookRepository;
9 }

@DataJpaTest automatically configures an in-memory H2 database, scans


for @Entity classes, and configures Spring Data JPA repositories. The
TestEntityManager is a test-specific wrapper around JPA’s EntityManager.

Let’s test our findByIsbn method:

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.

Persist the test data:

1 entityManager.persistAndFlush(book);

The persistAndFlush() method saves the entity and immediately synchronizes


with the database. This ensures the data is available for queries.

Test the repository method:


Chapter 2: Testing with a Sliced Application Context 75

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

The TestEntityManager provides test-specific methods for managing entities.


Let’s test author search:

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 }

We create three books - two by Martin, one by Bloch.

Persist all books:

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.

Test the case-insensitive search:


Chapter 2: Testing with a Sliced Application Context 76

1 // When
2 List<Book> martinBooks = bookRepository
3 .findByAuthorContainingIgnoreCase("martin");

Searching for “martin” (lowercase) should find “Robert C. Martin” (mixed


case).

Verify the results:

1 // Then
2 assertThat(martinBooks).hasSize(2);
3 assertThat(martinBooks).extracting(Book::getTitle)
4 .containsExactlyInAnyOrder("Clean Code", "The Clean Coder");

containsExactlyInAnyOrder() is perfect here - we care about which books are


returned, not their order. Database queries without ORDER BY can return
results in any sequence.

Testing Custom Repository Methods

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.

Perform the search with pagination:


Chapter 2: Testing with a Sliced Application Context 77

1 // When
2 Page<Book> results = bookRepository.searchBooks("spring",
3 PageRequest.of(0, 10));

The search is case-insensitive, so “spring” should match “Spring”.

Verify results and pagination:

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.

Now let’s test the update query:

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 }

We start with a borrow count of 5.

Execute the update:

1 // When
2 bookRepository.incrementBorrowCount(saved.getId());
3 entityManager.flush();
4 entityManager.clear(); // Clear persistence context

The clear() is crucial - it evicts all entities from Hibernate’s cache.

Verify the increment:


Chapter 2: Testing with a Sliced Application Context 78

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.

Testing with Real Databases using Testcontainers

For more realistic testing, we use Testcontainers.

Introduction to Testcontainers

Testcontainers is a Java library that provides lightweight, throwaway in-


stances of databases, message brokers, web browsers, or anything else that
can run in a Docker container. It revolutionizes integration testing by allow-
ing us to test against real instances of external dependencies.

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

Adding Testcontainers to Your Project

First, add the core Testcontainers dependency:


Chapter 2: Testing with a Sliced Application Context 79

1 <dependency>
2 <groupId>org.testcontainers</groupId>
3 <artifactId>testcontainers</artifactId>
4 <version>1.19.0</version>
5 <scope>test</scope>
6 </dependency>

This provides the base functionality for managing Docker containers.

Next, add the database-specific module:

1 <dependency>
2 <groupId>org.testcontainers</groupId>
3 <artifactId>postgresql</artifactId>
4 <version>1.19.0</version>
5 <scope>test</scope>
6 </dependency>

The PostgreSQL module includes pre-configured containers and connection


helpers for PostgreSQL databases.

Testcontainers in Action

First, set up the test class:

1 @DataJpaTest
2 @Testcontainers
3 @AutoConfigureTestDatabase(replace =
4 AutoConfigureTestDatabase.Replace.NONE)
5 class BookRepositoryTest {

@AutoConfigureTestDatabase(replace = NONE) tells Spring Boot not to replace our


database with H2. We want to use the real PostgreSQL provided by Testcon-
tainers. This gives us database-specific behavior that H2 might not support.

Configure PostgreSQL container with @ServiceConnection:


Chapter 2: Testing with a Sliced Application Context 80

1 @Container
2 @ServiceConnection
3 static PostgreSQLContainer<?> postgres =
4 new PostgreSQLContainer<>("postgres:16-alpine")
5 .withDatabaseName("testdb")
6 .withUsername("test")
7 .withPassword("test");

@ServiceConnection (Spring Boot 3.1+) automatically configures the datasource


to use this container. The container is static and starts once for all tests in
the class. We use Alpine Linux for a smaller image and faster startup.

Inject the repository and ensure clean state:

1 @Autowired
2 private BookRepository cut;

The variable name cut (Class Under Test) clearly identifies what we’re testing.

Organize tests with @Nested:

1 @Nested
2 @DisplayName("findByIsbn tests")
3 class FindByIsbnTests {

Test 1: Find existing book

Create test data:

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.

Set additional properties and save:


Chapter 2: Testing with a Sliced Application Context 81

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.

Find and verify:

1 // Act
2 Optional<Book> result = cut.findByIsbn(isbn);
3
4 // Assert
5 assertThat(result).isPresent();

Use satisfies for multiple assertions:

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.

Test 2: Book not found


Chapter 2: Testing with a Sliced Application Context 82

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.

Search for non-existent ISBN:

1 // Act
2 Optional<Book> result = cut.findByIsbn("9780987654321");
3
4 // Assert
5 assertThat(result).isEmpty();

Test isolation verification:

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.

Key advantages of Testcontainers:

1. Real database behavior: Tests run against actual PostgreSQL


Chapter 2: Testing with a Sliced Application Context 83

2. @ServiceConnection: Automatic configuration in Spring Boot 3.1+


3. Test isolation: Each test starts clean
4. Production parity: Catch database-specific issues early

Best Practices for Custom Test Slices

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.

The @SpringBootTest annotation loads the complete application context, en-


abling comprehensive integration testing that closely mimics production
behavior.

Full Application Context Testing

When to Use Full Context Tests

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.

Use @SpringBootTest when:

• Testing the complete request-to-response flow


• Verifying component interactions across layers
• Testing application startup and configuration
• Validating production-like scenarios
• Testing features that require the full Spring context

Configuring the Application Context for Tests

@SpringBootTest offers several configuration options.

Let’s explore a comprehensive example:


Chapter 3: Testing with @SpringBootTest 85

1 @SpringBootTest(
2 webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT
3 )
4 class BookshelfApplicationIT {
5 // Test implementation
6 }

This configuration showcases @SpringBootTest’s flexibility:

• RANDOM_PORT starts a real embedded servlet container (usually Tomcat) on


an available port, preventing conflicts when tests run in parallel

The webEnvironment options include:

• MOCK (default): Loads a web ApplicationContext with a mock servlet envi-


ronment
• RANDOM_PORT: Starts an embedded server on a random port
• DEFINED_PORT: Starts an embedded server on the port defined in properties
• NONE: Loads an ApplicationContext without any web environment

Managing Context Caching for Performance

Understanding Context Caching Theory

Spring Test framework implements an intelligent caching mechanism for


application contexts. When a test runs, Spring creates an application context
and caches it for reuse.

The cache key is based on:

1. Configuration classes: The @SpringBootTest classes or @ContextConfiguration


2. Active profiles: Set via @ActiveProfiles
3. Properties: Defined in @TestPropertySource or test properties
4. Context customizers: Including @MockBean, @SpyBean, and custom test con-
figurations
Chapter 3: Testing with @SpringBootTest 86

5. … and further context customizations, see the docs.

Here’s how context caching works in practice:

1 // These tests share the same context (identical configuration)


2 @SpringBootTest
3 class BookshelfServiceIT{ }
4
5 @SpringBootTest
6 class BookRepositoryIT { }

The first two tests have identical configurations, so they share one context.
The second test runs much faster because it reuses the cached context.

Here’s what breaks context caching:

1 // Different context due to different properties


2 @SpringBootTest(properties = "spring.jpa.show-sql=true")
3 class DebugBookServiceIT { }

Different properties mean different application behavior, forcing a new


context.

Mocking also breaks caching:

1 // Different context due to @MockBean


2 @SpringBootTest
3 class BookControllerIT {
4 @MockBean
5 private ExternalBookService externalService;
6 }

@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.

Optimizing for Context Caching

1. Create a standardized base test configuration:

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.

Add a shared database container:

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.

Now multiple test classes can share this configuration:

1 class BookServiceIT extends BaseIntegrationTest {


2 @Autowired
3 private BookService bookService;
4
5 @Test
6 void shouldCreateBook() {
7 // This test reuses the cached context
8 }
9 }

This test class inherits the base configuration and gets fast context startup.
Chapter 3: Testing with @SpringBootTest 88

1 class BookControllerIT extends BaseIntegrationTest {


2 @Autowired
3 private TestRestTemplate restTemplate;
4
5 @Test
6 void shouldGetBooks() {
7 // This test also reuses the same cached context
8 }
9 }

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.

2. Understanding what breaks context caching:

Here’s an anti-pattern that breaks caching:

1 // Different context due to @MockBean


2 @SpringBootTest
3 class DifferentContextWithMockTest {
4 @MockBean
5 private BookRepository bookRepository; // Creates new context!
6
7 @Test
8 void testWithMock() {
9 // This test has its own context, slower startup
10 }
11 }

Each @MockBean creates a unique context because Spring must create a custom
configuration with that specific mock.

Property differences also break caching:


Chapter 3: Testing with @SpringBootTest 89

1 // Different context due to properties


2 @SpringBootTest(properties = "spring.jpa.show-sql=true")
3 class DifferentContextWithPropertyTest {
4 @Test
5 void testWithSqlLogging() {
6 // Different property = different context
7 }
8 }

Different properties mean different application behavior, requiring a new


context. If you have 10 test classes each with different @MockBean annotations,
you’ll have 10 different contexts, significantly slowing down your test suite.

3. Smart mocking strategy to preserve context caching:

Instead of using @MockBean which breaks caching, use @TestConfiguration:

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 pattern provides mocks without breaking context caching.


@TestConfiguration loads only for tests, while @Primary ensures the mock
takes precedence over the real bean. Since this configuration is part of the
base class, all subclasses share the same mock setup and cached context.

This approach:

• Maintains context caching


• Provides mock functionality
• Reduces test execution time
• Saves memory

3. Use @DirtiesContext judiciously:


Chapter 3: Testing with @SpringBootTest 90

1 // Only use when absolutely necessary


2 @Test
3 @DirtiesContext // Forces context recreation after this test
4 void shouldModifyApplicationState() {
5 // Test that modifies singleton beans or system properties
6 }

@DirtiesContext is the nuclear option - it marks the context as dirty, forcing


Spring to discard it and create a new one for the next test.

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.

4. Monitor context creation:

1 # Enable logging to see context caching


2 logging.level.org.springframework.test.context.cache=DEBUG

This debug logging is invaluable for optimizing test performance.

Customizing the Application Context

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.

Replace expensive password encoding for tests:


Chapter 3: Testing with @SpringBootTest 91

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.

This speeds up tests significantly since password hashing is computationally


expensive. The @Primary ensures this test bean overrides the production one.

Using @TestPropertySource for external configuration:

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 {

@TestPropertySource provides two ways to override properties: external files and

inline properties. The inline properties take precedence over the file.

Inject and verify properties:

1 @Value("${app.book.import.enabled}")
2 private boolean bookImportEnabled;
3
4 @Test
5 void shouldDisableBookImportInTests() {
6 assertThat(bookImportEnabled).isFalse();
7 }

This example switches to an in-memory H2 database for tests and disables a


book import feature that might slow down tests.

The @Value annotation injects the property value so we can verify it’s correctly
set.
Chapter 3: Testing with @SpringBootTest 92

Testing Different Application Layers Together

End-to-End Flow Testing

Full context tests excel at verifying complete workflows. Here’s a compre-


hensive integration test setup:

1 class BookServiceIT extends BaseIntegrationTest {


2
3 @Autowired
4 private BookService bookService;
5
6 @Autowired
7 private BookRepository bookRepository;

We use real Spring beans for internal components to test the actual applica-
tion flow.

Mock external dependencies:

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.

Now let’s test the complete workflow:

1 @Test
2 @DisplayName("Should create book with metadata from external API")
3 void shouldCreateBookWithMetadata() {
4 // Arrange
5 String isbn = "9780134685991";

The test name clearly describes the scenario we’re testing.

Create a realistic request:


Chapter 3: Testing with @SpringBootTest 93

1 BookCreationRequest request = new BookCreationRequest(


2 isbn, "Effective Java", "Joshua Bloch",
3 LocalDate.of(2018, 1, 6)
4 );

Using a real ISBN and actual book data makes the test more realistic and can
help catch edge cases with data validation.

Set up the mock to return metadata:

1 BookMetadataResponse metadata = new BookMetadataResponse();


2 metadata.setCoverUrl("https://covers.openlibrary.org/...");

We create the expected response from the external API.

Configure the mock behavior:

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.

Execute the service method and verify the full flow:

1 // Act
2 Long bookId = bookService.createBook(request);
3
4 // Assert - Check service layer
5 assertThat(bookId).isNotNull();

We verify that the service returns a valid book ID.

Verify database persistence:


Chapter 3: Testing with @SpringBootTest 94

1 // Assert - Verify database persistence


2 Book savedBook = bookRepository.findById(bookId).orElseThrow();
3 assertThat(savedBook.getIsbn()).isEqualTo(isbn);
4 assertThat(savedBook.getThumbnailUrl())
5 .isEqualTo(metadata.getCoverUrl());

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.

Controller-to-Database Integration Tests

Testing the full stack from HTTP request to database using TestRestTemplate.

First, set up the test infrastructure:

1 class BookControllerIntegrationIT extends BaseIntegrationTest {


2
3 @Autowired
4 private TestRestTemplate restTemplate;
5
6 @Autowired
7 private BookRepository bookRepository;

TestRestTemplate is Spring’s test-friendly HTTP client that handles authentica-


tion and follows redirects by default.

Test the complete REST API flow:


Chapter 3: Testing with @SpringBootTest 95

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.

Set up authentication headers:

1 HttpHeaders headers = new HttpHeaders();


2 headers.setContentType(MediaType.APPLICATION_JSON);
3 headers.setBasicAuth("user", "password");

We set the Content-Type to tell the server we’re sending JSON. Basic authen-
tication is added for secured endpoints.

Combine body and headers:

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.

Make the HTTP request and verify:


Chapter 3: Testing with @SpringBootTest 96

1 // Act
2 ResponseEntity<Void> response = restTemplate.exchange(
3 baseUrl + "/api/books",
4 HttpMethod.POST,
5 request,
6 Void.class
7 );

We perform the actual HTTP POST request to create the book.

Verify the HTTP response:

1 // Assert - HTTP response


2 assertThat(response.getStatusCode())
3 .isEqualTo(HttpStatus.CREATED);
4 assertThat(response.getHeaders().getLocation())
5 .isNotNull();

We check for the correct status code and Location header.

Verify database state:

1 // Assert - Database state


2 assertThat(bookRepository.count()).isEqualTo(1);
3 Book savedBook = bookRepository.findAll().get(0);
4 assertThat(savedBook.getIsbn()).isEqualTo("9780134685991");

Finally, we verify that the book was actually saved to the database with the
correct data.

Testing with Mock External Services

Introduction to WireMock

WireMock is a library for stubbing and mocking HTTP services. It helps us


test components that depend on external HTTP APIs without actually calling
those services. This is crucial for:

1. Test Isolation: Tests don’t depend on external service availability


Chapter 3: Testing with @SpringBootTest 97

2. Predictable Responses: Control exactly what the external service re-


turns
3. Error Simulation: Test how your code handles various error scenarios
4. Performance: No network latency in tests

Adding WireMock to Your Project

Add the WireMock dependency to your test dependencies:

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.

Basic WireMock Usage

WireMock can be used as a JUnit extension or started programmatically.


Here’s a comprehensive example testing an OpenLibrary API client:

1 class OpenLibraryApiClientTest {
2 @RegisterExtension
3 static WireMockExtension wireMockServer =
4 WireMockExtension.newInstance()
5 .options(wireMockConfig().dynamicPort())
6 .build();
7
8 private OpenLibraryApiClient cut;

@RegisterExtension integrates WireMock with JUnit 5’s lifecycle.

Configure your client to use the mock server:


Chapter 3: Testing with @SpringBootTest 98

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. dynamicPort() avoids port conflicts when tests run in parallel


2. Point the WebClient to WireMock’s URL instead of the real API
3. Create a fresh client for each test to ensure isolation

Testing Successful Responses

First, set up a successful response stub:

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 );

The withBodyFile method reads JSON from src/test/resources/__files/.

Create the response file (9780132350884-success.json):


Chapter 3: Testing with @SpringBootTest 99

1 {
2 "isbn_13": ["9780132350884"],
3 "title": "Clean Code",
4 "publishers": ["Prentice Hall"],
5 "number_of_pages": 431
6 }

This simulates what the real OpenLibrary API would return.

Now execute and verify the test:

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);

Verify that the client correctly parses the mocked response.

Testing Error Scenarios

Testing error handling is equally important. Configure WireMock to return


an error:

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 );

WireMock returns a 500 status with an error message.

Verify your client handles the error appropriately:


Chapter 3: Testing with @SpringBootTest 100

1 // Act & Assert


2 WebClientResponseException exception = assertThrows(
3 WebClientResponseException.class,
4 () -> cut.getBookByIsbn(isbn)
5 );
6
7 assertThat(exception.getStatusCode().value()).isEqualTo(500);

This ensures your application gracefully handles external service failures.

Testing Network Issues

WireMock can simulate network problems like slow responses:

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 );

withFixedDelay simulates network latency or slow external services.

Verify your application handles delays gracefully:

1 // Test should still pass with delay


2 BookMetadataResponse result = cut.getBookByIsbn(isbn);
3 assertThat(result).isNotNull();
4 assertThat(result.getTitle()).isEqualTo("Clean Code");

This ensures your timeout and retry configurations work correctly.

Best Practices for @SpringBootTest

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

By mastering @SpringBootTest, we can write comprehensive integration tests


that give us confidence our application works correctly as a whole, while
still maintaining reasonable test execution times through careful design and
configuration.
Chapter 4: Testing Pitfalls and Best
Practices
Throughout our testing journey, we’ve explored the powerful tools and tech-
niques Spring Boot offers.

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.

Common Testing Anti-Patterns

Recognizing and Avoiding Testing Anti-Patterns

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.

Over-Mocking: The Isolation Trap

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.

Why Over-Mocking Hurts

1. False Confidence: Tests pass even when integration would fail


Chapter 4: Testing Pitfalls and Best Practices 103

2. Brittle Tests: Any refactoring breaks tests, even if behavior is unchanged


3. Maintenance Burden: Updating mocks becomes more work than the
actual code
4. Lost Integration Issues: Problems between components go undetected
5. Unclear Intent: It’s hard to understand what the test actually verifies

1 // Anti-pattern: Over-mocked test


2 @ExtendWith(MockitoExtension.class)
3 class BookshelfServiceOverMockedTest {
4 @Mock private BookRepository bookRepository;
5 @Mock private UserRepository userRepository;
6 @Mock private BorrowingValidator validator;
7 @Mock private NotificationService notificationService;
8 // ... 4 more mocks!

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.

The test setup becomes overwhelming:

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

Here we see the problem escalate.

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 test gets lost in the noise:

1 BorrowResult result = bookshelfService.borrowBook(1L, 1L);


2
3 // Verify mocks were called - but did it actually work?
4 verify(validator).canBorrow(any(), any());

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.

Guidelines for Effective Mocking

1. Mock External Dependencies Only: Databases, web services, email


servers
2. Use Real Components When Possible: Let Spring wire real beans
3. Prefer Test Slices: Use @DataJpaTest, @WebMvcTest for focused testing
4. Mock at System Boundaries: Not between your own components
Chapter 4: Testing Pitfalls and Best Practices 105

1 // Better: Integration test with selective mocking


2 @SpringBootTest
3 @Transactional
4 class BookshelfServiceIT {
5 @Autowired private BookshelfService bookshelfService;
6 @Autowired private BookRepository bookRepository;
7
8 @MockBean private EmailService emailService; // Only mock external

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.

Testing Implementation Details

The Problem: Tests that verify internal implementation make refactoring


difficult and don’t ensure correct behavior.

Signs You’re Testing Implementation

1. Using Reflection: Accessing private fields or methods


2. Verifying Method Calls: Testing HOW instead of WHAT
3. Spy Objects: Spying on the class under test
4. Exact Call Counts: Counting internal method invocations
5. White-Box Testing: Tests that know too much about internals
Chapter 4: Testing Pitfalls and Best Practices 106

1 // Anti-pattern: Testing HOW it works, not WHAT it does


2 @Test
3 void shouldProcessBookReturnUsingSpecificMethods() {
4 BookshelfService service = spy(bookshelfService);
5
6 service.returnBook(1L);

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.

This is problematic because we’re now testing the internal implementation


rather than the external behavior. If we refactor the service to process
returns differently, this test will break even if the functionality remains
correct.

Verifying internal method calls:

1 // Bad: Testing implementation details


2 verify(service).validateBookReturn(any());
3 verify(service).calculateLateFees(any());

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.

Using reflection to access private fields:

1 // Worse: Breaking encapsulation


2 Field cache = BookshelfService.class
3 .getDeclaredField("recentReturns");
4 cache.setAccessible(true);
5 // Don't do this!

This is the worst anti-pattern: using Java reflection to access private fields.
The getDeclaredField() method retrieves a private field, and setAccessible(true)

bypasses Java’s access control.

This completely breaks encapsulation - one of the fundamental principles of


object-oriented programming. Private fields are private for a reason!
Chapter 4: Testing Pitfalls and Best Practices 107

The Solution: Test behavior and outcomes, not implementation.

How to Test Behavior Correctly

1. Focus on Public API: Only test through public methods


2. Verify Outcomes: Check return values and side effects
3. Test State Changes: Verify database or external changes
4. Ignore Implementation: Don’t care HOW it’s done
5. Black-Box Testing: Treat the component as a black box

1 // Better: Testing WHAT it does, not HOW


2 @Test
3 void shouldCalculateLateFeeForOverdueBook() {
4 // Given - Setup scenario
5 Book book = createOverdueBook(10); // 10 days overdue

This better approach focuses on behavior. We set up a test scenario using a


helper method createOverdueBook() that creates a book that was due 10 days ago.

We don’t care how the service calculates late fees internally - we only care
that it produces the correct result.

Call the public API:

1 // When - Call public API


2 ReturnResult result = bookshelfService.returnBook(book.getId());

We interact with the service through its public API only. The returnBook()

method is what external code would call, so that’s what we test.

The method returns a result object that contains all the information about
the return operation.

Verify the outcomes:


Chapter 4: Testing Pitfalls and Best Practices 108

1 // Then - Verify outcomes


2 assertThat(result.isOverdue()).isTrue();
3 assertThat(result.getLateFee()).isEqualTo("5.00");

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.

Check side effects in the database:

1 // Verify side effects


2 Book returned = bookRepository.findById(book.getId())
3 .orElseThrow();
4 assertThat(returned.isBorrowed()).isFalse();

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 Fragile Test

The Problem: Tests that break with minor, unrelated changes indicate tight
coupling to implementation.

What Makes Tests Fragile

1. Exact String Matching: Testing precise formatting


2. Order Dependencies: Assuming specific ordering
3. Timing Assumptions: Hard-coded delays or timeouts
4. Environmental Dependencies: Tests that only work on specific OS/lo-
cale
5. Shared State: Tests that depend on other tests
Chapter 4: Testing Pitfalls and Best Practices 109

1 // Anti-pattern: Breaks if formatting changes slightly


2 @Test
3 void shouldFormatBookDetailsInSpecificFormat() {
4 Book book = new Book("Clean Code", "Robert Martin",
5 "978-0132350884");

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.

Get the formatted result:

1 String result = bookshelfService.formatBookDetails(book);

The service returns a formatted string representation of the book. How


this string is formatted is an implementation detail that might change for
aesthetic reasons without affecting functionality.

Exact string matching is fragile:

1 // Bad: Will break if spacing or order changes


2 assertThat(result).isEqualTo(
3 "Title: Clean Code\n" +
4 "Author: Robert Martin\n" +
5 "ISBN: 978-0132350884"
6 );

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.

The Solution: Test essential characteristics, not exact formatting.

Making Tests Resilient

1. Test Content, Not Format: Verify information is present


2. Use Flexible Matchers: Contains, matches patterns
Chapter 4: Testing Pitfalls and Best Practices 110

3. Parameterize Tests: Make tests data-driven


4. Abstract Assertions: Create domain-specific assertions
5. Test Contracts: Focus on what must be true

1 // Better: Tests what matters, ignores formatting


2 @Test
3 void shouldIncludeRequiredBookInformation() {
4 Book book = new Book("Clean Code", "Robert Martin",
5 "978-0132350884");
6
7 String result = bookshelfService.formatBookDetails(book);

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.

Verify content without caring about format:

1 // Good: Flexible assertions


2 assertThat(result)
3 .containsIgnoringCase("clean code")
4 .contains("Robert Martin")
5 .contains(book.getIsbn());
6 }

These assertions check that the required information is present without


caring about formatting.

The containsIgnoringCase() method doesn’t care about capitalization, and


contains() just verifies the text appears somewhere in the result.

This test will survive formatting changes while still ensuring correctness.

The Slow Test Suite

The Problem: Slow tests discourage frequent execution and delay feedback.
Chapter 4: Testing Pitfalls and Best Practices 111

Common Causes of Slow Tests

1. Large Data Sets: Testing with thousands of records


2. Real Time Delays: Using Thread.sleep() or real timeouts
3. Full Context Loading: Using @SpringBootTest unnecessarily
4. External Services: Calling real APIs or databases
5. Inefficient Queries: N+1 problems in tests

1 // Anti-pattern: Multiple problems making test slow


2 @SpringBootTest // Loads entire context
3 class SlowBookImportTest {
4 @Test
5 void shouldImportLargeCatalog() {

This test class demonstrates several performance anti-patterns. First,


@SpringBootTest loads the entire application context, including all beans,
configurations, and dependencies.

This is often unnecessary and adds seconds to test startup time.

Using excessive test data:

1 // Bad: Too much data


2 List<Book> books = generateBooks(10000);

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.

Adding real time delays:


Chapter 4: Testing Pitfalls and Best Practices 112

1 // Bad: Real delay


2 Thread.sleep(5000);
3
4 assertThat(bookRepository.count()).isEqualTo(10000);

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.

Strategies for Faster Tests

1. Representative Data: Use minimum data that proves the point


2. Test Slices: Use @DataJpaTest instead of @SpringBootTest
3. Async Utilities: Replace sleep() with Awaitility
4. Mock Time: Use Clock abstraction for time-based logic
5. Parallel Execution: Run independent tests concurrently

1 // Better: Fast, focused test


2 @DataJpaTest // Only loads JPA components
3 class FastBookImportTest {
4 @Test
5 void shouldImportMultipleGenres() {

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.

Use minimal representative data:


Chapter 4: Testing Pitfalls and Best Practices 113

1 // Small dataset that tests the behavior


2 bookRepository.saveAll(List.of(
3 new Book("Clean Code", "Technology"),
4 new Book("1984", "Fiction")
5 ));

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.

Verify immediately without delays:

1 // Verify without delays


2 assertThat(bookRepository.countByGenre("Technology"))
3 .isEqualTo(1);
4 assertThat(bookRepository.countByGenre("Fiction"))
5 .isEqualTo(1);

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!

The custom repository method countByGenre() efficiently counts books without


loading all entities.

Test Performance Optimization

Context Caching Strategies

Understanding Context Cache Impact

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

What Breaks Context Caching

1. Different @MockBean/@SpyBean: Each unique mock creates a new


context
2. Different Properties: Any property difference forces new context
3. Different Profiles: Active profiles are part of the cache key
4. @DirtiesContext: Explicitly marks context as dirty
5. Different Configuration Classes: Additional @Import or @TestConfig-
uration

1 // Good: Share base configuration for context reuse


2 @SpringBootTest
3 @ActiveProfiles("test")
4 abstract class BaseIntegrationTest {
5 // Shared context configuration
6 }

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.

Tests that extend the base share the same context:

1 // These tests share the same context


2 class BookServiceIT extends BaseIntegrationTest { }
3 class UserServiceIT extends BaseIntegrationTest { }

Both test classes inherit the same configuration from BaseIntegrationTest.

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

Avoiding Context Pollution

Context pollution occurs when tests modify shared state, forcing Spring to
create new contexts. This dramatically slows down test execution.

Common Pollution Sources:

• Modifying singleton beans


• Changing system properties
• Altering database schemas
• Clearing caches

1 // Bad: Forces context recreation


2 @Test
3 @DirtiesContext // Expensive!
4 void shouldModifyCache() {
5 cacheManager.getCache("books").clear();
6 }

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!

Better approach using mocks:


Chapter 4: Testing Pitfalls and Best Practices 116

1 // Better: Mock the cache


2 @MockBean
3 private CacheManager cacheManager;
4
5 @BeforeEach
6 void setUp() {
7 // Each test gets fresh cache
8 when(cacheManager.getCache(any()))
9 .thenReturn(new ConcurrentMapCache("test"));
10 }

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.

Reducing Test Startup Time

Techniques for Faster Startup

1. Lazy Initialization: Beans created only when needed


2. Disable Auto-Configuration: Turn off unused starters
3. Specific Component Scanning: Limit package scanning
4. Skip Database Migration: Use pre-created schemas
5. Conditional Beans: Use @ConditionalOnProperty

1 // Slow: Loads everything


2 @SpringBootTest
3 class SlowTest { }

Without any configuration, @SpringBootTest loads your entire application: all


beans, all auto-configurations, all components. For a large application, this
can take 10-30 seconds. Most tests don’t need everything.

Faster alternative with specific classes:


Chapter 4: Testing Pitfalls and Best Practices 117

1 // Fast: Only what's needed


2 @SpringBootTest(classes = {
3 BookshelfService.class,
4 BookRepository.class
5 })
6 class FastTest { }

By specifying exactly which classes to load, Spring creates a minimal context


with just these components and their dependencies.
### Parallel Test Execution

Two Modes of Test Parallelization

There are two distinct approaches to parallelize test execution, each with
different trade-offs:

1. Build Tool Level: Forks multiple JVM processes


2. Test Runner Level: Uses multiple threads within a single JVM

Understanding both modes helps us choose the right strategy for our specific
needs.

Build Tool Level Parallelization (JVM Forking)

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.

Maven Surefire Configuration:


Chapter 4: Testing Pitfalls and Best Practices 118

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>

This configuration creates 4 separate JVM processes. The reuseForks=true set-


ting reuses processes between test classes for better performance. Each fork
gets 1GB of heap memory.

Maven Failsafe Configuration (Integration Tests):

1 <plugin>
2 <artifactId>maven-failsafe-plugin</artifactId>
3 <configuration>
4 <forkCount>2</forkCount>
5 <reuseForks>false</reuseForks>
6 </configuration>
7 </plugin>

For integration tests, we often disable fork reuse (reuseForks=false) to ensure


complete isolation.

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 }

Gradle dynamically calculates the number of forks based on available CPU


cores. The forkEvery setting creates fresh JVMs periodically to prevent mem-
ory leaks from accumulating.

Advantages of JVM Forking:

• Complete isolation between test groups


Chapter 4: Testing Pitfalls and Best Practices 119

• No shared memory concerns


• Crashes in one fork don’t affect others
• Can use different JVM settings per fork

Disadvantages:

• High memory overhead (each JVM needs its own heap)


• Slower startup time for each fork
• Context must be loaded in each fork

Test Runner Level Parallelization (Thread-Based)

Test runners like JUnit 5 can execute tests using multiple threads within
the same JVM. This approach shares memory and loaded contexts between
threads.

JUnit 5 Parallel Configuration:

Create a junit-platform.properties file in src/test/resources:

1 # Enable parallel execution


2 junit.jupiter.execution.parallel.enabled=true
3
4 # Execution mode (concurrent or same_thread)
5 junit.jupiter.execution.parallel.mode.default=concurrent
6 junit.jupiter.execution.parallel.mode.classes.default=concurrent
7
8 # Parallelism configuration
9 junit.jupiter.execution.parallel.config.strategy=dynamic
10 junit.jupiter.execution.parallel.config.dynamic.factor=1.0

The dynamic strategy calculates thread count as: availableProcessors * factor. A


factor of 1.0 means one thread per CPU core.

Fixed Thread Pool Configuration:


Chapter 4: Testing Pitfalls and Best Practices 120

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.

Controlling Parallel Execution in Code:

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.

Resource Locks for 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.

Combining Both Approaches

We can use both parallelization modes together for maximum performance:


Chapter 4: Testing Pitfalls and Best Practices 121

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>

This configuration creates 2 JVM processes, each running tests in parallel


using multiple threads. On an 8-core machine, this could utilize all cores
effectively.

Benefits and Risks of Parallel Testing

Benefits:

• Faster test execution


• Better resource utilization
• Faster CI/CD pipelines

Risks:

• Race conditions in tests


• Database conflicts
• Port conflicts
• Shared file system issues

Selective Testing for Faster Feedback

Organize tests by execution speed:


Chapter 4: Testing Pitfalls and Best Practices 122

1 // Tag fast tests


2 @Tag("fast")
3 @Test
4 void shouldValidateIsbn() {
5 assertThat(BookValidator.isValidIsbn("978-0132350884")).isTrue();
6 assertThat(BookValidator.isValidIsbn("invalid-isbn")).isFalse();
7 }
8
9 // Tag slow tests
10 @Tag("slow")
11 @Tag("integration")
12 @SpringBootTest
13 @Test
14 void shouldProcessCompleteBorrowingWorkflow() {
15 // Long-running integration test for complete borrowing cycle
16 }

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.

Run specific test categories:

1 # Run only fast tests during development


2 mvn test -Dgroups="fast"
3
4 # Run all tests in CI
5 mvn test -Dgroups="fast,slow"

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

Test Data Management

Creating Test Data Factories

Why Test Data Factories Matter

Test data factories solve common problems:

• Reduce Duplication: Centralize test data creation


• Improve Readability: Express intent clearly
• Ensure Validity: Create valid data consistently
• Enable Variations: Easy to create specific scenarios

Object Mother Pattern

1 public class BookMother {


2 private static final Faker faker = new Faker();

The Object Mother pattern creates a central place for generating test objects.
The Faker library generates realistic test data (names, addresses, ISBNs) auto-
matically.

This is better than hardcoding “Test Book 1” everywhere, as it creates more


realistic test scenarios and catches edge cases.

Basic factory method:


Chapter 4: Testing Pitfalls and Best Practices 124

1 public static Book simple() {


2 return Book.builder()
3 .title(faker.book().title())
4 .author(faker.book().author())
5 .isbn(generateIsbn())
6 .build();
7 }

Variation with specific title:

1 public static Book withTitle(String title) {


2 return simple().toBuilder()
3 .title(title)
4 .build();
5 }

Scenario-specific factory:

1 public static Book overdue() {


2 return simple().toBuilder()
3 .borrowedDate(LocalDate.now().minusDays(30))
4 .dueDate(LocalDate.now().minusDays(10))
5 .borrowed(true)
6 .build();
7 }

Bulk creation method:

1 public static List<Book> withGenre(String genre, int count) {


2 return Stream.generate(() -> simple().toBuilder()
3 .genre(genre)
4 .build())
5 .limit(count)
6 .toList();
7 }

Using Test Fixtures Effectively

Builder Pattern for Test Scenarios

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;

Scenario for overdue books:

1 public Scenario libraryWithOverdueBooks() {


2 User lateReader = userRepository.save(
3 UserMother.withOverdueBooks("late@example.com")
4 );

Create and associate overdue book:

1 Book overdueBook = bookRepository.save(


2 BookMother.overdue().toBuilder()
3 .borrowedBy(lateReader)
4 .build()
5 );
6
7 return new Scenario(lateReader, List.of(overdueBook));

Scenario for popular books:

1 public Scenario popularBooksScenario() {


2 List<Book> books = bookRepository.saveAll(List.of(
3 BookMother.withTitle("Clean Code").toBuilder()
4 .borrowCount(50).build(),
5 BookMother.withTitle("Refactoring").toBuilder()
6 .borrowCount(45).build()
7 ));
8
9 return new Scenario(null, books);
10 }
11
12 @Value
13 public static class Scenario {
14 User user;
15 List<Book> books;
16 }

Using Fixtures in Tests


Chapter 4: Testing Pitfalls and Best Practices 126

1 @SpringBootTest
2 class BookBorrowingTest {
3 @Autowired
4 private TestScenarios scenarios;
5 @Autowired
6 private BookshelfService bookshelfService;

Using test scenarios in action:

1 @Test
2 void shouldPreventBorrowingWithOverdueBooks() {
3 // Given - Complete scenario in one line
4 Scenario scenario = scenarios.libraryWithOverdueBooks();
5 Book availableBook = BookMother.simple();

Clear business rule test:

1 // When/Then
2 assertThatThrownBy(() ->
3 bookshelfService.borrowBook(
4 availableBook.getId(),
5 scenario.getUser().getId()
6 )
7 ).isInstanceOf(BorrowingDeniedException.class)
8 .hasMessageContaining("overdue books");

Database Seeding Strategies

Approaches to Test Data Setup

1. SQL Scripts: Best for static reference data


2. Object Mothers: Best for unit tests
3. CSV/JSON Files: Best for large datasets
4. Test Containers Init Scripts: Best for schema setup
5. Programmatic Setup: Best for complex scenarios

Simple SQL script approach:


Chapter 4: Testing Pitfalls and Best Practices 127

1 @Sql("/test-data/books.sql")
2 @Test
3 void testWithSqlData() { }

Programmatic approach for dynamic data:

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.

CSV for large datasets:

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.

The @PostConstruct method runs after dependency injection. You’d typically


use a CSV library or Spring Batch for the actual parsing.
Chapter 4: Testing Pitfalls and Best Practices 128

Cleanup Procedures

Strategies for Test Isolation

1. @Transactional + @Rollback: Best for most tests


2. @DirtiesContext: Nuclear option, recreates context
3. Manual Cleanup: For non-transactional tests
4. Test Containers: Fresh database per test class

Strategy 1: Transaction rollback (preferred):

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.

Strategy 2: Manual cleanup for specific data:


Chapter 4: Testing Pitfalls and Best Practices 129

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 }

Sometimes you need manual cleanup, especially for non-transactional op-


erations or when using @Commit. This component provides methods to reset
specific tables. The jdbcTemplate executes raw SQL for maximum control.

Call these methods in @AfterEach to ensure clean state.

Strategy 3: Fresh database per test:

1 @Testcontainers
2 class FreshDatabaseTest {
3 @Container
4 static PostgreSQLContainer<?> postgres =
5 new PostgreSQLContainer<>();
6 }

Testcontainers provides the ultimate isolation: a fresh database container


for each test class. The @Container annotation manages the container lifecycle.
This approach is slower but guarantees complete isolation.

Perfect for tests that modify database structure or need specific PostgreSQL
versions.

Testing Security

Authentication and Authorization Testing

Key Security Testing Scenarios

1. Anonymous Access: What can unauthenticated users do?


Chapter 4: Testing Pitfalls and Best Practices 130

2. Role-Based Access: Do roles work correctly?


3. Method Security: Are service methods protected?
4. CSRF Protection: Is CSRF properly configured?
5. Session Management: Do sessions timeout correctly?

1 @SpringBootTest
2 @AutoConfigureMockMvc
3 class SecurityTest {
4 @Autowired
5 private MockMvc mockMvc;

Security tests require @AutoConfigureMockMvc to enable MockMvc with security


filters. This loads the complete security configuration including authentica-
tion, authorization, CSRF protection, and session management.

MockMvc simulates HTTP requests without starting a real server.

Test anonymous access:

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.

Spring Security intercepts it and, finding no authentication, redirects to the


login page (3xx status). The redirectedUrlPattern() uses a wildcard to match any
host/port.

Test authenticated user access:


Chapter 4: Testing Pitfalls and Best Practices 131

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).

Test authorization boundaries:

1 @Test
2 @WithMockUser(roles = "USER")
3 void userCannotAccessAdmin() throws Exception {
4 mockMvc.perform(get("/admin/users"))
5 .andExpect(status().isForbidden());
6 }

This test ensures role-based authorization works correctly. A user with


the USER role attempts to access an admin endpoint. The expected 403
Forbidden status confirms that Spring Security blocks the request based on
insufficient privileges.

This verifies your security configuration is properly enforcing access rules.

Testing Secured Endpoints

Custom Security Test Annotations

Create reusable security contexts for common test scenarios:

Simple role-based annotation:


Chapter 4: Testing Pitfalls and Best Practices 132

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.

Complex custom security context:

1 @WithSecurityContext(factory = ReaderSecurityFactory.class)
2 public @interface WithMockReader {
3 String email() default "reader@example.com";
4 boolean hasOverdueBooks() default false;
5 }

For complex security scenarios, @WithSecurityContext allows custom security


context creation. The factory class can create a principal with specific at-
tributes like email and business state (overdue books).

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

Testing Method-Level Security

@PreAuthorize and @PostAuthorize Testing

Service with method-level security:

1 @Service
2 public class BookManagementService {
3 @PreAuthorize("hasRole('LIBRARIAN')")
4 public Book addBook(String title, String author) {
5 // Implementation
6 }

Method with complex security expression:

1 @PreAuthorize("hasRole('USER') and !#user.hasOverdueBooks")


2 public BorrowResult borrowBook(Long bookId, User user) {
3 // Implementation
4 }

Test class setup:

1 @SpringBootTest
2 class MethodSecurityTest {
3 @Autowired
4 private BookManagementService service;

Test unauthorized access:

1 @Test
2 @WithMockUser(roles = "USER")
3 void userCannotAddBooks() {
4 assertThrows(AccessDeniedException.class,
5 () -> service.addBook("Title", "Author")
6 );
7 }

Test authorized access:


Chapter 4: Testing Pitfalls and Best Practices 134

1 @Test
2 @WithMockUser(roles = "LIBRARIAN")
3 void librarianCanAddBooks() {
4 assertDoesNotThrow(
5 () -> service.addBook("Title", "Author")
6 );
7 }

Best Practices Summary

Test Design Principles

FIRST Principles

Fast - Tests should run quickly


Independent - Tests don’t depend on each other
Repeatable - Same result every time
Self-Validating - Pass or fail, no manual inspection
Timely - Written just before production code

The Three A’s Pattern

Structured test with clear sections:

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 }

Test Naming Conventions

Behavior-driven naming pattern:


Chapter 4: Testing Pitfalls and Best Practices 135

1 // Pattern: should_ExpectedBehavior_When_StateUnderTest
2 void should_ThrowException_When_BookNotFound()
3 void should_ReturnBook_When_UserIsAuthorized()

Method-focused naming pattern:

1 // Pattern: methodName_StateUnderTest_ExpectedBehavior
2 void borrowBook_WithOverdueBooks_ThrowsException()
3 void calculateFee_SevenDaysLate_Returns3Dollars50()

Key Takeaways

Avoid These Anti-Patterns

1. Over-mocking: Mock only external dependencies


2. Testing implementation: Focus on behavior, not how
3. Fragile tests: Make tests resilient to minor changes
4. Slow tests: Keep feedback loops fast
5. Shared state: Ensure test isolation

Embrace These Practices

1. Test slices: Use the right tool for the job


2. Object mothers: Centralize test data creation
3. Context caching: Optimize for reuse
4. Parallel execution: Leverage modern hardware
5. Continuous refactoring: Tests are code too
Conclusion: The Future of Testing in
Spring Boot

Embracing a Testing Culture

Throughout this book, we’ve explored the comprehensive testing capabili-


ties that Spring Boot offers.

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.

When done well, testing:

• Enables you to make changes with confidence


• Provides living documentation for your codebase
• Catches issues before they reach production
• Allows you to refactor safely
• Improves the design of your code

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:

• Follow the Spring Boot team’s blog and release notes


• Participate in the Java testing community through forums and confer-
ences
Conclusion: The Future of Testing in Spring Boot 138

• Explore new testing libraries and frameworks as they emerge


• Share your testing knowledge with colleagues and the wider community

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.

The Simple Truth About Spring Boot Testing

After everything we’ve covered, here’s the bottom line: Testing Spring Boot
applications is simple once you know the tools.

It’s All About the Right Tools

Spring Boot provides an incredible testing toolkit out of the box:

• @SpringBootTest for full integration testing


• Test slices for focused, fast component testing
• MockMvc for web layer testing without starting a server
• @DataJpaTest for repository testing with an in-memory database
• Testcontainers for real database and service testing
• AssertJ for readable, fluent assertions

These aren’t just random libraries—they’re carefully designed to work to-


gether seamlessly.

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

What We Couldn’t Cover: The Extended Testing Universe

While this book provides a comprehensive foundation for testing Spring


Boot applications, the testing landscape is vast and ever-evolving. There are
several advanced testing approaches and specialized techniques that, while
valuable, extend beyond the scope of this introduction.

Mutation Testing: Finding Weaknesses in Your Tests

Mutation testing is a fascinating technique that tests your tests themselves.


Tools like PIT Mutation Testing introduce small changes (mutations) to your
production code and then run your test suite. If your tests still pass with
mutated code, it indicates weak test coverage or assertions.

Imagine changing > to >= in a validation rule, or && to || in a condition. Strong


tests should catch these mutations and fail, proving they’re actually testing
the logic effectively. Mutation testing reveals holes in your test coverage that
traditional code coverage tools miss.

This technique is particularly valuable for critical business logic where you
need absolute confidence in your test suite’s ability to catch regressions.

Performance and Load Testing

Spring Boot applications eventually need to handle real-world traffic,


and testing performance characteristics requires specialized tools and
approaches. Performance testing includes load testing (normal expected
load), stress testing (beyond normal capacity), and endurance testing
(sustained load over time).

Tools like JMeter, Gatling, or K6 can simulate thousands of concurrent users


hitting your REST APIs. Testing involves creating realistic scenarios: users
Conclusion: The Future of Testing in Spring Boot 140

browsing catalogs, adding items to carts, processing payments, and handling


error conditions.

Spring Boot’s actuator endpoints provide excellent monitoring capabilities


during performance tests, giving insights into memory usage, response
times, and application health under load.

End-to-End Testing with Real Browsers

While we covered integration testing extensively, true end-to-end testing


often requires real browsers and user interactions. Tools like Selenium Web-
Driver, Playwright, or Cypress automate real browsers to click buttons, fill
forms, and navigate through complete user workflows.

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.

E2E testing also involves testing different deployment environments, net-


work conditions, and device types to ensure your application works for all
users.

Contract Testing

In microservices architectures, contract testing ensures that services can


communicate correctly without the overhead of full integration 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

The Learning Journey Continues

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.

The key is to remember that testing is a journey, not a destination. Start


with solid fundamentals - unit tests for business logic, integration tests for
critical workflows, and a reliable CI/CD pipeline. As your applications grow
in complexity and scale, you can gradually adopt more sophisticated testing
strategies.

Keep learning, keep experimenting, and most importantly, keep testing. The
investment in testing skills pays dividends throughout your entire develop-
ment career.

Looking Ahead: Emerging Trends

The world of Spring Boot testing continues to evolve. Here are some trends
to watch:

1. Testcontainers and Infrastructure as Code

Testcontainers has revolutionized integration testing by making it easy to


spin up real databases, message brokers, and other infrastructure compo-
nents in Docker containers.

This trend will continue with more specialized containers and improved
integration with cloud services.
Conclusion: The Future of Testing in Spring Boot 142

2. Contract Testing for Microservices

As applications become more distributed, contract testing frameworks like


Spring Cloud Contract will become increasingly important for ensuring that
services can communicate correctly.

3. AI-Assisted Testing

Machine learning and AI are beginning to influence testing, from generating


test cases to identifying patterns in test failures.

While not a replacement for thoughtful human-written tests, these tools


(like Diffblue Cover) will enhance our testing capabilities.

4. Performance Testing as Standard Practice

As user expectations for responsiveness increase, performance testing will


become a standard part of the testing pipeline, with tools that make it easier
to identify and fix performance issues early.

5. Chaos Engineering Principles

Testing failure modes and resilience will become more mainstream, with de-
velopers intentionally introducing failures to verify that systems can recover
properly.

Your Testing Journey Starts Today

Here’s your action plan:

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

Become a Testing Champion

The real multiplier effect comes from sharing your knowledge:

• Run a lunch-and-learn on test slices for your team


• Pair program with a colleague struggling with integration tests
• Create team standards based on the patterns in this book
• Lead by example with well-tested code in your pull requests

When your entire team embraces testing, everyone moves faster and ships
with confidence.

The Secret? There Is No Secret

Testing Spring Boot applications isn’t magic—it’s a learnable skill. The


“experts” simply know which tool to use when. Now you do too.

Remember:

• Unit tests for business logic (fast, isolated, numerous)


• Integration tests for component interactions (focused, reliable)
• End-to-end tests for critical paths (few but valuable)
• The right tool for the right job makes all the difference

Your Future Self Will Thank You

Imagine six months from now:


Conclusion: The Future of Testing in Spring Boot 144

• Your test suite runs in under 5 minutes


• Refactoring is stress-free because tests catch regressions
• New team members understand the codebase through test examples
• Deployments happen with confidence, not crossed fingers

This isn’t a dream—it’s the natural result of applying what you’ve learned.

Ready to Master Spring Boot Testing?

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?

What if you want hands-on experience with real-world scenarios, expert


guidance, and a community of fellow learners?

That’s where the Testing Spring Boot Applications Masterclass comes in.

What Makes the Masterclass Different?

While this book provides the essential knowledge, the Masterclass takes you
on a complete journey:

Real-World Project: Build and test a production-ready Spring Boot applica-


tion from scratch

• Start with requirements and architecture


• Implement features test-first
• Handle complex scenarios like async processing, caching, and security
• Deploy with confidence using comprehensive test suites

Deep-Dive Topics: Go beyond the basics


Conclusion: The Future of Testing in Spring Boot 145

• Advanced Testcontainers patterns


• Testing microservices and event-driven architectures
• Security testing strategies

Expert Support: Learn from experienced practitioners

• Live Q&A sessions


• Access to a private community
• Lifetime updates as Spring Boot evolves

Practical Outcomes: Skills you can use immediately

• Cut your test execution time by 80%


• Eliminate flaky tests from your codebase
• Build test suites that actually catch bugs
• Lead testing initiatives in your team

Who Is the Masterclass For?

The Masterclass is perfect if you:

• Want to become the testing expert on your team


• Need to modernize a legacy test suite
• Are building microservices and need advanced testing strategies
• Want hands-on practice with expert feedback
• Learn best through practical examples and real code

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:

• Testing Spring Boot Applications Masterclass - Deep-dive, advanced,


and comprehensive testing course
• TDD with Spring Boot Done Right - Hands-on, practical course on Test-
Driven Development with Spring Boot
• Spring Boot Testing Workshops
• Java Testing Toolbox - 30 Testing Tools & Libraries Every Java Developer
Must Know
• Things I Wish I Knew When I Started Testing Spring Boot Applications
• How fixing a broken window cut down our build time by 50%
• Spring Boot testing: Zero to Hero by Daniel Garnier-Moiroux
• Spring Boot Testing - Batteries Included by Dan Vega
Conclusion: The Future of Testing in Spring Boot 147

Final Thoughts

Good tests are the foundation of maintainable software. They give us confi-
dence to refactor, deploy, and evolve our applications.

By avoiding common pitfalls and following established practices, we create


test suites that serve as both safety nets and documentation.

Remember: Tests are not a cost, they’re an investment in your applica-


tion’s future.

Thank you for reading Testing Spring Boot Applications Demystified.

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

You might also like