如何简洁验证可构建对象的方法调用且不暴露类成员?
有时候我们需要验证被测系统(SUT)类的某个方法是否被调用,但如果这个类是采用Joshua Bloch在《Effective Java》中提到的构建器模式实现的,事情就会变得有点棘手。
我最近做了个简单测试:想验证自己扩展的JDialog在点击OK按钮后确实会被dispose掉。但如果不用那种很繁琐的Mock初始化方式(见下文代码块),测试就会失败。而且我还得把构造函数改成public——这其实是我不想做的,毕竟Mockito默认没法调用私有构造函数。
我想知道怎么能简洁地完成这个测试,还不用不必要地暴露类成员(比如构造函数)?另外有个关键点:测试过程中不能实际显示任何GUI元素,这样就算在没有图形环境的情况下测试也能通过。
无繁琐初始化的写法(测试失败)
import org.junit.jupiter.api.Test; import javax.swing.JButton; import static org.mockito.Mockito.*; class MyDialogTest { @Test void okButtonClickShouldDisposeDialog() { // 用构建器创建实例,但Mockito无法监控dispose方法调用 MyDialog dialog = MyDialog.builder().build(); JButton okButton = dialog.getOkButton(); // 假设存在这个获取按钮的方法 okButton.doClick(); verify(dialog).dispose(); // 测试失败,因为dialog不是Mock/Spy对象 } }
繁琐初始化的写法(测试通过但不够优雅)
import org.junit.jupiter.api.Test; import javax.swing.JButton; import static org.mockito.Mockito.*; class MyDialogTest { @Test void okButtonClickShouldDisposeDialog() throws Exception { // 通过反射调用私有构造函数创建实例,再包装成Spy MyDialog dialog = spy(MyDialog.class.getDeclaredConstructor().newInstance()); // 可能还要手动初始化按钮等组件,流程非常繁琐 JButton okButton = dialog.getOkButton(); okButton.doClick(); verify(dialog).dispose(); // 测试通过,但代码冗余且暴露了构造细节 } }
推荐的简洁解决方案
这里有几个优雅的方案可以帮你解决问题,既不用破坏类的封装性,也能保证测试在无图形环境下正常运行:
1. 用Mockito Spy包装构建器创建的实例
Mockito的spy()方法可以直接包装真实实例,只要你的构建器能生成完整的对象,就可以把它转成Spy来监控方法调用。另外别忘了提前设置Headless模式,避免GUI真的显示出来:
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.BeforeAll; import javax.swing.JButton; import java.awt.GraphicsEnvironment; import static org.mockito.Mockito.*; class MyDialogTest { // 提前启用Headless模式,确保无图形环境也能运行 @BeforeAll static void setupHeadlessMode() { GraphicsEnvironment.getLocalGraphicsEnvironment().setHeadless(true); } @Test void okButtonClickShouldDisposeDialog() { // 用构建器创建真实实例,再包装成Spy MyDialog dialog = spy(MyDialog.builder().build()); // 如果你不想让真实的dispose()执行(比如避免GUI操作),可以打桩跳过 doNothing().when(dialog).dispose(); JButton okButton = dialog.getOkButton(); okButton.doClick(); verify(dialog).dispose(); // 现在可以成功验证方法调用了! } }
这个方案的优势是完全沿用你原有的构建器流程,不用修改类的内部结构,只是给实例加了一层监控。Headless模式的设置也能满足“不显示GUI”的要求。
2. 给构建器添加监听器注入点
如果你的MyDialog中OK按钮的点击逻辑是硬编码的,可以把监听器改成可注入的方式,这样测试时可以替换成自定义监听器,直接验证逻辑,甚至不用创建完整的Dialog实例:
// 修改后的MyDialog类,支持注入按钮监听器 class MyDialog extends JDialog { private final JButton okButton; // 私有构造函数,仅供构建器使用 private MyDialog(Builder builder) { this.okButton = new JButton("OK"); // 优先使用注入的监听器,否则用默认的dispose逻辑 okButton.addActionListener(builder.okActionListener != null ? builder.okActionListener : e -> this.dispose()); } public static Builder builder() { return new Builder(); } public static class Builder { private ActionListener okActionListener; // 添加监听器注入方法 public Builder okActionListener(ActionListener listener) { this.okActionListener = listener; return this; } public MyDialog build() { return new MyDialog(this); } } // 供测试获取按钮的方法(如果需要的话) public JButton getOkButton() { return okButton; } }
对应的测试代码可以这么写:
import org.junit.jupiter.api.Test; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; import static org.mockito.Mockito.*; class MyDialogTest { @Test void okButtonClickShouldTriggerDispose() { // 创建Mock监听器 ActionListener mockListener = mock(ActionListener.class); // 通过构建器注入监听器 MyDialog dialog = MyDialog.builder() .okActionListener(mockListener) .build(); JButton okButton = dialog.getOkButton(); okButton.doClick(); // 验证监听器被触发,间接证明dispose逻辑会执行 verify(mockListener).actionPerformed(any(ActionEvent.class)); // 或者结合Spy直接验证dispose: MyDialog spyDialog = spy(MyDialog.builder().build()); spyDialog.getOkButton().doClick(); verify(spyDialog).dispose(); } }
这种方式遵循依赖注入的思想,让测试更灵活,同时保持类的封装性,私有构造函数依然可以保留。
3. 用PowerMock绕过私有构造(不推荐)
如果上面的方案都不适用(比如构建器逻辑极度复杂),可以用PowerMock来绕过私有构造函数的限制,但我不太推荐这个方案——因为PowerMock会修改类加载器,可能带来一些潜在的兼容性问题。如果必须用,代码大概是这样:
import org.junit.jupiter.api.Test; import org.powermock.api.mockito.PowerMockito; import org.powermock.core.classloader.annotations.PrepareForTest; import org.powermock.modules.junit4.PowerMockRunner; import javax.swing.JButton; import static org.mockito.Mockito.*; @RunWith(PowerMockRunner.class) @PrepareForTest(MyDialog.class) class MyDialogTest { @Test void okButtonClickShouldDisposeDialog() throws Exception { // 用PowerMock创建Spy,无需调用构造函数 MyDialog dialog = PowerMockito.spy(MyDialog.class); // 打桩构建器方法,返回Spy实例 PowerMockito.when(MyDialog.builder().build()).thenReturn(dialog); JButton okButton = mock(JButton.class); // 打桩获取按钮的方法 when(dialog.getOkButton()).thenReturn(okButton); okButton.doClick(); verify(dialog).dispose(); } }
还是优先推荐前两种方案,尤其是第一种,简单直接,对原有代码的侵入性最小。
备注:内容来源于stack exchange,提问作者Cagepi

