換種思路寫Mock,讓單元測試更簡單

行無際發表於2020-12-10

開篇引入

單元測試中的Mock方法,通常是為了繞開那些依賴外部資源或無關功能的方法呼叫,使得測試重點能夠集中在需要驗證和保障的程式碼邏輯上。在定義Mock方法時,開發者真正關心的只有一件事:"這個呼叫,在測試的時候要換成那個假的Mock方法"。

然而當下主流的Mock框架在實現Mock功能時,需要開發者操心的事情實在太多:Mock框架如何初始化、與所用的單元測試框架是否相容、要被Mock的方法是不是私有的、是不是靜態的、被Mock物件是new出來的還是注入的、怎樣把被測物件送回被測類裡...這些非關鍵的額外工作極大分散了使用Mock工具應有的樂趣。

週末,在翻github上alibaba的開源專案時,無意間看到了下面這個特立獨行的輕量Mock工具。當前知道這個工具的人應該很少,star人數28(包括本人在內),另外我留意了一下該專案在github上第一次提交程式碼時間是2020年5月9日。

專案地址:https://github.com/alibaba/testable-mock
文件:https://alibaba.github.io/testable-mock/

換種思路寫Mock,讓單元測試更簡單。無需初始化,不挑測試框架,甭管要換的方法是被測類的私有方法、靜態方法還是其他任何類的成員方法,也甭管要換的物件是怎麼建立的。寫好Mock方法,加個@TestableMock註解,一切統統搞定。

這是 README 上的描述。掃了一眼專案描述與目錄結構後,就抵制不住誘惑,快速上手玩了一下。於是,就有了這篇划水部落格,讓看到的朋友也心癢一下(●´ω`●)。當然,最重要的是如果確實好用的話,可以在實際專案中用起來,這樣就不再反感需要Mock的單元測試了。

快速上手

完整程式碼見本人github:https://github.com/itwild/less/tree/master/less-alibaba/less-testable

這裡有一個 WeatherApi 的介面,通過呼叫第三方介面查詢天氣情況,如下:

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 查詢具體城市的天氣,如下:

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, "北京市")
            .put(SHANG_HAI, "上海市")
            .put(HE_FEI, "合肥市")
            .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);
    }

執行 main 方法,輸出如下:

上海市: 不要被陰雲遮擋住好心情
合肥市: 不要被陰雲遮擋住好心情
北京市: 陰晴之間,謹防紫外線侵擾

相信大多數人編寫單元測試時,遇到這種依賴第三方資源時,可能就有點反感寫單元測試了。
下面看看有了 testable-mock 工具,如何編寫單元測試?
CityWeatherTest 檔案如下:

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();
        // mock天氣介面呼叫返回的結果
        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();

    /**
     * 測試 public方法呼叫
     */
    @Test
    public void test_public() {
        String shanghai = cityWeather.queryShangHaiWeather();

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

    /**
     * 測試 private方法呼叫
     */
    @Test
    public void test_private() {
        String hefei = (String) PrivateAccessor.invoke(cityWeather, "queryHeFeiWeather");

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

    /**
     * 測試 靜態方法呼叫
     */
    @Test
    public void test_static() {
        String beijing = CityWeather.queryBeiJingWeather();

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

執行單元測試,輸出如下:

合肥市: this is from mock
上海市: this is from mock
北京市: this is from mock

從執行結果不難發現,依賴第三方介面的 query 方法已經被僅僅加了個 TestableMock 註解的方法Mock了。也就是說達到了預期的Mock效果,而且程式碼優雅易讀。

實現原理

那麼,這優雅易讀的背後到底隱藏著什麼祕密呢?

相信對這方面有些瞭解的朋友或多或少也猜到了,沒錯,正是位元組碼增強技術!!!

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);

}

追一下原始碼,可見,該Mock工具藉助了ASM Core API來修改位元組碼。上面也提到了,該專案在github上開源出來的時間並不長,核心程式碼並不多,認真看應該能看懂,主要是有些朋友可能從來沒有了解過位元組碼增強技術。這裡推薦美團技術團隊的一篇位元組碼增強技術相關的文章,https://tech.meituan.com/2019/09/05/java-bytecode-enhancement.html,相信有了這樣的基礎,回過頭來再看看 TestableMock 的原始碼會輕鬆許多。

本篇部落格並不會過多探究位元組碼增強技術的細節,頂多算是拋磚引玉,目的是讓讀者知道有這麼一個優雅的Mock工具,另外位元組碼增強技術相當於是一把開啟執行時JVM的鑰匙,利用它可以動態地對執行中的程式做修改,也可以跟蹤JVM執行中程式的狀態,這樣就能在開發中減少冗餘程式碼,提高開發效率。順便提一句,我們平時使用的AOP(Cglib就是基於ASM的)也與位元組碼增強密切相關,它們實質上還是利用各種手段生成符合規範的位元組碼檔案。

雖然這篇不講修改位元組碼的操作細節,但我還是想讓讀者直觀地看到增強後的位元組碼(class檔案)是什麼樣子的,說白了就是到底把我寫的程式碼在執行時修改成了啥???於是,我把執行時增強過的位元組碼重新寫入了檔案,然後使用反編譯工具(拖到IDEA中即可)觀察被修改後的原始碼。

執行時(即增強後的)CityWeatherTest.class反編譯後如下:

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("上海市: 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("合肥市: this is from mock", hefei);
    }

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

執行時(即增強後的)CityWeather.class反編譯後如下:

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", "北京市").put("101020100", "上海市").put("101220101", "合肥市").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);
    }
}

原來,執行時把呼叫到 query 方法的實現都換成了自己Mock的程式碼。

相關文章