Lombok @Synchronized with Mockito throws NPE

Viewed 562

Given synchronized and Lombok's @Synchronized, the latter causes a NullPointerException when mocking a method under test. Given

public class Problem
{
    public Problem()
    {
        // Expensive initialization,
        // so use Mock, not Spy
    }

    public synchronized String a()
    {
        return "a";
    }

    @Synchronized // <-- Causes NPE during tests, literally, here
    public String b()
    {
        return "b";
    }
}

and the Jupiter test class

class ProblemTest
{
    @Mock
    private Problem subject;

    @BeforeEach
    void setup()
    {
        initMocks(this);
        // There is more mocking. Please don't let the simplicity
        // of this example throw you off.
        doCallRealMethod().when( subject ).a();
        doCallRealMethod().when( subject ).b();

        // This is a hack, but works. Can we rely on this?
        // ReflectionTestUtils.setField( subject, "$lock", new Object[0] );
    }

    @Test
    void a()
    {
        // Succeeds
        assertEquals( "a", subject.a() );
    }

    @Test
    void b()
    {
        // NullPointerException during tests
        assertEquals( "b", subject.b() );
    }
}

Lombok adds something like the following:

private final Object $lock = new Object[0]; // We can't rely on this name
...
public String b()
{
    synchronized($lock)
    {
        return "b";
    }
}

How to mock a method that is decorated with Lombok's default @Synchronized annotation?


Here is the stack trace, though it is unhelpful. I suspect Lombok adds a field as in my example above, and of course that is not injected into the mock, so voilĂ , NPE.

java.lang.NullPointerException
    at com.ericdraken.Problem.b(Problem.java:16) // <-- @Synchronized keyword
    at com.ericdraken.ProblemTest.b(ProblemTest.java:43) // <-- assertEquals( "b", subject.b() );
    at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
    at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
    ... [snip] ...
    at com.intellij.rt.junit.JUnitStarter.prepareStreamsAndStart(JUnitStarter.java:230)
    at com.intellij.rt.junit.JUnitStarter.main(JUnitStarter.java:58)
3 Answers

This isn't an issue with Lombok, the following also fails.

@ExtendWith({MockitoExtension.class})
@MockitoSettings(strictness = Strictness.LENIENT)
public class ProblemTest {
    @Mock
    private Problem subject;

    @BeforeEach
    void setup()
    {
        doCallRealMethod().when( subject ).c();

    }

    @Test
    void c()
    {
        // NullPointerException during tests
        assertEquals( "c", subject.c() );
    }
}

class Problem
{
  private final Map<String,String> c = new HashMap<>(){{put("c","c");}};

    public String c(){
        return c.get("c");
    }
}

To be precise, you are not really mocking Problem, you are partially mocking via doCallRealMethod hence the issue.

This is also called out in Mockito's documentation,

Mockito.spy() is a recommended way of creating partial mocks. The reason is it guarantees real methods are called against correctly constructed object because you're responsible for constructing the object passed to spy() method.

doCallRealMethod() is called on a mock which is not guaranteed to have the object created the way it's supposed to be.

So to answer your question, yes that's the way you create a mock, but doCallRealMethod is always a gamble irrespective of Lombok.

You can use spy if you really want to call the actual method.

  @Test
  void c() {
    Problem spyProblem = Mockito.spy(new Problem());
    assertEquals("c", spyProblem.c());
    verify(spyProblem, Mockito.times(1)).c();
  }

Synopsis

Project Lombok has the @Synchronized annotation on methods to hide the underlying and auto-generated private lock(s), whereas synchronized locks on this.

When using a Mockito mock (not a spy, because there are situations when we don't want a full object instantiated), fields are not initialized. That means as well the auto-generated "lock" field is null which causes the NPE.

Solution 1 - Field injection

Looking at Lombok source code, we see that Lombok uses the following lock names:

private static final String INSTANCE_LOCK_NAME = "$lock";
private static final String STATIC_LOCK_NAME = "$LOCK";

Unless Lombok suddenly changes this in the future, this means we can do field injection even if it feels like a "hack":

@BeforeEach
void setup()
{
    initMocks(this);
    ...
    ReflectionTestUtils.setField( subject, "$lock", new Object[0] );
}

Solution 2 - Declare a lock, then field injection

The question asks about @Synchronized, not @Synchronized("someLockName"), but if you can explicitly declare the lock name, then you can use solution one with confidence about the lock field name.

The core problem is that you are combining calling the real method with a mock rather than a spy. This is dangerous in general, as whether it works for anything depends very much on the internal implementation of the method in question.

Lombok only matters because it works by altering that internal implementation during compilation, in a way that happens to require proper object initialization to work where the original method does not.

If you're going to configure a mock to call the real method, you should probably use a spy instead.

Related