为什么一个空方法在模拟对象上会得到NullPointerException?

4

我正在为我的Java应用编写单元测试,该应用分析提交到Stash的内容,这是一个类似于Github的Atlassian应用。

我要测试的方法如下:

public List<Message> processEvent(RepositoryRefsChangedEvent event) {
    ArrayList<Message> commitList = new ArrayList<Message>();

    for (RefChange refChange : event.getRefChanges()) {
        LOGGER.info("checking ref change refId={} fromHash={} toHash={} type={}", refChange.getRefId(), refChange.getFromHash(),
                refChange.getToHash(), refChange.getType());

        if (refChange.getRefId().startsWith(REF_BRANCH)) {
            if (refChange.getType() == RefChangeType.ADD && isDeleted(refChange)) {
                LOGGER.info("Deleted a ref that never existed. This shouldn't ever occur.");
            }
            else if (isDeleted(refChange) || isCreated(refChange)) {
                branchCreation(refChange, event.getRepository(), commitList);
            }
            else {
                sepCommits.findCommitInfo(refChange, event.getRepository(), commitList);
            }
        }
        else {
            refNotProcessed(refChange);
        }
    }
    return commitList;
}

我想确保如果我有一个git笔记提交,处理将被忽略并调用refNotProcessed(..)

幸运的是,我能够相对容易地找到解决方案:

@RunWith (MockitoJUnitRunner.class)
public class RefChangEventTest {
    @Mock RefChange ref;
    @Mock RepositoryRefsChangedEvent refsChangedEvent;
    @Mock Repository repo;
    @Mock ApplicationPropertiesService appService;
    @Mock SEPCommits sepCommits;
    @Spy SEPRefChangeEventImpl sepRefChangeEvent = new SEPRefChangeEventImpl(sepCommits, appService);

    @Before
    public void testSetup() {
        Collection<RefChange> refList = new ArrayList<RefChange>(1);
        refList.add(ref);
        when(refsChangedEvent.getRefChanges()).thenReturn(refList);
        when(refsChangedEvent.getRepository()).thenReturn(repo);
    }

    @Test
    public void gitNotesAreIgnored() throws Exception {
        when(ref.getRefId()).thenReturn("refs/notes/foo");
        when(ref.getFromHash()).thenReturn("da69d7e202d7f66cba01c6f4030bd5975adbf200");
        when(ref.getToHash()).thenReturn("da69d7e202d7f66cba01c6f4030bd5975adbf201");
        doNothing().when(sepCommits).findCommitInfo(any(RefChange.class), any(Repository.class), any(ArrayList.class));

        sepRefChangeEvent.processEvent(refsChangedEvent);
        verify(sepRefChangeEvent, times(1)).refNotProcessed(ref);
    }

接下来,我想测试一下如果将引用名称更改为类似于 refs/heads/foo 的预期名称,我的单元测试是否会因正确的原因而失败。 我希望看到类似于以下内容的结果:预期 refNotProcessed 执行 1 次,但根本没有运行

然而我得到的结果是:

java.lang.NullPointerException
at com.cray.stash.SEPRefChangeEventImpl.processEvent(SEPRefChangeEventImpl.java:62)
at ut.com.isroot.stash.plugin.RefChangEventTest.gitNotesAreIgnored(RefChangEventTest.java:48)
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.lang.reflect.Method.invoke(Method.java:497)
at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:47)
at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:44)
at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17)
at org.junit.internal.runners.statements.RunBefores.evaluate(RunBefores.java:26)
at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:271)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:70)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:50)
at org.junit.runners.ParentRunner$3.run(ParentRunner.java:238)
at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:63)
at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:236)
at org.junit.runners.ParentRunner.access$000(ParentRunner.java:53)
at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:229)
at org.junit.runners.ParentRunner.run(ParentRunner.java:309)
at org.mockito.internal.runners.JUnit45AndHigherRunnerImpl.run(JUnit45AndHigherRunnerImpl.java:37)
at org.mockito.runners.MockitoJUnitRunner.run(MockitoJUnitRunner.java:62)
at org.junit.runner.JUnitCore.run(JUnitCore.java:160)
at com.intellij.junit4.JUnit4IdeaTestRunner.startRunnerWithArgs(JUnit4IdeaTestRunner.java:117)
at com.intellij.junit4.JUnit4IdeaTestRunner.startRunnerWithArgs(JUnit4IdeaTestRunner.java:42)
at com.intellij.rt.execution.junit.JUnitStarter.prepareStreamsAndStart(JUnitStarter.java:253)
at com.intellij.rt.execution.junit.JUnitStarter.main(JUnitStarter.java:84)
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.lang.reflect.Method.invoke(Method.java:497)
at com.intellij.rt.execution.application.AppMain.main(AppMain.java:147)

