JUnit 5 extensions: Lifecycle injection

Marcus • May 17, 2020

A mixer, with hundreds of knobs.

Coming from JUnit 4 requires a few changes to how parameterized tests are working with the possibly improved JUnit 5 extension model.

Whereas a lot of legacy tests I’ve seen so far saved the parameters in the constructor & used them everywhere, that option is now gone for good, and no alternative exists out-of-the-box.

How would you migrate your parameterized tests?

Getting parameters, using the default approach

Parameters for your test case are injected into the method’s parameters if it is annotated with @ParameterizedTest, and that may be enough for simple cases. Here’s what a very simple test case may look like:

class SomeSimpleTest {
  @ParameterizedTest
  @MethodSource("data")
  void parametersWork(String x, int y) {
    assertEquals(1, x.length());
    assertEquals(7, y);
  }

  static Stream<Arguments> data() {
    return Stream.of(
        Arguments.of("a", 7),
        Arguments.of("c", 7)
    );
  }
}

The massive downside to this approach is that your parameters are available within the test method, exclusively. That may be OK, too, if different methods in your test class require completely different parameters.

If you want to share common @BeforeEach or @AfterEach lifecycle methods, you need to call them in each test method. Especially when relying on a method being executed after your test, you’ll end up with an awkward try/finally-block in each test method.

Identifying parameters

A naive implementation of a parameter resolver could, for example, just return a fixed string instance.

If that’s all you want, it’s not particularly hard:

public class SimpleParameterResolver implements ParameterResolver {
  @Override
  public boolean supportsParameter(
      ParameterContext parameterContext,
      ExtensionContext extensionContext) {
    return parameterContext.getParameter().getType() == String.class;
  }

  @Override
  public Object resolveParameter(
      ParameterContext parameterContext,
      ExtensionContext extensionContext) {
    return "Hello World";
  }
}

Next, we need to tell our test class to use that particular parameter resolver during its runtime, by registering it as below:

@ExtendWith(SimpleParameterResolver.class)
class SomeSimpleTest {
  // ...
}

That’ll fill any string parameter with Hello World, but executing our test fails anyway:

org.junit.jupiter.api.extension.ParameterResolutionException:
  Discovered multiple competing ParameterResolvers for parameter
  [java.lang.String arg0] in method
  [void parametersWork(java.lang.String,int)]:
  ...SimpleParameterResolver@...,
  org.junit.jupiter.params.ParameterizedTestParameterResolver@...

So there’s two opposing parameter resolvers at work here, and both claim to provide a string parameter as requested. There are two ways to resolve this:

  1. Provide unique types not found in your set of parameters.
  2. Identify the supported parameters using a separate annotation.

While there are certainly use cases for option #1, we want to use the same parameters from our @ParameterizedTest method during @BeforeEach & @AfterEach. Since they should have the same type, option #2 is the only one we can use.

Let’s try to solve our problem with an annotation instead:

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.PARAMETER)
public @interface ResolvedParameter {
  /** The position of the parameter. */
  int value();
}

Changing our resolver from above to support the newly-created annotation is fairly simple, since we only need to change what kind of parameters we want to support:

public boolean supportsParameter(
    ParameterContext parameterContext,
    ExtensionContext extensionContext) {
  return parameterContext.isAnnotated(ResolvedParameter.class);
}

Retrieving the parameters using Reflection

Now that we know which parameters to support in our resolver, there’s only one big question remaining: How do I actually get the parameter instances to supply? That’s a complex question to answer, since JUnit 5 doesn’t ship with an easily accessible API to query for the current set of parameters.

We’re not the first person to ask for this functionality, and the issue junit-team/junit5#1139 actually reflects the same question. It just isn’t a solved issue at the time of writing, but reflection code is all we need.

All that’s left is getting the parameter position from @ResolvedParameter, and we’re done.

You can view the full source code on GitHub.

The most obvious caveat

Getting the parameters from the context this way is most certainly useful.

However, you can only get you the parameters the test actually declares. That is, if your data source gets you n parameters, if your test method declares fewer parameters, you cannot retrieve any parameters beyond that scope. As a consequence, it is very possible some of your parameters end up annotated with @SuppressWarnings("unused").

Since you can pick which parameters to use in your lifecycle methods, you can skip any you don’t need.

Up next

In part 2, we’ll take a look at how you can inject your parameters as fields, instead of having them available as method parameters. This closely resembles how the JUnit 4 runner processes @Parameter fields.

Part 2 ->

}