Mock 测试

Mock 基本概念介绍

mock 测试就是在测试过程中,对于某些不容易构造或者不容易获取的对象,用一个虚拟的对象来创建以便测试的测试方法。

好处

  1. 团队并行工作
    团队间不需互相等待对方进度,只需约定好相互之间的数据规范(接口文档),即可使用 mock 构建出可用接口,然后尽快进行开发和自测,提前发现缺陷
  2. 测试驱动开发 TDD (Test-Driven Development)
    单元测试是 TDD 实现的基石,而 TDD 经常会碰到协同模块尚未开发完成的情况,但有了 mock,当接口定义好后,测试人员就可以创建一个 Mock,把接口添加到自动化测试环境,提前创建测试。
  3. 测试覆盖率
    若一个接口在不同的状态下要返回不同的值,常见做法是复现这种状态然后再去请求接口,而这种方法很可能因操作时机或方式不当导致失败,甚至污染后端存储如数据库等, 但用 mock 则不用担心
  4. 隔离系统
    使用某些接口时,为避免系统数据库被污染,可以将接口调整为 Mock 模式,以保证数据库纯净。
  5. 方便演示

Mock 框架介绍

因为项目主要基于 Java 开发, 因此下面主要介绍 Java 相关的 mock 框架, 其他语言思想类似

moco

moco 框架在开发 Mock 服务的时候提供了一种不需任何编程语言的方式, 可以通过撰写它约束的 json 建立服务, 并通过命令独立启动对应的服务, 这可以快速开发和启动运行所需的 Mock 服务. 除此之外, 也可以编写服务代码来进行测试. 下面进行简单举例:

  1. 使用 json 配置文件启动 mock 服务
# foo.json
[
  {
    "response" :
      {
        "text" : "Hello, Moco"
      }
  }
]
java -jar moco-runner-1.1.0-standalone.jar  http -p 12306 -c foo.json

这时访问 http://localhost:12306/ 将会返回 Hello, Moco

  1. 在项目中使用 moco Java API
    除了使用 json 配置文件作为独立服务启动外, 还可以使用 Java API 来启动 mock 服务, 下面是代码片段:
@RunWith(SpringRunner.class)
@SpringBootTest(classes = MockServletContext.class)
public class MockAPITest {
    @Test
    public void should_response_as_expected() throws Exception {
        HttpServer server = httpServer(12307);
        server.response("foo");
        running(server, new Runnable() {
            @Override
            public void run() throws IOException {
                CloseableHttpResponse response = HttpClients.createDefault().execute(new HttpGet("http://localhost:12307"));
                String content = EntityUtils.toString(response.getEntity(), "UTF-8");
                assertThat(content, is("foo"));
            }
        });
    }
}

moco 还支持 HTTPS 和 Socket, 支持与 JUnit 集成等, 详细内容见文档使用说明

SpringMVC 单元测试 - MockMvc

MockMvc 实现了对 Http 请求的模拟,可以方便对 Controller 进行测试,测试速度快、不依赖网络环境,且提供了验证的工具。下面是具体示例:

  • HelloController
//HelloController
@RestController
public class HelloController {
    @RequestMapping("/hello")
    public String index() {
        return "Hello World";
    }
}
  • UserController
//UserController
@Slf4j
@RestController
@RequestMapping(value = "/users")     // 通过这里配置使下面的映射都在/users下
public class UserController {
    // 创建线程安全的Map
    static Map<Long, User> users = Collections.synchronizedMap(new HashMap<Long, User>());

    @RequestMapping(value = "/", method = RequestMethod.GET)
    public List<User> getUserList() {
        // 处理"/users/"的GET请求,用来获取用户列表
        // 还可以通过@RequestParam从页面中传递参数来进行查询条件或者翻页信息的传递
        List<User> r = new ArrayList<User>(users.values());
        return r;
    }

    @RequestMapping(value = "/", method = RequestMethod.POST)
    public String postUser(@ModelAttribute User user) {
        // 处理"/users/"的POST请求,用来创建User
        // 除了@ModelAttribute绑定参数之外,还可以通过@RequestParam从页面中传递参数
        users.put(user.getId(), user);
        return "success";
    }

    @RequestMapping(value = "/{id}", method = RequestMethod.GET)
    public User getUser(@PathVariable Long id) {
        // 处理"/users/{id}"的GET请求,用来获取url中id值的User信息
        // url中的id可通过@PathVariable绑定到函数的参数中
        return users.get(id);
    }

