Write Mock in a different way to make unit testing easier

Introduction

The Mock method in unit testing is usually used to bypass method calls that rely on external resources or unrelated functions, so that the test focus can be focused on the code logic that needs to be verified and guaranteed. When defining a Mock method, the developer really cares about only one thing: "this call should be replaced by the fake Mock method during testing".

However, when the mainstream Mock framework implements the Mock function, developers need to worry about too many things: how to initialize the Mock framework, whether it is compatible with the unit testing framework used, and whether the method to be mocked is private or static. , Whether the mocked object is new or injected, how to return the tested object to the tested class... These non-critical extra work greatly distracts the fun of using the mocking tool.

Over the weekend, while looking through the open source project of alibaba on github, I accidentally saw the following maverick lightweight Mock tool. There should be very few people who know this tool at present. The number of star s is 28 (including myself). In addition, I noticed that the first code submission time of this project on github was May 9, 2020.

project address: https://github.com/alibaba/testable-mock
Documentation: https://alibaba.github.io/testable-mock/

Write Mock in a different way to make unit testing easier. There is no need to initialize, no test framework is required, no matter whether the method to be replaced is a private method of the tested class, a static method or a member method of any other class, and no matter how the object to be replaced is created. Write the Mock method, add an @TestableMock annotation, and everything is done.

This is the description on the README. After glancing at the project description and directory structure, I couldn't resist the temptation and started playing quickly. So, there is this paddling blog, which makes friends who see it also itchy (●´ω`●). Of course, the most important thing is that if it is really easy to use, it can be used in actual projects, so that you will no longer be disgusted with unit testing that requires Mock.

Get started quickly

See my github for the complete code: https://github.com/itwild/less/tree/master/less-alibaba/less-testable

Here is an interface of WeatherApi, which can query weather conditions by calling a third-party interface, as follows:

import com.github.itwild.less.base.http.feign.WeatherExample;
import feign.Param;
import feign.RequestLine;

public interface WeatherApi {

    @RequestLine("GET /api/weather/city/{city_code}")
    WeatherExample.Response query(@Param("city_code") String cityCode);
}

CityWeather queries the weather of a specific city, as follows:

import cn.hutool.core.map.MapUtil;
import com.github.itwild.less.base.http.feign.WeatherExample;
import feign.Feign;
import feign.jackson.JacksonDecoder;
import feign.jackson.JacksonEncoder;

import java.util.HashMap;
import java.util.Map;

public class CityWeather {

    private static final String API_URL = "http://t.weather.itboy.net";

    private static final String BEI_JING = "101010100";
    private static final String SHANG_HAI = "101020100";
    private static final String HE_FEI = "101220101";

    public static final Map<String, String> CITY_CODE = MapUtil.builder(new HashMap<String, String>())
            .put(BEI_JING, "Beijing")
            .put(SHANG_HAI, "Shanghai")
            .put(HE_FEI, "Hefei")
            .build();

    private static WeatherApi weatherApi = Feign.builder()
            .encoder(new JacksonEncoder())
            .decoder(new JacksonDecoder())
            .target(WeatherApi.class, API_URL);

    public String queryShangHaiWeather() {
        WeatherExample.Response response = weatherApi.query(SHANG_HAI);
        return response.getCityInfo().getCity() + ": " + response.getData().getYesterday().getNotice();
    }

    private String queryHeFeiWeather() {
        WeatherExample.Response response = weatherApi.query(HE_FEI);
        return response.getCityInfo().getCity() + ": " + response.getData().getYesterday().getNotice();
    }

    public static String queryBeiJingWeather() {
        WeatherExample.Response response = weatherApi.query(BEI_JING);
        return response.getCityInfo().getCity() + ": " + response.getData().getYesterday().getNotice();
    }

    public static void main(String[] args) {
        CityWeather cityWeather = new CityWeather();

        String shanghai = cityWeather.queryShangHaiWeather();
        String hefei = cityWeather.queryHeFeiWeather();
        String beijing = CityWeather.queryBeiJingWeather();

        System.out.println(shanghai);
        System.out.println(hefei);
        System.out.println(beijing);
    }

Running the main method, the output is as follows:

Shanghai: Don't be clouded by clouds
 Hefei: Don't be clouded by clouds
 Beijing: Between cloudy and sunny, beware of UV rays

I believe that when most people write unit tests, when they encounter this kind of dependence on third-party resources, they may be a little disgusted with writing unit tests.
Let's see how to write unit tests with the testable-mock tool?
The CityWeatherTest file is as follows:

import com.alibaba.testable.core.accessor.PrivateAccessor;
import com.alibaba.testable.core.annotation.TestableMock;
import com.alibaba.testable.processor.annotation.EnablePrivateAccess;
import com.github.itwild.less.base.http.feign.WeatherExample;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.*;

@EnablePrivateAccess
public class CityWeatherTest {

