跳过构造函数创建 Java 对象(测试)

如果一个 Java 类在初始化时会有外部依赖,这就给单元测试创建它的实例时造成困难。当然被测试类可以改造为依赖全部构造时注入或创建实例后延迟注入,这里不考虑这种改造。


可以参看我以前一篇类似的日志:使用 JMockit 来 mock 构造函数

来说下面的例子
1public class OrderService {
2    private PriceInquiry priceInquiry = new PriceInquiry();
3    .........
4
5    public double totalPrice() {
6      return priceInquiry.retrieve(....);
7    }
8}

假如上面的代码是不能改动的,并且在 new PriceInquiry() 时依赖于网络环境,所以单机情况不能创建成功。也就使得测试时试图
new OrderService();
会失败。并且试图用 Mockito 的 @InjectMocks 也不行
 1@RunWith(MockitoJUnitRunner.class)
 2public class OrderServiceTest {
 3
 4    @Mock
 5    private PriceInquiry priceInquiry;
 6
 7    @InjectMocks
 8    private OrderService testMe;
 9
10    @Test
11    public void fooTest() {
12        ....
13    }
14}

会出类似下面的借
org.mockito.exceptions.base.MockitoException:
Cannot instantiate @InjectMocks field named 'testMe' of type 'class cc.unmi.OrderService'.
You haven't provided the instance at field declaration so I tried to construct the instance.
However the constructor or the initialization block threw an exception : xxxxxxxxxxxxxxxx
想要千方百计先创建出实例再转换掉内部的 priceInquiry 属性值的打算也落空了,因为无论是用 new 还是 @InjectMocks 怎么都跳不过构造函数的执行(实例成员的初始化会放到构造函数中去,没有声明构造函数会有一个默认构造函数)

因此上面的需求就是如何在测试类跳过构造函数,初步想到的办法有四

一. 反序列化跳过构造函数

ObjectInputstream.readObject() 反列化出 OrderService 对象,但前提是先要有序列化出的字节数据,所以不好操作,还会有 serialVersionUID 不一致的问题

二. 使用 sun.misc.Unsafe 内部 API 

 1@Test
 2public void fooTest() {
 3    OrderService testMe = createTestedInstance(OrderService.class);
 4
 5    PriceInquiry priceInquiry = Mockito.mock(PriceInquiry.class);
 6    Whitebox.setInternalState(testMe, "priceInquiry", priceInquiry);  //通过反射转换掉内部属性以使用 Mock 对象
 7
 8    //Your test here
 9}
10
11@SuppressWarnings("unchecked")
12private <T> T createTestedInstance(Class<T> clazz) {
13    try {
14        Field singleoneInstanceField = Unsafe.class.getDeclaredField("theUnsafe");
15        singleoneInstanceField.setAccessible(true);
16        Unsafe unsafe = (Unsafe) singleoneInstanceField.get(null);
17        return (T)unsafe.allocateInstance(clazz);
18    } catch (Exception e) {
19        throw new RuntimeException("cannot instantiate " + clazz + " bypassing default constructor");
20    }
21}

但是上面的代码编译时会有警告
[WARNING] COMPILATION WARNING :
...........................................sun.misc.Unsafe is internal proprietary API and may be removed in a future release
并且是没法用 `SuppressWarnings` 抑制住的警告,如果用 Maven 时配置了用 -Werror 编译选项的话将无法构建成功。除非不用 -Werror 选项,否则用他法来通过构建还不容易搞

三. 用 JMockit 来 mock 构造函数

又要体验到 JMockit 比 Mockito 强大之处,我们不是一般问题还不愿意祭出 JMockit 来
 1@Test
 2public void fooTest() { 
 3    new MockUp<OrderService>() {
 4        @mockit.Mock
 5        public void $init() { //这样就不会调用 OrderService 实际的构造函数
 6        }
 7    };
 8    
 9    OrderService testMe = new OrderService();
10    Whitebox.setInternalState(testMe, "priceInquiry", mockedPriceInquiry);
11
12    //Your test here
13}

通常情况下我只是用 JMockit 来辅助 Mockito, 因为更习惯于 Mockito 流畅的打桩(Stubbing) 和校验(Verifying) API。

四. Deencapsulation.newUninitializedInstance(clazz), JMockit 更直截的方式

写本文之前只想到前面三种方式,借此机会又重新看了一个 JMockit 的 Deencapsulation API,发现一个更直截了当的方式,方法名为 newUninitializedInstance(clazz)。顾名思义,就是构造实例不初始化内部状态,恰恰是我所追求的。于是事情变得更为明了
1@Test
2public void fooTest() {
3    OrderService testMe = Deencapsulation.newUninitializedInstance(OrderService.class);
4    Deencapsulation.setField(testMe, "priceInquiry", mockedPriceInquiry);
5
6    //Your test here
7}

连设置内部状态的 API Deencapsulation 也提供了,用不着模仿着 Mockito 1 做了一个 Whitebox 类来进行反射操作。

最后,在测试中着重推荐用第四种方式,第三种方式也行,它们都是 JMockit 提供的实现。用 JMockit 来辅助 Mockito 跳过构造函数创建实例,而后替换实例的内部状态,再然后就是 Mockito 的事情了。如果被测试类的外部依赖能够通过构造函数或 setter 方法来注入就更简单了,常规手段而无需跳过构造函数就能创建被测试类的实例了。

以上方式只是实例变量不被初始化,静态变量(即类变量) 不受影响,也就是说如果类中有
1private static Logger logger = LoggerFactory.getLogger(OrderService.class);

logger 静态变量总是会被初始化。 永久链接 https://yanbin.blog/create-java-instance-bypass-constructor/, 来自 隔叶黄莺 Yanbin's Blog
[版权声明] 本文采用 署名-非商业性使用-相同方式共享 4.0 国际 (CC BY-NC-SA 4.0) 进行许可。