    @RequestMapping(value = "/{id}", method = RequestMethod.PUT)
    public String putUser(@PathVariable Long id, @ModelAttribute User user) {
        // 处理"/users/{id}"的PUT请求,用来更新User信息
        User u = users.get(id);
        u.setName(user.getName());
        u.setAge(user.getAge());
        users.put(id, u);
        return "success";
    }

    @RequestMapping(value = "/{id}", method = RequestMethod.DELETE)
    public String deleteUser(@PathVariable Long id) {
        // 处理"/users/{id}"的DELETE请求,用来删除User
        users.remove(id);
        return "success";
    }
    // 测试 
    @RequestMapping(value = "/postByJson", method = RequestMethod.POST)
    public String postByJson(@RequestBody User user, String method) {
        log.info("user: {};   method: {}", user, method);
        return "success";
    }
}

  • 单元测试类 HttpMockTest
public class HttpMockTest {

    private MockMvc mvc;
    private final static ObjectMapper objectMapper = new ObjectMapper();

    @Before
    public void setUp() throws Exception {
        mvc = MockMvcBuilders.standaloneSetup(
                new HelloController(),
                new UserController()).build();
    }

    @Test
    public void getHello() throws Exception {
        mvc.perform(MockMvcRequestBuilders.get("/hello").accept(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk())
                .andExpect(content().string(equalTo("Hello World")));
    }

    @Test
    public void testUserController() throws Exception {
        // 测试UserController
        RequestBuilder request = null;

        // 1、get查一下user列表,应该为空
        request = get("/users/");
        mvc.perform(request)
                .andExpect(status().isOk())
                .andExpect(content().string(equalTo("[]")));

        // 2、post提交一个user
        request = post("/users/")
                .param("id", "1")
                .param("name", "测试大师")
                .param("age", "20");
        mvc.perform(request)
                .andDo(MockMvcResultHandlers.print())
                .andExpect(content().string(equalTo("success")));

        // 3、get获取user列表,应该有刚才插入的数据
        request = get("/users/");
        mvc.perform(request)
                .andExpect(status().isOk())
                .andExpect(content().string(equalTo("[{\"id\":1,\"name\":\"测试大师\",\"age\":20}]")));

        // 4、put修改id为1的user
        request = put("/users/1")
                .param("name", "测试终极大师")
                .param("age", "30");
        mvc.perform(request)
                .andExpect(content().string(equalTo("success")));

        // 5、get一个id为1的user
        request = get("/users/1");
        mvc.perform(request)
                .andExpect(content().string(equalTo("{\"id\":1,\"name\":\"测试终极大师\",\"age\":30}")));

        // 6、del删除id为1的user
        request = delete("/users/1");
        mvc.perform(request)
                .andExpect(content().string(equalTo("success")));

        // 7、get查一下user列表,应该为空
        request = get("/users/");
        mvc.perform(request)
                .andExpect(status().isOk())
                .andExpect(content().string(equalTo("[]")));

        // 8、json作为参数
        request = post("/users/postByJson")
                .param("method", "postByJson")
                .content(objectMapper.writeValueAsString(new User(1L, "USER", 23)))
                .contentType(MediaType.APPLICATION_JSON);
        mvc.perform(request).andExpect(status().is(200))
                .andExpect(content().string("success"));
    }
}

Mockito & PowerMock

Mockito 是 GitHub 上使用非常广泛的 Java Mock 框架, star 数 11k, 在包括 openstack4jkubernetes-client/java 等都有用到. Mockito 与 JUnit 结合使用, 能隔离外部依赖以便对自己的业务逻辑代码进行单元测试在编写单元测试需要调用某一个接口时,可以模拟一个假方法,并任意指定方法的返回值。Mockito 的工作原理是通过创建依赖对象的 proxy,所有的调用先经过 proxy 对象,proxy 对象拦截了所有的请求再根据预设的返回值进行处理。但缺点是 Mockito 2 版本对静态方法、final 方法、private 方法和构造函数的功能支持并不完善, 因此 PowerMock 则在 Mockito 原有的基础上做了扩展,通过修改类字节码并使用自定义 ClassLoader 加载运行的方式来实现 mock 静态方法、final 方法、private 方法和构造函数等功能。

Mockito & PowerMock 一般测试步骤

1. mock: 模拟对象

用 mock()/@Mock 或 spy()/@Spy 创建模拟对象, 两者创建出来的模拟对象区别是: 使用 mock 生成的对象,所有方法都是被 mock 的,除非某个方法被 stub 了,否则返回值都是默认值; 使用 spy 生产的 spy 对象,所有方法都是调用的 spy 对象的真实方法,直到某个方法被 stub 后

2. stub: 定义桩函数

可以通过 when()/given()/thenReturn()/doReturn()/thenAnswer() 等来定义 mock 对象如何执行, 如果提供的接口不符合需求, 还可以通过实现 Answer 接口来自定义实现

3. run: 执行调用

执行实际方法的调用,此时被 mock 的对象将返回自定义的桩函数的返回值

4. verify: 可选, 对调用进行验证, 如是否被调用, 调用次数等

这一步可以对 mock 对象的方法是否被调用以及被调用次数进行验证,同时还可以对参数捕获进行参数校验

下面以操作 Redis 和 RabbitMQ 来进行简单举例。

Redis
// redis 操作类
class RedisDemo {