    @TestableMock(targetMethod = "query")
    public WeatherExample.Response query(WeatherApi self, String cityCode) {
        WeatherExample.Response response = new WeatherExample.Response();
        // The result returned by the mock weather interface call
        response.setCityInfo(new WeatherExample.CityInfo().setCity(
                CityWeather.CITY_CODE.getOrDefault(cityCode, cityCode)));
        response.setData(new WeatherExample.Data().setYesterday(
                new WeatherExample.Forecast().setNotice("this is from mock")));
        return response;
    }

    CityWeather cityWeather = new CityWeather();

    /**
     * Test public method calls
     */
    @Test
    public void test_public() {
        String shanghai = cityWeather.queryShangHaiWeather();

        System.out.println(shanghai);
        assertEquals("Shanghai: this is from mock", shanghai);
    }

    /**
     * Test private method calls
     */
    @Test
    public void test_private() {
        String hefei = (String) PrivateAccessor.invoke(cityWeather, "queryHeFeiWeather");

        System.out.println(hefei);
        assertEquals("Hefei: this is from mock", hefei);
    }

    /**
     * test static method call
     */
    @Test
    public void test_static() {
        String beijing = CityWeather.queryBeiJingWeather();

        System.out.println(beijing);
        assertEquals("Beijing: this is from mock", beijing);
    }
}

Running the unit tests, the output is as follows:

Hefei: this is from mock
 Shanghai: this is from mock
 Beijing: this is from mock

It is not difficult to find from the running results that the query method that relies on the third-party interface has been Mocked by a method that is only annotated with TestableMock. That is to say, the expected Mock effect is achieved, and the code is elegant and easy to read.

Implementation principle

So, what is the secret behind this elegant and easy-to-read?

I believe that friends who have some understanding of this aspect have guessed it more or less. Yes, it is the bytecode enhancement technology! ! !

package com.alibaba.testable.agent;

import com.alibaba.testable.agent.transformer.TestableClassTransformer;
import java.lang.instrument.Instrumentation;

/**
 * Agent entry, dynamically modify the byte code of classes under testing
 * @author flin
 */
public class PreMain {
    
    public static void premain(String agentArgs, Instrumentation inst) {
        parseArgs(agentArgs);
        inst.addTransformer(new TestableClassTransformer());
    }
}
package com.alibaba.testable.agent.handler;

import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.Opcodes;
import org.objectweb.asm.tree.ClassNode;

import java.io.IOException;

/**
 * @author flin
 */
abstract public class BaseClassHandler implements Opcodes {

    public byte[] getBytes(byte[] classFileBuffer) throws IOException {
        ClassReader cr = new ClassReader(classFileBuffer);
        ClassNode cn = new ClassNode();
        cr.accept(cn, 0);
        transform(cn);
        ClassWriter cw = new ClassWriter( 0);
        cn.accept(cw);
        return cw.toByteArray();
    }

    /**
     * Transform class byte code
     * @param cn original class node
     */
    abstract protected void transform(ClassNode cn);

}

After chasing the source code, it can be seen that the Mock tool uses the ASM Core API to modify the bytecode. As mentioned above, the project has not been open sourced on github for a long time, and there are not many core codes. If you look carefully, you should be able to understand it, mainly because some friends may never understand bytecode enhancement technology. Here I recommend an article about bytecode enhancement technology from the Meituan technical team. https://tech.meituan.com/2019/09/05/java-bytecode-enhancement.html , I believe that with such a foundation, it will be much easier to go back and look at the source code of TestableMock.

This blog will not explore the details of the bytecode enhancement technology too much, at most it will be an introduction. The purpose is to let readers know that there is such an elegant Mock tool. In addition, the bytecode enhancement technology is equivalent to opening the runtime JVM. The key can be used to dynamically modify the running program and track the status of the JVM running program, so that redundant code can be reduced during development and development efficiency can be improved. By the way, the AOP we usually use (Cglib is based on ASM) is also closely related to bytecode enhancement, and they essentially use various means to generate standard bytecode files.

Although this article does not cover the details of the operation of modifying the bytecode, I still want the reader to intuitively see what the enhanced bytecode (class file) looks like. Modified to what? ? ? So, I re-written the runtime-enhanced bytecode to the file, and then used the decompilation tool (just drag it into IDEA) to observe the modified source code.

The runtime (ie enhanced) CityWeatherTest.class is decompiled as follows:

import com.alibaba.testable.core.accessor.PrivateAccessor;
import com.alibaba.testable.core.annotation.TestableMock;
import com.alibaba.testable.core.util.InvokeRecordUtil;
import com.alibaba.testable.processor.annotation.EnablePrivateAccess;
import com.github.itwild.less.base.http.feign.WeatherExample.CityInfo;
import com.github.itwild.less.base.http.feign.WeatherExample.Data;
import com.github.itwild.less.base.http.feign.WeatherExample.Forecast;
import com.github.itwild.less.base.http.feign.WeatherExample.Response;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;

@EnablePrivateAccess
public class CityWeatherTest {
    CityWeather cityWeather = new CityWeather();
    public static CityWeatherTest _testableInternalRef;
    public static CityWeatherTest _testableInternalRef;

