关于Mock、Spy、@MockBean、@SpyBean的笔记

前言

  1. Mock是将目标对象整个模拟 ,所有方法默认都返回null,并且原方法中的代码逻辑不会执行,被Mock出来的对象,想用哪个方法,哪个方法就需要打桩,否则返回null
  2. Spy可实现对目标对象部分方法、特定入参条件时的打桩,没有被打桩的方法,将会真实调用。

本文maven依赖

本文使用了SpringJunit5Mokitocommons-lang3工具类。

<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-lang3</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

1. 非Spring环境

假设我们有如下服务:

public class DemoService {
    public String serviceA(String name) {
        System.out.println("serviceA 被真实调用了,当前入参:" + name);
        return "serviceA 真实返回:" + name;
    }

    public String serviceB(String name) {
        System.out.println("serviceB 被真实调用了,当前入参:" + name);
        return "serviceB 真实返回:" + name;
    }
}

我们用如下单测代码示例,注释已经很清楚了,此处不再赘述。

import org.apache.commons.lang3.RandomStringUtils;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;

class DemoServiceTest {

    @Test
    @DisplayName("Mock的正确姿势")
    void testMock() {
        // Mock 出一个目标对象的实例,注意不是 new 出来的真实对象
        DemoService mockService = Mockito.mock(DemoService.class);

        // 假设我们仅对 serviceA 方法,并且入参等于 "asdf" 时进行打桩
        Mockito.when(mockService.serviceA(Mockito.eq("asdf")))
                .thenReturn("mock:serviceA方法入参等于asdf时的特定返回值");

        // 由于上边已经打桩了,并且这个调用正好命中打桩规则,因次返回值将是 "mock:serviceA方法入参等于asdf时的特定返回值"
        String asdf = mockService.serviceA("asdf");
        System.out.println("asdf = " + asdf);
        Assertions.assertEquals("mock:serviceA方法入参等于asdf时的特定返回值", asdf);

        // 虽然也是调用 serviceA 方法,但由于没有命中打桩规则,所以返回值是 null
        String qwer = mockService.serviceA("qwer");
        System.out.println("qwer = " + qwer);
        Assertions.assertNull(qwer);

        // serviceB 根本没有打桩,但由于 mockService 这个对象实例是 Mock 出来的,
        // 所以 serviceB 的方法体代码不会被执行,并且返回值固定为 null,不管入参是什么
        for (int i = 0; i < 3; i++) {
            String returnValueFromRandom = mockService.serviceB(RandomStringUtils.random(10));
            System.out.println("returnValueFromRandom 第[" + i + "]次 = " + returnValueFromRandom);
            Assertions.assertNull(returnValueFromRandom);
        }
    }

    @Test
    @DisplayName("Spy的错误打桩姿势")
    void testBadSpy() {
        // Spy 一个目标对象的实例,注意不是 new 出来的 100% 真实的实例,也不是 100% 假的实例
        // 到底有多真,有多假,取决于打桩埋点的覆盖程度
        DemoService spyService = Mockito.spy(DemoService.class);

        // 打桩错误示例:参照 Mock 打桩的写法,预期对 serviceA 方法打桩,
        // 并且当入参等于 "asdf" 时,返回特定值 "spy:serviceA方法入参等于asdf时的特定返回值"
        Mockito.when(spyService.serviceA(Mockito.eq("asdf")))
                .thenReturn("spy:serviceA方法入参等于asdf时的特定返回值");

        // 由于上边已经打桩了,并且这个调用正好命中打桩规则,因次返回值将是 "spy:serviceA方法入参等于asdf时的特定返回值"
        String asdf = spyService.serviceA("asdf");
        System.out.println("asdf = " + asdf);
        Assertions.assertEquals("spy:serviceA方法入参等于asdf时的特定返回值", asdf);
        // 注意:上述对入参等于 "asdf" 时,被"spy错误打桩"的方法 serviceA 的返回值的断言,是可以跑通的
        // 但是,存在如下两个问题:
        // 1. serviceA 被真实调用了;(这一点往往不是我们的预期)
        // 2. serviceA 被真实调用时,它的实际入参,其实并不是 "asdf",而是 null。
        // (想象一下如果 serviceA 方法体内真正运行时,极有可能由于实际入参是 null 而抛出异常中断执行,这是一种灾难)

        // 下述调用,虽然 serviceA 被打桩,由于 "qwer" 并没有命中打桩点,所以它不会返回打桩点设定的返回值
        // 不同于 Mock 出来的对象,下述调用会真实地执行 serviceA 方法体代码块,而不是像 Mock 一样返回 null
        System.out.println("==============================================");
        String qwer = spyService.serviceA("qwer");
        System.out.println("qwer = " + qwer);
        Assertions.assertEquals("serviceA 真实返回:qwer", qwer);

        // 同理,serviceB 方法没有被打桩,但由于 spyService 是 Spy 出来的对象实例,
        // 不同于 Mock 出来的对象实例返回 null,对 serviceB 的调用会真实的执行目标方法,并按真实情况返回
        System.out.println("==============================================");
        String zxcv = spyService.serviceB("zxcv");
        System.out.println("zxcv = " + zxcv);
        Assertions.assertEquals("serviceB 真实返回:zxcv", zxcv);
    }

