openrewrite / openrewrite/rewrite-testing-frameworks
`JUnit4to5Migration` moves `Parameterized` parameter assignment after `@BeforeEach`, so setup methods read null
Nobody has claimed this yet.
- Dominant language
- Java
- Stars
- 100
- Forks
- 105
- Avg merge
- 2h 25m
- Merged PRs (30d)
- 9
Description
What happens
Under JUnit 4, @RunWith(Parameterized.class) supplies parameters before any @Before method
runs — through the constructor for constructor injection, or through @Parameter field assignment
for field injection. A @Before method may therefore rely on parameter-derived state.
JUnit4to5Migration replaces that with an init<ClassName>(...) method invoked as the first
statement of the @ParameterizedTest method. JUnit 5 runs @BeforeEach before the test method, so
the setup method now executes before the parameters are assigned and reads uninitialised fields.
The migrated code compiles. The failure appears only when the tests run, as a
NullPointerException inside the setup method.
Minimal reproduction 1 — constructor injection
Before, passing (Tests run: 2, Failures: 0):
package com.example;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.Parameterized;
import org.junit.runners.Parameterized.Parameters;
import static org.junit.Assert.assertEquals;
@RunWith(Parameterized.class)
public class ParamSetupTest {
private final List<String> _names;
private List<String> _upper;
public ParamSetupTest(String... names) {
_names = new ArrayList<>(Arrays.asList(names));
}
@Parameters
public static Collection<Object[]> testCases() {
return Arrays.asList(new Object[][] {
{new String[] {"a", "b"}},
{new String[] {"c"}},
});
}
@Before
public void before() {
_upper = _names.stream().map(String::toUpperCase).toList();
}
@Test
public void namesAreUppercased() {
assertEquals(_names.size(), _upper.size());
}
}
After org.openrewrite.java.testing.junit5.JUnit4to5Migration:
package com.example;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
import static org.junit.jupiter.api.Assertions.assertEquals;
public class ParamSetupTest {
private List<String> _names;
private List<String> _upper;
public void initParamSetupTest(String... names) {
_names = new ArrayList<>(Arrays.asList(names));
}
public static Collection<Object[]> testCases() {
return Arrays.asList(new Object[][] {
{new String[] {"a", "b"}},
{new String[] {"c"}},
});
}
@BeforeEach
public void before() {
_upper = _names.stream().map(String::toUpperCase).toList();
}
@MethodSource("testCases")
@ParameterizedTest
public void namesAreUppercased(String... names) {
initParamSetupTest(names);
assertEquals(_names.size(), _upper.size());
}
}
java.lang.NullPointerException: Cannot invoke "java.util.List.stream()" because "this._names" is null
at com.example.ParamSetupTest.before(ParamSetupTest.java:32)
Tests run: 2, Failures: 0, Errors: 2
before() runs as @BeforeEach, ahead of the test method that would have called
initParamSetupTest(...). Note also that final is removed from _names so the renamed
constructor can assign it, losing the guarantee that the field is written exactly once.
Minimal reproduction 2 — field injection
The same failure occurs with @Parameter fields:
@RunWith(Parameterized.class)
public class FieldParamTest {
@Parameter(0)
public String name;
private String upper;
@Parameters
public static Collection<Object[]> testCases() {
return Arrays.asList(new Object[][] {{"a"}, {"b"}});
}
@Before
public void before() {
upper = name.toUpperCase();
}
@Test
public void nameIsUppercased() {
assertEquals(name.toUpperCase(), upper);
}
}
becomes
public class FieldParamTest {
public String name;
private String upper;
public static Collection<Object[]> testCases() {
return Arrays.asList(new Object[][] {{"a"}, {"b"}});
}
@BeforeEach
public void before() {
upper = name.toUpperCase();
}
@MethodSource("testCases")
@ParameterizedTest
public void nameIsUppercased(String name) {
initFieldParamTest(name);
assertEquals(name.toUpperCase(), upper);
}
public void initFieldParamTest(String name) {
this.name = name;
}
}
java.lang.NullPointerException: Cannot invoke "String.toUpperCase()" because "this.name" is null
at com.example.FieldParamTest.before(FieldParamTest.java:23)
Tests run: 2, Failures: 0, Errors: 2
Why this matters
This is a silent behavioural regression, not a build break — the module compiles and the failure
only shows up when the tests run, as a NullPointerException inside what used to be reliable setup
code. It can affect any @Before that reads parameter-derived state, which is a common pattern for
shared fixtures in parameterized tests, and it hits both injection styles for different underlying
reasons:
| Path | Setup call added? | Outcome |
|---|---|---|
| Constructor injection | No — never generated on this path | NullPointerException at runtime |
Field injection, run via the full JUnit4to5Migration composite |
No — @Before is already @BeforeEach by the time the scanner looks for it |
NullPointerException at runtime |
| Field injection, recipe run in isolation | Yes — this.before() is correctly added |
passes |
The last row matters: the recipe has the right idea and gets it right when run alone. Both real-world
reproductions fail anyway, for two independent reasons.
Why the existing tests don't cover it
ParameterizedRunnerToParameterized already knows the setup method has to be re-invoked. Its scanner
records the @Before method name:
private static final AnnotationMatcher BEFORE = new AnnotationMatcher("@org.junit.Before");
...
if (BEFORE.matches(annotation)) {
params.put(BEFORE_METHOD_NAME, method.getSimpleName());
}
and buildInitMethodDeclarationTemplate appends a call to it:
if (beforeMethodName != null) {
initMethodTemplate.append(" this.").append(beforeMethodName).append("();\n");
}
Two separate things stop that from reaching the output.
The template is only used for field injection. buildInitMethodDeclarationTemplate is applied
under if (!isConstructorInjection). For constructor injection the recipe instead renames the
existing constructor:
// Change constructor to test init method
if (isConstructorInjection && m.isConstructor()) {
m = m.withName(m.getName().withSimpleName(initMethodName));
The renamed constructor keeps its original body, so no call to the setup method is ever added.
Running the recipe on its own against reproduction 1, with @Before still present, confirms this —
the generated initParamSetupTest contains only the original assignment.
On the field-injection path the matcher no longer matches once run inside the composite. Run in
isolation, the recipe produces the intended output:
public void initFieldParamTest(String name) {
this.name = name;
this.before();
}
Run as part of JUnit4to5Migration the this.before(); line is absent, because
UpdateBeforeAfterAnnotations is listed earlier in the composite recipe (junit5.yml:
UpdateBeforeAfterAnnotations before ParameterizedRunnerToParameterized in JUnit4to5Migration's
recipeList) and has already rewritten @Before to @BeforeEach by the time this scanner runs.
BEFORE matches @org.junit.Before only, so beforeMethodName stays null.
Even with the call restored, the setup method would still be annotated @BeforeEach and run a
second time before the parameters exist, so the annotation itself needs handling rather than only
the call.
ParameterizedRunnerToParameterizedTest exercises the recipe directly rather than through
JUnit4to5Migration, so @Before is always still @org.junit.Before when the scanner runs and the
matcher always fires — the composite ordering that defeats it is never reproduced. The suite also
has no constructor-injection test with a @Before method at all, so the missing this.before() call
on that path has nothing in the expected output to show up against.
Environment
- rewrite-testing-frameworks
3.44.0(tagv3.44.0); - rewrite-maven-plugin
6.46.1 - Recipe run:
org.openrewrite.java.testing.junit5.JUnit4to5Migration, which reaches
org.openrewrite.java.testing.junit5.ParameterizedRunnerToParameterized
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with org.openrewrite.java.testing.junit5.ParameterizedRunnerToParameterized and its tests in ParameterizedRunnerToParameterizedTest, then inspect JUnit4to5Migration and junit5.yml for composite ordering. Reproduce both constructor- and field-injection cases through the composite recipe; done means setup methods no longer read unassigned parameters and the migration tests pass.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- java
- Domain
- testing-qa, tooling
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 70/100