    private Jedis jedis;

    public void setUp() {
        jedis = new Jedis("127.0.0.1", 6379);
        jedis.connect();
    }

    public boolean isAdmin(String user) {
        String ret = jedis.get("name");
        if (user.equals(ret)) {
            return true;
        }
        return false;
    }

    public void set(String key, String val) {
        jedis.set(key, val);
    }

    public String get(String key) {
        String s = jedis.get(key);
        return s;
    }

    void out(){
        System.out.println("ss");
    }
}

// 单元测试类
@RunWith(PowerMockRunner.class) //让测试运行于PowerMock环境
public class RedisMockitoTest {

    @Mock //此注解会自动创建1个mock对象并注入到@InjectMocks对象中
    private Jedis jedis;

    @InjectMocks
    private RedisDemo demo;

    @Mock
    StringOperator stringOperator;

    //第1种方式
    @Test
    public void redisTest1() throws Exception {
        Mockito.when(jedis.get("name")).thenReturn("admin");
        boolean admin = demo.isAdmin("admin");
        assertTrue(admin);
    }

    //第2种方式
    @Test
    public void redisTest2() {
        RedisDemo demo = mock(RedisDemo.class);
        ReflectionTestUtils.setField(demo, "jedis", jedis);
        when(demo.isAdmin("admin")).thenReturn(true);
        boolean admin = demo.isAdmin("admin");
        assertTrue(admin);
    }

    //第3种方式
    @Test
    public void redisTest3() {
        RedisDemo demo = mock(RedisDemo.class);
        doReturn(true).when(demo).isAdmin("admin");
        System.out.println(demo.isAdmin("admin"));
    }
}
RabbitMQ
@Component
public class DirectReceiver {
    @Autowired
    RabbitTemplate rabbitTemplate;

    public Object getMsg() {
        return rabbitTemplate.receiveAndConvert("queue_demo");
    }
}

@RunWith(SpringJUnit4ClassRunner.class)
@SpringBootTest(classes = Main.class)
public class RecvMessage {
    @Spy
    RabbitTemplate rabbitTemplate;

    @InjectMocks
    @Autowired
    DirectReceiver receiver;

    @Test
    public void recvTest() {
        doReturn("Mock answer").when(rabbitTemplate).receiveAndConvert("queue_demo");
        System.out.println(rabbitTemplate.receiveAndConvert("queue_demo"));
    }
}
更多示例
public class Node {
    private int num;
    private String name;

    public static Node getStaticNode() {
        return new Node(1, "static node");
    }

    public Node() {
    }

    public Node(String name) {
        this.name = name;
    }

    public Node(int num) {
        this.num = num;
    }

    public Node(int num, String name) {
        this.num = num;
        this.name = name;
    }
}

public class LocalServiceImpl implements ILocalService {

    @Autowired
    private IRemoteService remoteService;

    @Override
    public Node getLocalNode(int num, String name) {
        return new Node(num, name);
    }

    @Override
    public Node getRemoteNode(int num) {
        return remoteService.getRemoteNode(num);
    }

    @Override
    public Node getRemoteNode(String name) throws MockException {
        try {
            return remoteService.getRemoteNode(name);
        } catch (IllegalArgumentException e) {
            throw e;
        }
    }

    @Override
    public void remoteDoSomething() {
        remoteService.doSometing();
    }

}

public class RemoteServiceImpl implements IRemoteService {

    @Override
    public Node getRemoteNode(int num) {
        return new Node(num, "Node from remote service");
    }

    @Override
    public final Node getFinalNode() {
        return new Node(1, "final node");
    }

    @Override
    public Node getRemoteNode(String name) throws MockException {
        if (StringUtils.isEmpty(name)) {
            throw new MockException("name不能为空", name);
        }
        return new Node(name);
    }

    @Override
    public void doSometing() {
        System.out.println("remote service do something!");
    }

