openrewrite / openrewrite/rewrite-testing-frameworks

`JUnit4to5Migration` creates one `mockStatic` registration per stubbing inside `@Before`, which Mockito rejects at runtime

Open
#1,123 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

bug
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 (tag v3.44.0); recipe source unchanged on current main
  • rewrite-maven-plugin 6.46.1
  • mockito-core 5.15.2, junit 4.13.2
  • Recipe run: org.openrewrite.java.testing.junit5.JUnit4to5Migration, which reaches
    org.openrewrite.java.testing.mockito.MockitoWhenOnStaticToMockStatic via
    org.openrewrite.java.testing.mockito.Mockito1to4Migration

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.