    @Test
    @DisplayName("Spy的正确打桩姿势")
    void testGoodSpy() {
        // 同样,先用 Spy 方式创建一个目标对象的实例
        DemoService spyService = Mockito.spy(DemoService.class);

        // 对 Spy 对象的正确打桩姿势,
        // (巧了,用这种 doReturn|doThrow|doAnswer(xxx).when(obj).methodXXX() 的方式,也适用于 Mock 出来的对象)
        Mockito.doReturn("spy:serviceA方法入参等于asdf时的特定返回值2")
                // 注意这里 when 里面是对象实例的变量名,而不是一个 methodCall
                .when(spyService)
                // 再通过 when 泛型方法返回的Spy实例引用,对目标方法打桩
                .serviceA(Mockito.eq("asdf"));

        // 由于上边已经打桩了,并且这个调用正好命中打桩规则,因次返回值将是 "spy:serviceA方法入参等于asdf时的特定返回值2"
        String asdf = spyService.serviceA("asdf");
        System.out.println("asdf = " + asdf);
        Assertions.assertEquals("spy:serviceA方法入参等于asdf时的特定返回值2", asdf);
        // 说明:由于上面正确的打桩姿势,上述被命中打桩规则的调用,不会触发目标方法的真实调用,不存在上述错误用法的2个问题。

        // 对于调用打桩方法,但没有命中打桩入参条件的调用,表现行为与上述 testBadSpy 单测代码一样
        System.out.println("==============================================");
        String qwer = spyService.serviceA("qwer");
        System.out.println("qwer = " + qwer);
        Assertions.assertEquals("serviceA 真实返回:qwer", qwer);

        // 同理,对于没有打桩的 spy 对象实例的其他方法,表现行为与上述 testBadSpy 单测代码一样
        System.out.println("==============================================");
        String zxcv = spyService.serviceB("zxcv");
        System.out.println("zxcv = " + zxcv);
        Assertions.assertEquals("serviceB 真实返回:zxcv", zxcv);
    }
}

2. Spring环境

假设我们有如下服务:

import org.springframework.stereotype.Component;

@Component
public class DemoBeanService {
    public String serviceA(String name) {
        System.out.println("bean serviceA 被真实调用了,当前入参:" + name);
        return "bean serviceA 真实返回:" + name;
    }

    public String serviceB(String name) {
        System.out.println("bean serviceB 被真实调用了,当前入参:" + name);
        return "bean serviceB 真实返回:" + name;
    }
}

注意:由于Spring IoC默认是单例的,为了区分,我们分别为@MockBean@SpyBean创建单独的测试类举例。

@MockBean示例

import org.apache.commons.lang3.RandomStringUtils;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.ContextConfiguration;

@ContextConfiguration(classes = DemoBeanService.class) // 此处我们的IoC只扫描注入 DemoBeanService 类
@SpringBootTest
public class MockBeanTest {
    @MockBean
    DemoBeanService mockBeanService;