    @Override
    public Node getPrivateNode() {
        return privateMethod();
    }

    private Node privateMethod() {
        return new Node(1, "private node");
    }

    @Override
    public Node getSystemPropertyNode() {
        return new Node(System.getProperty("abc"));
    }
}

// 单元测试类
@RunWith(MockitoJUnitRunner.class) //让测试运行于Mockito环境
public class LocalServiceImplMockTest {

    @InjectMocks //此注解表示这个对象需要被注入mock对象
    private LocalServiceImpl localService;
    @Mock //此注解会自动创建1个mock对象并注入到@InjectMocks对象中
    private RemoteServiceImpl remoteService;
    @Captor
    private ArgumentCaptor<String> localCaptor;

    //如果不使用上述注解,可以使用@Before方法来手动进行mock对象的创建和注入,但会多几行代码
    /*@Before
    public void setUp() throws Exception {
        localService = new LocalServiceImpl();
        remoteService = mock(RemoteServiceImpl.class);
        Whitebox.setInternalState(localService, "remoteService", remoteService);
    }*/

    /**
     * any系列方法指定多参数情况
     */
    @Test
    public void testAny() {
        Node target = new Node(1, "target");
        when(remoteService.getRemoteNode(anyInt())).thenReturn(target); //静态导入Mockito.when和ArgumentMatchers.anyInt后可以简化代码提升可读性

        Node result = localService.getRemoteNode(20); //上面指定了调用remoteService.getRemoteNode(int)时,不管传入什么参数都会返回target对象
        assertEquals(target, result);   //可以断言我们得到的返回值其实就是target对象
        assertEquals(1, result.getNum());   //具体属性和我们指定的返回值相同
        assertEquals("target", result.getName());   //具体属性和我们指定的返回值相同
    }

    /**
     * 指定mock多次调用返回值
     */
    @Test
    public void testMultipleReturn() {
        Node target1 = new Node(1, "target");
        Node target2 = new Node(1, "target");
        Node target3 = new Node(1, "target");
        when(remoteService.getRemoteNode(anyInt())).thenReturn(target1).thenReturn(target2).thenReturn(target3);
        //第一次调用返回target1、第二次返回target2、第三次返回target3

        Node result1 = localService.getRemoteNode(1); //第1次调用
        assertEquals(target1, result1);
        Node result2 = localService.getRemoteNode(2); //第2次调用
        assertEquals(target2, result2);
        Node result3 = localService.getRemoteNode(3); //第3次调用
        assertEquals(target3, result3);
    }

    /**
     * 指定mock对象已声明异常抛出的方法抛出受检查异常
     */
    @Test
    public void testCheckedException() {
        try {
            Node target = new Node(1, "target");
            when(remoteService.getRemoteNode("name")).thenReturn(target).thenThrow(new MockException("message", "exception")); //第一次调用正常返回,第二次则抛出一个Exception

            Node result1 = localService.getRemoteNode("name");
            assertEquals(target, result1); //第一次调用正常返回

            Node result2 = localService.getRemoteNode("name"); //第二次调用不会正常返回,会抛出异常
            assertEquals(target, result2);
        } catch (MockException e) {
            assertEquals("exception", e.getName()); //验证是否返回指定异常内容
            assertEquals("message", e.getMessage()); //验证是否返回指定异常内容
        }
    }

    /**
     * 校验mock对象和方法的调用情况
     */
    public void testVerify() {
        Node target = new Node(1, "target");
        when(remoteService.getRemoteNode(anyInt())).thenReturn(target);

        verify(remoteService, Mockito.never()).getRemoteNode(1); //mock方法未调用过

        localService.getRemoteNode(1);
        verify(remoteService, times(1)).getRemoteNode(anyInt()); //目前mock方法调用过1次

        localService.getRemoteNode(2);
        verify(remoteService, times(2)).getRemoteNode(anyInt()); //目前mock方法调用过2次
        verify(remoteService, times(1)).getRemoteNode(2); //目前mock方法参数为2只调用过1次
    }

    /**
     * mock对象调用真实方法
     */
    @Test
    public void testCallRealMethod() {
        when(remoteService.getRemoteNode(anyInt())).thenCallRealMethod(); //设置调用真实方法
        Node result = localService.getRemoteNode(1);

        assertEquals(1, result.getNum());
        assertEquals("Node from remote service", result.getName());
    }