    public CityWeatherTest() {
    }

    @TestableMock(
        targetMethod = "query"
    )
    public Response query(WeatherApi var1, String cityCode) {
        InvokeRecordUtil.recordMockInvoke(new Object[]{var1, cityCode}, false);
        InvokeRecordUtil.recordMockInvoke(new Object[]{var1, cityCode}, false);
        Response response = new Response();
        response.setCityInfo((new CityInfo()).setCity((String)CityWeather.CITY_CODE.getOrDefault(cityCode, cityCode)));
        response.setData((new Data()).setYesterday((new Forecast()).setNotice("this is from mock")));
        return response;
    }

    @Test
    public void test_public() {
        _testableInternalRef = this;
        _testableInternalRef = this;
        String shanghai = this.cityWeather.queryShangHaiWeather();
        System.out.println(shanghai);
        Assertions.assertEquals("Shanghai: this is from mock", shanghai);
    }

    @Test
    public void test_private() {
        _testableInternalRef = this;
        _testableInternalRef = this;
        String hefei = (String)PrivateAccessor.invoke(this.cityWeather, "queryHeFeiWeather", new Object[0]);
        System.out.println(hefei);
        Assertions.assertEquals("Hefei: this is from mock", hefei);
    }

    @Test
    public void test_static() {
        _testableInternalRef = this;
        _testableInternalRef = this;
        String beijing = CityWeather.queryBeiJingWeather();
        System.out.println(beijing);
        Assertions.assertEquals("Beijing: this is from mock", beijing);
    }
}

The runtime (ie enhanced) CityWeather.class is decompiled as follows:

import cn.hutool.core.map.MapUtil;
import com.github.itwild.less.base.http.feign.WeatherExample.Response;
import feign.Feign;
import feign.jackson.JacksonDecoder;
import feign.jackson.JacksonEncoder;
import java.util.HashMap;
import java.util.Map;

public class CityWeather {
    private static final String API_URL = "http://t.weather.itboy.net";
    private static final String BEI_JING = "101010100";
    private static final String SHANG_HAI = "101020100";
    private static final String HE_FEI = "101220101";
    public static final Map<String, String> CITY_CODE = MapUtil.builder(new HashMap()).put("101010100", "Beijing").put("101020100", "Shanghai").put("101220101", "Hefei").build();
    private static WeatherApi weatherApi = (WeatherApi)Feign.builder().encoder(new JacksonEncoder()).decoder(new JacksonDecoder()).target(WeatherApi.class, "http://t.weather.itboy.net");

    public CityWeather() {
    }

    public String queryShangHaiWeather() {
        Response response = CityWeatherTest._testableInternalRef.query(weatherApi, "101020100");
        return response.getCityInfo().getCity() + ": " + response.getData().getYesterday().getNotice();
    }

    private String queryHeFeiWeather() {
        Response response = CityWeatherTest._testableInternalRef.query(weatherApi, "101220101");
        return response.getCityInfo().getCity() + ": " + response.getData().getYesterday().getNotice();
    }

    public static String queryBeiJingWeather() {
        Response response = CityWeatherTest._testableInternalRef.query(weatherApi, "101010100");
        return response.getCityInfo().getCity() + ": " + response.getData().getYesterday().getNotice();
    }

    public static void main(String[] args) {
        CityWeather cityWeather = new CityWeather();
        String shanghai = cityWeather.queryShangHaiWeather();
        String hefei = cityWeather.queryHeFeiWeather();
        String beijing = queryBeiJingWeather();
        System.out.println(shanghai);
        System.out.println(hefei);
        System.out.println(beijing);
    }
}

It turned out that the runtime replaced the implementation of the call to the query method with its own Mock code.

Tags: Java jvm

Posted by MichaelHe on Mon, 02 May 2022 18:21:46 +0300