    @Test
    @DisplayName("MockBean的正确姿势")
    void testMock() {
        // 不同于普通 Mock 方式,这里不需要调用 Mockito.mock(xxx.class) 创建 mock 对象实例
        // 我们已经通过 @MockBean 的方式,将一个 mock 对象的实例放入了 Spring IoC ApplicationContext 中
        // 注意到 Spring IoC 默认是单例的,也就是当前 ApplicationContext 中,只有一个 mock 出来的 DemoBeanService 实例
        // 所以,如果它打桩不全的话,在当前这个 IoC 中,调用没有被打桩的方法,将一律返回 null
        // 因此在不同的 xxxTest.java 中出现不同 MockBean 时,会触发 Spring 上下文重建,写的 MockBean 越多,整个工程单测就越慢

        // 假设我们仅对 serviceA 方法,并且入参等于 "asdf" 时进行打桩
        Mockito.when(mockBeanService.serviceA(Mockito.eq("asdf")))
                .thenReturn("MockBean:serviceA方法入参等于asdf时的特定返回值");

        // 由于上边已经打桩了,并且这个调用正好命中打桩规则,因次返回值将是 "MockBean:serviceA方法入参等于asdf时的特定返回值"
        String asdf = mockBeanService.serviceA("asdf");
        System.out.println("asdf = " + asdf);
        Assertions.assertEquals("MockBean:serviceA方法入参等于asdf时的特定返回值", asdf);

        // 虽然也是调用 serviceA 方法,但由于没有命中打桩规则,所以返回值是 null
        String qwer = mockBeanService.serviceA("qwer");
        System.out.println("qwer = " + qwer);
        Assertions.assertNull(qwer);

        // serviceB 根本没有打桩,但由于 mockBeanService 这个对象实例是 Mock 出来的,
        // 所以 serviceB 的方法体代码不会被执行,并且返回值固定为 null,不管入参是什么
        for (int i = 0; i < 3; i++) {
            String returnValueFromRandom = mockBeanService.serviceB(RandomStringUtils.random(10));
            System.out.println("returnValueFromRandom 第[" + i + "]次 = " + returnValueFromRandom);
            Assertions.assertNull(returnValueFromRandom);
        }
    }
}

@SpyBean示例

import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.SpyBean;
import org.springframework.test.context.ContextConfiguration;

@ContextConfiguration(classes = DemoBeanService.class)
@SpringBootTest
public class SpyBeanTest {
    @SpyBean
    DemoBeanService spyBeanService;

    @Test
    @DisplayName("SpyBean的错误打桩姿势")
    void testBadSpyBean() {
        // 与 MockBean 类似,此处也不再需要 Mockito.spy(xxx.class) 创建 spy 对象实例
        // IoC 中也同样有一个 spy 对象实例,详情参见 @MockBean 的Test示例

        // 打桩错误示例:参照 Mock 打桩的写法,预期对 serviceA 方法打桩,
        // 并且当入参等于 "asdf" 时,返回特定值 "SpyBean:serviceA方法入参等于asdf时的特定返回值"
        Mockito.when(spyBeanService.serviceA(Mockito.eq("asdf")))
                .thenReturn("SpyBean:serviceA方法入参等于asdf时的特定返回值");

        // 由于上边已经打桩了,并且这个调用正好命中打桩规则,因次返回值将是 "SpyBean:serviceA方法入参等于asdf时的特定返回值"
        String asdf = spyBeanService.serviceA("asdf");
        System.out.println("asdf = " + asdf);
        Assertions.assertEquals("SpyBean:serviceA方法入参等于asdf时的特定返回值", asdf);
        // 注意:上述对入参等于 "asdf" 时,被"spy错误打桩"的方法 serviceA 的返回值的断言,是可以跑通的
        // 但是,存在如下两个问题:
        // 1. serviceA 被真实调用了;(这一点往往不是我们的预期)
        // 2. serviceA 被真实调用时,它的实际入参,其实并不是 "asdf",而是 null。
        // (想象一下如果 serviceA 方法体内真正运行时,极有可能由于实际入参是 null 而抛出异常中断执行,这是一种灾难)

        // 下述调用,虽然 serviceA 被打桩,由于 "qwer" 并没有命中打桩点,所以它不会返回打桩点设定的返回值
        // 不同于 Mock 出来的对象,下述调用会真实地执行 serviceA 方法体代码块,而不是像 Mock 一样返回 null
        System.out.println("==============================================");
        String qwer = spyBeanService.serviceA("qwer");
        System.out.println("qwer = " + qwer);
        Assertions.assertEquals("bean serviceA 真实返回:qwer", qwer);

        // 同理,serviceB 方法没有被打桩,但由于 spyService 是 Spy 出来的对象实例,
        // 不同于 Mock 出来的对象实例返回 null,对 serviceB 的调用会真实的执行目标方法,并按真实情况返回
        System.out.println("==============================================");
        String zxcv = spyBeanService.serviceB("zxcv");
        System.out.println("zxcv = " + zxcv);
        Assertions.assertEquals("bean serviceB 真实返回:zxcv", zxcv);
    }