    /**
     * 利用ArgumentCaptor捕获方法参数进行mock方法参数校验
     */
    @Test
    public void testCaptor() throws Exception {
        Node target = new Node(1, "target");
        when(remoteService.getRemoteNode(anyString())).thenReturn(target);

        localService.getRemoteNode("name1");
        localService.getRemoteNode("name2");
        verify(remoteService, atLeastOnce()).getRemoteNode(localCaptor.capture()); //设置captor

        assertEquals("name2", localCaptor.getValue()); //获取最后一次调用的参数
        List<String> list = localCaptor.getAllValues(); //按顺序获取所有传入的参数
        assertEquals("name1", list.get(0));
        assertEquals("name2", list.get(1));
    }

    /**
     * 校验mock对象0调用和未被验证的调用
     */
    @Test(expected = NoInteractionsWanted.class)
    public void testInteraction() {

        verifyZeroInteractions(remoteService); //目前还未被调用过,执行不报错

        Node target = new Node(1, "target");
        when(remoteService.getRemoteNode(anyInt())).thenReturn(target);

        localService.getRemoteNode(1);
        localService.getRemoteNode(2);
        verify(remoteService, times(2)).getRemoteNode(anyInt());
        // 参数1和2的两次调用都会被上面的anyInt()校验到,所以没有未被校验的调用了
        verifyNoMoreInteractions(remoteService);

        reset(remoteService);
        localService.getRemoteNode(1);
        localService.getRemoteNode(2);
        verify(remoteService, times(1)).getRemoteNode(1);
        // 参数2的调用不会被上面的校验到,所以执行会抛异常
        verifyNoMoreInteractions(remoteService);
    }
}

WireMock

WireMock 是在阅读 kubernetes-client/java 代码时发现的, 在其中有大量使用,它是基于 HTTP API 的 mock 服务框架,和前面提到的 moco 一样,它可以通过文件配置以独立服务启动, 也可以通过代码控制,同时 Spring Cloud Contract WireMock 模块也使得可以在 Spring Boot 应用中使用 WireMock,具体介绍见 Spring Cloud Contract WireMock 。除此之外, WireMock 还提供了在线 mock 服务 MockLab 。下面是 WireMock 在 K8S API 上的示例:

public class K8SApiTest {
    @Rule
    public WireMockRule wireMockRule = new WireMockRule(8000);

    private GenericKubernetesApi<V1Job, V1JobList> jobClient;

    ApiClient apiClient;


    @Before
    public void setup() {
        apiClient = new ClientBuilder().setBasePath("http://localhost:" + 8000).build();
        jobClient =
                new GenericKubernetesApi<>(V1Job.class, V1JobList.class, "batch", "v1", "jobs", apiClient);
    }

    // test delete
    @Test
    public void delJob() {
        V1Status status = new V1Status().kind("Status").code(200).message("good!");
        stubFor(
                delete(urlEqualTo("/apis/batch/v1/namespaces/default/jobs/foo1"))
                        .willReturn(aResponse().withStatus(200).withBody(new Gson().toJson(status))));

        KubernetesApiResponse<V1Job> deleteJobResp = jobClient.delete("default", "foo1", null);
        assertTrue(deleteJobResp.isSuccess());
        assertEquals(status, deleteJobResp.getStatus());
        assertNull(deleteJobResp.getObject());
        verify(1, deleteRequestedFor(urlPathEqualTo("/apis/batch/v1/namespaces/default/jobs/foo1")));
    }

    @Test
    public void getNs() throws ApiException {
        Configuration.setDefaultApiClient(apiClient);

        V1Namespace ns1 = new V1Namespace().metadata(new V1ObjectMeta().name("name"));

        stubFor(
                get(urlEqualTo("/api/v1/namespaces/name"))
                        .willReturn(
                                aResponse()
                                        .withHeader("Content-Type", "application/json")
                                        .withBody(apiClient.getJSON().serialize(ns1))));

        CoreV1Api api = new CoreV1Api();
        V1Namespace ns2 = api.readNamespace("name", null, null, null);
        assertEquals(ns1, ns2);
    }
}

总结

以上,就是关于 Mock 服务框架及使用的简单介绍, 详细用法还需要参考相应的文档或源码。关于 Mock 服务框架的选择, 在 《微服务接口:怎么用 Mock 解决混乱的调用关系?》 一文中提到,首先要基于团队的技术栈来选择,这决定了完成服务"替身"的速度;其次,Mock 要方便快速修改和维护,并能马上发挥作用。而关于 Mock 服务的设计,首先要简单,其次处理速度比完美的 Mock 服务更重要;最后,Mock 服务要能轻量化启动,并能容易销毁。

参考:

mock
moco
SpringMVC 单元测试 - MockMvc
Mockito & PowerMock
Wiremock