If you’re anything like me, you’ve reached a point where your tests start getting cluttered with configurations, and you aren’t quite sure where to put them. You think a helper class might do the trick, then you move them to a TestConfiguration
that you import for every test. But even then, you find yourself reusing helpers in each test, wishing everything would just work behind the scenes, making your tests clean and elegant.
I’ve seen countless articles about JUnit 5 Extensions but never really gave them much thought—until my colleague Johnny (special thanks to you) showed me just how powerful they could be in action. I realized I’d been missing out.
So, let me share how JUnit 5 Extensions made my life so much easier, and how they helped me fall back in love with my tests.
TL;DR
- JUnit 4 had some limitations
- JUnit 5 introduced a new Extension model
- You can hook into multiple Injection Points
- Creating Custom Extensions is easy
- You can inject parameters into methods and leverage shared state between tests
- JUnit5 Extensions have your back, and your team’s back
Understanding JUnit 5 Extensions
In JUnit 4, we extended test behavior with Runners and Rules, but they had limitations. For instance, you could only use one Runner per test class, making it impossible to combine functionalities from multiple Runners. Rules were a bit more flexible, but they still involved extra boilerplate code and didn’t quite achieve the composability developers needed.
JUnit 5 overcomes these challenges with a unified extension model that emphasizes composability and separation of concerns. Extensions in JUnit 5 can be registered at various levels—field, parameter, method, or class—providing more flexibility in managing test behavior and reducing boilerplate.
Registering Extensions
Extensions can be registered in a few different ways:
- At the Class Level:
- At the Method Level:
- At the Field Level:
By offering these options, JUnit 5 lets you decide where and how to apply extensions, whether it’s across the entire class or just a specific test method. It’s all about giving you the control to write cleaner, more maintainable tests without unnecessary hassle.
Popular Extensions
JUnit 5 comes with built-in extensions, and there’s a thriving community that has contributed even more. Here are a couple of popular ones that most of us have used at least once:
- MockitoExtension: Makes it easy to integrate Mockito with JUnit 5 for creating and injecting mock objects.
- SpringExtension: Integrates the Spring TestContext Framework into JUnit 5, allowing for dependency injection and transaction management.
Injection Points of JUnit 5 Extensions
JUnit 5 provides several injection points that let extensions hook into different stages of the test lifecycle. This flexibility allows you to customize and extend test behavior in a reusable way. Here are some of the key injection points:
- BeforeAllCallback: Runs code before all test methods in a test class, typically used for global setup tasks.
- AfterAllCallback: Executes code after all test methods, often used for global cleanup.
- BeforeEachCallback and AfterEachCallback: Run code before or after each individual test method. This is useful for setting up or tearing down state specific to each test.
- TestWatcher: Monitors individual test statuses—like passing or failing—which is helpful for logging and reporting purposes.
- ParameterResolver: Allows you to inject dependencies directly into your test methods by resolving method parameters.
These injection points enable you to create extensions that are both reusable and composable, helping you keep your tests clean and maintainable. They offer a level of flexibility that goes beyond what annotations alone can provide, allowing you to dynamically modify test behavior as needed.
GETTING OUR HANDS DIRTY
The best way to demonstrate the benefits of JUnit 5 extensions is to see them in action. Let’s refactor an integration test in a Spring Boot project that uses Testcontainers with PostgreSQL and Spring Boot Data JPA.
Dependencies
Add these dependencies to your pom.xml
:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-testcontainers</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.testcontainers</groupId> <artifactId>junit-jupiter</artifactId> <scope>test</scope> </dependency> <!-- postgresql driver --> <dependency> <groupId>org.postgresql</groupId> <artifactId>postgresql</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.testcontainers</groupId> <artifactId>postgresql</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>1.18.34</version> <scope>provided</scope> </dependency> </dependencies>
These dependencies provide the testing framework and containerized database we’ll need for our examples (Tip: use https://start.spring.io/ to get these out of the box)
The Cluttered Test
Here’s a sample integration test that sets up a PostgreSQL container and performs some database operations:
This test functions correctly but is cluttered with setup and teardown code, which can make it harder to read and maintain. The additional boilerplate distracts from the core purpose of the test, making it less clean and elegant than we aim for, and a loss let reusable.
Refactoring with a PostgreSQL Extension
To declutter our test code, we can create a dedicated PostgreSQL extension that handles the setup and teardown logic.
Creating the PostgreSQLExtension
Refactored Test Class
Now, we can refactor the test class to use the PostgreSQLExtension
:
By refactoring, we’ve removed the setup and teardown code from the test class. This makes the test cleaner and allows us to focus on the actual test logic, while being able to reuse the same logic without any duplication.
Introducing the SQLite Scenario
But what if you need your tests to run faster during development? Switching to SQLite might just do the trick. For this, we’ll setup an in-memory SQLite
database that our tests will use and a new SQLiteExtension
responsible for all the required configuration:
Switching Between Databases
Using the PostgreSQL extension looks like this:
To switch over to SQLite, simply use:
See how the test logic stays the same? By swapping out the extension, your tests become flexible and adaptable to different scenarios without any extra hassle.
Creating a Reporting Extension
Now, let’s figure out how long our tests take when running against different databases. This ties in nicely with our earlier focus on writing clean, reusable code for database extensions. But before jumping straight into using extensions for this, let’s take a look at how we’d typically approach the task without them. Then, we’ll dive into how a reporting extension can make the process smoother and more consistent.
Without Extensions
Here’s an example of how you might measure test execution time without using extensions. While effective, this approach adds boilerplate code to every test class, making the tests harder to maintain and less focused on their core purpose.
While this works, it introduces repetitive boilerplate code in every test class. Now, let’s see how we can use a reporting extension to clean this up.
Using Extensions
To clean things up, let’s create a ReportingExtension
that handles timing and reporting:
Integrating the Reporting Extension
We can now register the reporting extension alongside our database extension:
Resulting in such an output:
test() PASSED in 878 ms
This setup shows how reporting integrates naturally with the existing extensions, also emphasizing the composability of JUnit 5 extensions.
Furthermore, we now have the ability to add the same reporting extension to the tests against SQLite database and be able to compare one approach to the other.
Going even further
Made it until here? Good. We’ll now deliver the final strike.
We will take our ReportingExtension
to the next level by incorporating a bunch of endpoint metrics into our test reporting.
Suppose we have an endpoint that returns various metrics such as averageProcessingTime
, meanProcessingTime
, and so on. To enrich our test report, we will include these metrics for different database types, specifically PostgreSQL
and SQLite
.
In this scenario, we will:
- Use JUnit 5’s parameter injection to extend our
ReportingExtension
, showcasing the parameter injection and state capabilities of JUnit 5 extensions. - Maintain a shared state for the
ReportingExtension
that stores the metrics during testing. - Use the two test classes—one for PostgreSQL and one for SQLite—to compare their metrics.
Enhancing the ReportingExtension
To incorporate endpoint metrics into our reporting, we will need to enhance our ReportingExtension
so that it:
- Tracks metrics for each test: We need to store the metrics for each database type.
- Uses a shared state: The state will be injected into each test, allowing us to add metrics during the test execution.
- Generates a final report: At the end of all tests, we will include the endpoint metrics in the report.
Creating the ReportingState
First, let’s create a ReportingState
class to hold our metrics.
We’ll use this class to store metrics from each test and print the report. Along with another cool feature of JUnit 5 extensions, called ExtensionContext
, we’ll implement a way to safely store state between tests (and even test suites).
STITCHING EVERYTHING TOGETHER
We’ll update our ReportingExtension
to manage this state and configure parameter injection so all our tests are able to leverage this state in order add their own results:
Here’s what we’ve added:
- Initialization: Before all tests, we initialize the
ExtensionContext
with a newReportingState
allowing us to reference it between tests runs safely. - Parameter Injection: We enable parameter injection to pass
ReportingState
into test methods. - Final Reporting: After all tests, we output the collected metrics.
Creating the Test Classes
Now, let’s create two test cases—one for PostgreSQL and one for SQLite. Each one will be calling the same endpoint that returns metrics, which we’ll add to our ReportingState
.
Metrics Test Class
What we’ve done:
-
ReportingExtension
is registered at class level so it’s available for both our test cases. - Each test cases is also extended with the specific database extension that we need.
- The
ReportingState
is injected into our test methods allowing us to populate it with results
Running the Tests and Viewing the Report
When we run these tests, the ReportingExtension
will generate a report that includes metrics from both PostgreSQL and SQLite tests. Here’s what the output might look like:
---- Test Results ---- testPostgresMetrics(ReportingState): PASSED in 33 ms testSqliteMetrics(ReportingState): PASSED in 20 ms sqlite: { "averageTime" : 0.4350518160108173, "meanTime" : 0.8010830012824306, "maxTime" : 0.2139154359220964, "minTime" : 0.7566418082724364 } postgres: { "averageTime" : 0.8143944973374195, "meanTime" : 0.22552853357243552, "maxTime" : 0.5854190992149244, "minTime" : 0.3479001940740827 }
This gives us a clear comparison of the metrics for each database type, highlighting how powerful JUnit 5 extensions can be for managing complex testing requirements.
By enhancing our ReportingExtension
with parameter injection and state management, we’ve made our tests more insightful. Injecting shared state simplifies the test logic and allows us to generate comprehensive reports that go beyond simple pass/fail results.
Feeling inspired?
It’s pretty straightforward to extend this approach to other needs in our projects. For example, one thing I’ve found handy is having access to the database instance within our tests for more granular control or data setup. Now that we’ve streamlined our database extensions, I’ll let you get your hands dirty and tweak them to inject the database object right where you need it.
Give it a try, see how it elevates your testing experience and share with the world what other cool ways of using extensions you’ve come up with.
Conclusion
JUnit 5 extensions have pretty much transformed the way I design and write tests. With these amazing capabilities of encapsulating repetitive configurations and setups into reusable components, my tests get cleaner, more readable, and easier to maintain.
Remember, the goal is to focus on what matters: your test logic. Let Extensions handle the heavy lifting, making it easier to standardize testing practices and keep your codebase tidy.
Keep in mind that they also scale effortlessly across projects, making them a valuable asset for teams aiming to standardize testing practices and keep their codebases tidy.
So why not give it a go? After all, better tests lead to better software.
All examples from this article are publicly available on github.
Java love.
Author: Vlad Dedita
Java developer, Spring Boot advocate, and Quarkus enthusiast with a passion for clean code and well-structured, maintainable software design