Site icon JVM Advent

Leveraging JUnit5 Extensions for Greater Flexibility

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:

See the gist on github.

  • At the Method Level:

See the gist on github.

  • At the Field Level:

See the gist on github.

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.

See the gist on github.

  • SpringExtension: Integrates the Spring TestContext Framework into JUnit 5, allowing for dependency injection and transaction management.

See the gist on github.

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.

See the gist on github.

  • AfterAllCallback: Executes code after all test methods, often used for global cleanup.

See the gist on github.

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

See the gist on github.

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

See the gist on github.

Refactored Test Class

Now, we can refactor the test class to use the PostgreSQLExtension:

See the gist on github.

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:

See the gist on github.

Switching Between Databases

Using the PostgreSQL extension looks like this:

See the gist on github.

To switch over to SQLite, simply use:

See the gist on github.

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.

See the gist on github.

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:

See the gist on github.

Integrating the Reporting Extension

We can now register the reporting extension alongside our database extension:

See the gist on github.

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 averageProcessingTimemeanProcessingTime, 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:

  1. Use JUnit 5’s parameter injection to extend our ReportingExtension, showcasing the parameter injection and state capabilities of JUnit 5 extensions.
  2. Maintain a shared state for the ReportingExtension that stores the metrics during testing.
  3. 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:

  1. Tracks metrics for each test: We need to store the metrics for each database type.
  2. Uses a shared state: The state will be injected into each test, allowing us to add metrics during the test execution.
  3. 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.

See the gist on github.

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:

See the gist on github.

Here’s what we’ve added:

  • Initialization: Before all tests, we initialize the ExtensionContext with a new ReportingState 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

See the gist on github.

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

Exit mobile version