这句话指向了对sepCommits.findCommitInfo(..)的调用。默认情况下,当模拟对象调用void签名的方法时,这些方法不会做任何事情。这正是我想要的。我希望它被调用,但什么也不做,只记录与sepCommits交互的事实。为什么会出现NPE错误?

这里有更多人们在询问的方法:

public SEPRefChangeEventImpl(SEPCommits sepCommits, ApplicationPropertiesService appService) {
    this.sepCommits = sepCommits;

    try {
        endpoint = appService.getPluginProperty("plugin.fedmsg.events.relay.endpoint");
    } catch (Exception e) {
        LOGGER.error("Failed to retrieve properties\n" + e);
    }

    if (endpoint == null) {
        endpoint = "tcp://some.web.address"
    }
}


public void refNotProcessed(RefChange refChange) {
    LOGGER.info("This type of refChange is not supported.\n refId={} fromHash={} toHash={} type={}", refChange.getRefId(), refChange.getFromHash(),
            refChange.getToHash(), refChange.getType());
}

public void findCommitInfo(RefChange ref, Repository repo, ArrayList<Message> commitList) {
    Page<Commit> commits = getChangeset(repo, ref);
    for (Commit commit : commits.getValues()) {
        String topic = topicPrefix + repo.getProject().getKey() + "." + repo.getName() + ".commit";
        Message message = new Message(getInfo(commit, ref), topic);
        commitList.add(message);
    }
}

间谍是必要的,以便在sepRefChangeEvent上运行验证,因为它需要一个模拟来运行。我添加了更多的方法供参考! - Scott James Walter
好的,我看了,没有特别的事情。你可以试着不进行监视吗? - davidxxx
只是为了检查一下模拟工作是否正常,不需要进行监视。无论如何,由于您的问题没有解决,我会在答案中提出另一种做法。 - davidxxx
你的意思是要用@Mock替换@Spy,对吗?我尝试了一下,但方法不再运行(我使用调试器发现的),因为我猜测@Mock引用使对象为空,并且它没有可运行的方法。请注意,该对象需要是一个Mock对象,这样我才能在其上运行“verify”方法。 - Scott James Walter
SEPRefChangeEventImpl第62行发生了什么?您尝试过在其中添加断点并进行调试吗?将@Spy添加到对象中意味着它正在执行,使用SEPCommits模拟。对其进行任何调用都将返回null,因为没有进一步的模拟指定。 - Koos Gadellaa
显示剩余5条评论
3个回答

1
根据您的设置,sepCommits 是一个类中的依赖项,包含 processEvent() 方法。
您需要将您创建的模拟对象注入到测试中的 sepRefChangeEvent 变量中。通常情况下,这可以通过在构造函数中传递参数或通过 setter 方法来实现。我在您的测试类中没有看到这样的代码。我认为您实际上正在访问一个真实的实例,而不是模拟实例,这会导致异常。

当我实例化我的 sepRefChangeEvent 变量时,我将 sepCommits 作为第一个参数传递。 - Scott James Walter
哦,抱歉,可能漏掉了那个。但是你为什么要监视你的测试实例呢?如果你只使用被测试类的普通实例会发生什么? - Danail Alexiev
没问题!如果我将它声明为普通实例,那么在 verify(sepRefChangeEvent, times(1)).refNotProcessed(ref) 中使用它时会出现错误,因为 verify 需要一个模拟对象来操作。 - Scott James Walter
你能否尝试注释掉验证部分,只运行代码,看看是否会再次调用原始方法? - Danail Alexiev
我已经尝试过这个了!当我注释掉验证代码时,我得到了相同的错误。这有意义吗?我预料到会出现这种情况,因为导致错误的方法是因为我运行了 sepRefChangeEvent.processEvent(refsChangedEvent) - Scott James Walter
显示剩余9条评论

0

我认为Mockito的Spying不是一种自然的行为,因为你的类既是Mock又是测试类。
在遗留代码中,这可能是可以接受的,但在新代码中,这是可惜的。

