Lombok的@Synchronized与Mockito一起使用会引发NPE。

huangapple go评论73阅读模式
英文:

Lombok @Synchronized with Mockito throws NPE

问题

给定` synchronized` 和 Lombok 的 `@Synchronized`,后者在模拟测试方法时会导致 `NullPointerException`。给定以下代码

```java
public class Problem
{
    public Problem()
    {
        // 昂贵的初始化,
        // 所以使用 Mock,而不是 Spy
    }

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

    @Synchronized // <-- 在这里导致测试期间的 NPE
    public String b()
    {
        return "b";
    }
}

以及 Jupiter 测试类:

class ProblemTest
{
    @Mock
    private Problem subject;

    @BeforeEach
    void setup()
    {
        initMocks(this);
        // 有更多的模拟。请不要让这个示例的简单性使你困惑。
        doCallRealMethod().when(subject).a();
        doCallRealMethod().when(subject).b();

        // 这是一个 hack,但有效。我们可以依赖这个吗?
        // ReflectionTestUtils.setField(subject, "$lock", new Object[0]);
    }

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

    @Test
    void b()
    {
        // 测试期间的 NullPointerException
        assertEquals("b", subject.b());
    }
}

Lombok 添加了类似以下内容:

private final Object $lock = new Object[0]; // 我们不能依赖这个名称
// ...
public String b()
{
    synchronized($lock)
    {
        return "b";
    }
}

如何模拟使用 Lombok 的 default @Synchronized 注解修饰的方法?


以下是堆栈跟踪,尽管不是很有用。我怀疑 Lombok 添加了一个类似我上面示例中的字段,当然这不会被注入到模拟中,所以就有了 NPE。

java.lang.NullPointerException
    at com.ericdraken.Problem.b(Problem.java:16) // <-- @Synchronized 关键字
    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)
英文:

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)

答案1

得分: 2

这不是 Lombok 的问题,以下代码也会失败。

@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
        assertEquals("c", subject.c());
    }
}

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

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

确切地说,并不是在真正对 Problem 进行模拟,而是通过 doCallRealMethod 进行部分模拟,因此出现了问题。

这在 Mockito 的文档中也有提到,

Mockito.spy() 是创建部分模拟的推荐方法。原因是,它可以保证对正确构造的对象调用真实方法,因为你需要负责构造传递给 spy() 方法的对象。

对于调用 doCallRealMethod() 的模拟对象,不能保证对象的创建方式是正确的。

所以,回答你的问题,是的,这是创建模拟对象的方式,但是 doCallRealMethod 总是一种不确定的方法,与是否使用 Lombok 无关。

如果你真的想调用实际方法,你可以使用 spy

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

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( &quot;c&quot;, subject.c() );
    }
}

class Problem
{
  private final Map&lt;String,String&gt; c = new HashMap&lt;&gt;(){{put(&quot;c&quot;,&quot;c&quot;);}};

    public String c(){
        return c.get(&quot;c&quot;);
    }
}

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(&quot;c&quot;, spyProblem.c());
    verify(spyProblem, Mockito.times(1)).c();
  }

答案2

得分: 0

概要

Project Lombok在方法上使用@Synchronized注解来隐藏基础的自动生成的私有锁,而synchronized则锁定this

在使用Mockito模拟对象时(不是spy,因为在某些情况下,我们不希望完整实例化对象),字段没有被初始化。这意味着自动生成的“lock”字段也是null,从而导致了空指针异常。

解决方案1 - 字段注入

从Lombok的源代码中可以看出,Lombok使用以下锁名称:

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

除非Lombok在未来突然更改了这一点,这意味着即使感觉有些像“hack”,我们仍然可以进行字段注入:

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

解决方案2 - 声明一个锁,然后进行字段注入

这个问题是关于@Synchronized,而不是@Synchronized("someLockName"),但如果你可以显式地声明锁的名称,那么你可以放心地使用解决方案一,并确信锁字段的名称。

英文:

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 = &quot;$lock&quot;;
private static final String STATIC_LOCK_NAME = &quot;$LOCK&quot;;

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, &quot;$lock&quot;, new Object[0] );
}

Solution 2 - Declare a lock, then field injection

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

答案3

得分: 0

核心问题在于你将调用真实方法与模拟方法结合在一起,而不是使用间谍(spy)。这在一般情况下是危险的,因为它是否适用于任何情况很大程度上取决于所讨论的方法的内部实现。

Lombok之所以重要,是因为它通过在编译期间修改内部实现的方式来工作,而这种方式恰好需要适当的对象初始化,以使原始方法不起作用。

如果你要配置一个模拟方法来调用真实方法,你应该考虑使用间谍(spy)。

英文:

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.

huangapple
  • 本文由 发表于 2020年8月28日 12:13:23
  • 转载请务必保留本文链接:https://go.coder-hub.com/63627361.html
匿名

发表评论

匿名网友

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen:

确定