    @Test
    @DisplayName("SpyBean的正确打桩姿势")
    // 注意:由于和 testBadSpyBean() 方法是分别打桩的(都是在各自的方法体局部打桩),因此不会互相影响
    void testGoodSpyBean() {
        // 当前测试方法(testGoodSpyBean),不使用 Mockito.spy(xxx.class) 创建 spy 对象实例
        // 同时,由于和 testBadSpyBean() 在一个测试类中,会共用同一个 spy 对象实例(DemoBeanService的instance)

        // 对 Spy 对象的正确打桩姿势,
        // (巧了,用这种 doReturn|doThrow|doAnswer(xxx).when(obj).methodXXX() 的方式,也适用于 Mock 出来的对象)
        Mockito.doReturn("SpyBean:serviceA方法入参等于asdf时的特定返回值3")
                // 注意这里 when 里面是对象实例的变量名,而不是一个 methodCall
                .when(spyBeanService)
                // 再通过 when 泛型方法返回的Spy实例引用,对目标方法打桩
                .serviceA(Mockito.eq("asdf"));

        // 由于上边已经打桩了,并且这个调用正好命中打桩规则,因次返回值将是 "SpyBean:serviceA方法入参等于asdf时的特定返回值3"
        String asdf = spyBeanService.serviceA("asdf");
        System.out.println("asdf = " + asdf);
        Assertions.assertEquals("SpyBean:serviceA方法入参等于asdf时的特定返回值3", asdf);
        // 说明:由于上面正确的打桩姿势,上述被命中打桩规则的调用,不会触发目标方法的真实调用,不存在上述错误用法的2个问题。

        // 对于调用打桩方法,但没有命中打桩入参条件的调用,表现行为与上述 testBadSpy 单测代码一样
        System.out.println("==============================================");
        String qwer = spyBeanService.serviceA("qwer");
        System.out.println("qwer = " + qwer);
        Assertions.assertEquals("bean serviceA 真实返回:qwer", qwer);

        // 同理,对于没有打桩的 spy 对象实例的其他方法,表现行为与上述 testBadSpy 单测代码一样
        System.out.println("==============================================");
        String zxcv = spyBeanService.serviceB("zxcv");
        System.out.println("zxcv = " + zxcv);
        Assertions.assertEquals("bean serviceB 真实返回:zxcv", zxcv);
    }
}

补充

  1. 其实,@MockBean@SpyBean注解,除了在FIELD上可以支持外,也可以直接在测试类上使用,适用范围可参考上述注解的源码@Target({ ElementType.TYPE, ElementType.FIELD })。因为这个漂亮的特性,我们可以通过设计一个测试基类BaseTest,让它的所有测试子类,最大程度地共享同一个IoC容器,而不是频繁触发重建ApplicationContext拖慢执行速度,从而提高整个工程的单测执行效率。
  2. 此外,在JAVA8以前,是可以使用@MockBeans@SpyBeans在同一个类上添加多个对应的@MockBean@SpyBean,在JAVA8以后,得益于@Repeatable注解,您可以直接在一个类上添加n个单一注解@MockBean@SpyBean
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,530评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 86,403评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,120评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,770评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,758评论 5 367
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,649评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,021评论 3 398
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,675评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,931评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,659评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,751评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,410评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,004评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,969评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,203评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,042评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,493评论 2 343

推荐阅读更多精彩内容