openrewrite / openrewrite/rewrite-testing-frameworks
`JUnit4to5Migration` creates one `mockStatic` registration per stubbing inside `@Before`, which Mockito rejects at runtime
Nobody has claimed this yet.
- Dominant language
- Java
- Stars
- 100
- Forks
- 105
- Avg merge
- 2h 25m
- Merged PRs (30d)
- 9
Description
What happens
JUnit4to5Migration converts Mockito.when(<static call>) into a MockedStatic handle plus a
lambda stubbing, by way of Mockito1to4Migration and MockitoWhenOnStaticToMockStatic. Inside a
@Test method it groups every stubbing of the same class onto a single handle. Inside a @Before
method it does not: each stubbing gets its own mockStatic(...) call for the same class.
Mockito permits only one active static mock per class per thread, so the second registration
throws before any test body runs.
Minimal reproduction 1 — two stubbings in @Before
Before, passing (Tests run: 1, Failures: 0):
package com.example;
import org.junit.Before;
import org.junit.Test;
import org.mockito.Mockito;
import static org.junit.Assert.assertEquals;
public class StaticMockTest {
@Before
public void setUp() {
Mockito.when(Vars.lookup("a")).thenReturn("A");
Mockito.when(Vars.lookup("b")).thenReturn("B");
}
@Test
public void bothStubsWork() {
assertEquals("A", Vars.lookup("a"));
}
}
After org.openrewrite.java.testing.junit5.JUnit4to5Migration:
package com.example;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.mockStatic;
public class StaticMockTest {
private MockedStatic<Vars> mockVars2;
private MockedStatic<Vars> mockVars1;
@BeforeEach
public void setUp() {
mockVars1 = mockStatic(Vars.class);
mockVars1.when(() -> Vars.lookup("a")).thenReturn("A");
mockVars2 = mockStatic(Vars.class);
mockVars2.when(() -> Vars.lookup("b")).thenReturn("B");
}
@AfterEach
public void tearDown() {
mockVars1.close();
mockVars2.close();
}
@Test
public void bothStubsWork() {
assertEquals("A", Vars.lookup("a"));
}
}
org.mockito.exceptions.base.MockitoException:
For com.example.Vars, static mocking is already registered in the current thread
To create a new mock, the existing static mock registration must be deregistered
at com.example.StaticMockTest.setUp(StaticMockTest.java:20)
Tests run: 2, Failures: 0, Errors: 2
Because the exception comes from the setup method, every test in the class errors out rather than
only the one exercising the stub.
Minimal reproduction 2 — an existing MockedStatic handle is ignored
Where the class already registers the mock itself, that handle is left in place and the new
registrations are added alongside it:
public class StaticMockTest {
private MockedStatic<Vars> _mocked;
@Before
public void setUp() {
_mocked = Mockito.mockStatic(Vars.class);
Mockito.when(Vars.lookup("a")).thenReturn("A");
Mockito.when(Vars.lookup("b")).thenReturn("B");
}
@After
public void tearDown() {
_mocked.close();
}
}
becomes
public class StaticMockTest {
private MockedStatic<Vars> mockVars2;
private MockedStatic<Vars> mockVars1;
private MockedStatic<Vars> _mocked;
@Before
public void setUp() {
_mocked = Mockito.mockStatic(Vars.class);
mockVars1 = mockStatic(Vars.class);
mockVars1.when(() -> Vars.lookup("a")).thenReturn("A");
mockVars2 = mockStatic(Vars.class);
mockVars2.when(() -> Vars.lookup("b")).thenReturn("B");
}
@After
public void tearDown() {
_mocked.close();
mockVars1.close();
mockVars2.close();
}
}
Three registrations of one class where the source had one. A pre-existing handle is not required to
trigger the failure — reproduction 1 has none — it only adds a further orphaned registration.
Why this matters
Because the exception is thrown from the lifecycle method itself, every test in the class errors
out — not just the one exercising the affected stub — and it happens before any test body executes.
The identical two stubbings, written inside a @Test method instead, migrate correctly, because that
path already tracks which classes have been mocked within the block. The equivalent state is simply
absent from the @Before path:
| Location of the stubbings | Handles created for one class | Outcome after migration |
|---|---|---|
@Test method |
1 (reused) | passes |
@Before method |
1 per stubbing | MockitoException, every test in the class errors |
So the correctness of the migration depends on which kind of method the stubbings happen to live in,
not on anything about the classes being stubbed.
Why the existing tests don't cover it
MockitoWhenOnStaticToMockStatic.visitBlock dispatches between two strategies depending on the
enclosing method:
List<Statement> newStatements = isMethodDeclarationWithAnnotation(containingMethod, BEFORE) ?
maybeStatementsToMockedStatic(block, block.getStatements(), ctx) :
maybeWrapStatementsInTryWithResourcesMockedStatic(block, block.getStatements(), ctx, new HashMap<>());
The @Test path threads a pendingResources map through its recursion, recording each class it has
already mocked:
Map<String, String> nextPending = new HashMap<>(pendingResources);
nextPending.put(invokedType.getFullyQualifiedName(), variableName);
so a later stubbing of the same class is routed to reuseMockedStatic(...), which emits
handle.when(...) against the existing variable. maybeStatementsToMockedStatic, the @Before
path, has no equivalent state: it iterates the statements and calls mockedStatic(...) for every
when(...) it finds, minting a fresh variable name each time through ++varCounter. Nothing in that
loop consults the classes already mocked in the block, and nothing looks for a MockedStatic of that
type already in scope. The recipe does have a findMockedStaticVariable helper that scans the whole
compilation unit for an existing handle of a given type, but it is wired in only on the
try-with-resources path — maybeStatementsToMockedStatic never calls it.
MockitoWhenOnStaticToMockStaticTest covers the lifecycle-method path with a single stubbing per
class, and covers multiple stubbings only through the @Test path, where pendingResources keeps
them on one handle. No case combines a @Before method with two stubbings of the same class, so the
missing state in maybeStatementsToMockedStatic is never exercised.
The two paths need the same invariant — one registration per class per scope — but only one of them
enforces it. A fix would thread the same kind of per-class tracking through
maybeStatementsToMockedStatic that the try-with-resources path already has, or call
findMockedStaticVariable from it the same way.
Environment
- rewrite-testing-frameworks
3.44.0(tagv3.44.0); recipe source unchanged on currentmain - rewrite-maven-plugin
6.46.1 - mockito-core
5.15.2, junit4.13.2 - Recipe run:
org.openrewrite.java.testing.junit5.JUnit4to5Migration, which reaches
org.openrewrite.java.testing.mockito.MockitoWhenOnStaticToMockStaticvia
org.openrewrite.java.testing.mockito.Mockito1to4Migration
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 in MockitoWhenOnStaticToMockStatic.visitBlock, especially maybeStatementsToMockedStatic, and compare it with the pendingResources handling in the @Test path. Review MockitoWhenOnStaticToMockStaticTest and add coverage for multiple stubbings of one class in a @Before method, including an existing MockedStatic handle. Done means the migration creates one registration per class per scope and the regression tests pass.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- java
- Domain
- testing
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 78/100