此外,在你的情况下,我认为你不需要Spying。
你的逻辑是:如果ref name输入与REF_BRANCH匹配,则进行处理,否则不进行任何操作。所以,你应该需要检查在接受的测试中,你是否执行了处理(验证mock是否被调用),而在未被接受的测试中,你是否没有执行处理(验证mock是否未被调用)。

       // You do the processing
       if (refChange.getRefId().startsWith(REF_BRANCH)) {
            if (refChange.getType() == RefChangeType.ADD && isDeleted(refChange)) {
                LOGGER.info("Deleted a ref that never existed. This shouldn't ever occur.");
            }
            else if (isDeleted(refChange) || isCreated(refChange)) {
                branchCreation(refChange, event.getRepository(), commitList);
            }
            else {
                sepCommits.findCommitInfo(refChange, event.getRepository(), commitList);
            }
        }
        // You do nothing
        else {
            refNotProcessed(refChange);
        }

断言测试类的另一个公共方法被调用并不是一种验收测试。

如果你真的想要测试它,另一种解决方案是引入一个新的类来将public method refNotProcessed移动到其中。你可以保留这个类来控制输入流程并将其分派到处理类中的处理方法:

       // You do the processing
       if (refChange.getRefId().startsWith(REF_BRANCH)) {
            if (refChange.getType() == RefChangeType.ADD && isDeleted(refChange)) {
                LOGGER.info("Deleted a ref that never existed. This shouldn't ever occur.");
            }
            else if (isDeleted(refChange) || isCreated(refChange)) {
                processClass.branchCreation(refChange, event.getRepository(), commitList);
            }
            else {
                sepCommits.findCommitInfo(refChange, event.getRepository(), commitList);
            }
        }
        // You do nothing
        else {
            processClass.refNotProcessed(refChange);
        }

这似乎是个不错的建议,但它可能无法解决我的问题。你基本上是告诉我把 refNotProcessedbranchCreation 放到另一个类中。但我仍然会遇到同样的问题。 - Scott James Walter
我会在这里回答,以免出现交叉信息。 不,我建议您删除spy注释,但不要用Mock替换它。将其用作自然对象。我认为,如果您停止使用间谍,您就不会再有模拟processClass的问题了。 - davidxxx
我的测试使用 verify 作为其验证方式。verify 方法需要一个模拟对象来配合工作。我不能使用非模拟的 sepRefChangeEvent 变量,否则会出现 "NotAMockException" 异常。如果我想在该变量上运行 verify,它必须是一个模拟对象。 - Scott James Walter
是的,但不要使用间谍,并注释掉您验证间谍的行,只是为了检查是否在没有间谍的情况下模拟分离提交是否有效。这只是一个尝试隔离问题的测试,而不是最终解决方案。 - davidxxx

0

这是我的解决方案:

@RunWith (MockitoJUnitRunner.class)
public class RefChangEventTest {
    @Mock RefChange ref;
    @Mock RepositoryRefsChangedEvent refsChangedEvent;
    @Mock ApplicationPropertiesService appService;
    @Mock ArrayList<Message> mockList;
    @Mock RepositoryService repositoryService;
    @Mock RefService refService;
    @Mock CommitService commitService;
    @Mock SecurityService securityService;
    @Mock Repository repo;

    SEPCommits sepCommits = mock(SEPCommits.class, RETURNS_DEEP_STUBS);
    @Spy SEPRefChangeEventImpl sepRefChangeEvent = new SEPRefChangeEventImpl(sepCommits, appService);

    @Before
    public void testSetup() {
        Collection<RefChange> refList = new ArrayList<RefChange>(1);
        refList.add(ref);
        when(refsChangedEvent.getRefChanges()).thenReturn(refList);
        when(refsChangedEvent.getRepository()).thenReturn(repo);
    }

    @Test
    public void gitNotesAreIgnored() throws Exception {
        when(ref.getRefId()).thenReturn("refs/notes/foo");
        when(ref.getFromHash()).thenReturn("da69d7e202d7f66cba01c6f4030bd5975adbf200");
        when(ref.getToHash()).thenReturn("da69d7e202d7f66cba01c6f4030bd5975adbf201");

        sepRefChangeEvent.processEvent(refsChangedEvent);
        verifyZeroInteractions(sepCommits);
    }

区别在于如何模拟sepCommits,它是使用RETURN_DEEP_STUBS进行模拟的。我避免完全初始化sepCommits的原因是因为我需要使findCommitInfo(..)内部执行的大量方法失效。我只想确保该方法被调用或未被调用。我不确定RETURN_DEEP_STUBS是否是一个好的解决方案,但它对我起作用了。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接