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 ExtensionsIn 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 ExtensionsExtensions can be registered in a few different ways:
View the code on Gist.
View the code on Gist.
View the code on Gist.
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 ExtensionsJUnit 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:
View the code on Gist.
View the code on Gist.
Injection Points of JUnit 5 ExtensionsJUnit 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:
View the code on Gist.
View the code on Gist.
GETTING OUR HANDS DIRTYThe 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.
DependenciesAdd 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 TestHere’s a sample integration test that sets up a PostgreSQL container and performs some database operations:
View the code on Gist.
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 ExtensionTo declutter our test code, we can create a dedicated PostgreSQL extension that handles the setup and teardown logic.
Creating the PostgreSQLExtensionView the code on Gist.
Refactored Test ClassNow, we can refactor the test class to use the PostgreSQLExtension:
View the code on Gist.
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 ScenarioBut 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:
View the code on Gist.
Switching Between DatabasesUsing the PostgreSQL extension looks like this:
View the code on Gist.
To switch over to SQLite, simply use:
View the code on Gist.
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 ExtensionNow, 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 ExtensionsHere’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.
View the code on Gist.
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 ExtensionsTo clean things up, let’s create a ReportingExtension that handles timing and reporting:
View the code on Gist.
Integrating the Reporting ExtensionWe can now register the reporting extension alongside our database extension:
View the code on Gist.
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 furtherMade 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:
ReportingExtension, showcasing the parameter injection and state capabilities of JUnit 5 extensions.ReportingExtension that stores the metrics during testing.Enhancing the ReportingExtensionTo incorporate endpoint metrics into our reporting, we will need to enhance our ReportingExtension so that it:
ReportingState class to hold our metrics.View the code on Gist.
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 TOGETHERWe’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:
View the code on Gist.
Here’s what we’ve added:
ExtensionContext with a new ReportingState allowing us to reference it between tests runs safely.ReportingState into test methods.ReportingState.Metrics Test ClassView the code on Gist.
What we’ve done:
ReportingExtension is registered at class level so it’s available for both our test cases.ReportingState is injected into our test methods allowing us to populate it with resultsRunning the Tests and Viewing the ReportWhen 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 mssqlite:{ "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.
ConclusionJUnit 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.
The post Leveraging JUnit5 Extensions for Greater Flexibility appeared first on JVM Advent.