前言
同學們平時用robolectric可能沒太留意robolectric的Custum Shadow功能。簡單地說,就是用Shadow類代替原始類,并不讓調用者感知。Shadow機制不僅僅讓用戶修改自己寫的類,robolectric大量用到shadow機制,這是最核心的技術。
本文并不打算深入講解robolectric shadow機制,robolectric用了比較復雜的原理。筆者希望用更簡單的方式,實現基本的shadow機制。
Shadow是什么?
官方原文:
Robolectric defines many shadow classes, which modify or extend the behavior of classes in the Android OS......Every time a method is invoked on an Android class, Robolectric ensures that the shadow class’ corresponding method is invoked first.
大概意思是,robolectric有很多shadow類來修改或拓展Android OS原本的類......每一次執行android類時,robolectric確保shadow類先執行。
簡單的例子:
Foo:
public class Foo {
public void display(){
System.out.println("foo");
}
}
ShadowFoo:
@Implements(Foo.class)
public class ShadowFoo {
@Implementation
public void display(){
System.out.println("shadow foo");
}
}
運行單元測試時,執行單元測試:
@RunWith(RobolectricTestRunner.class)
@Config(shadows = {ShadowFoo.class}, manifest = Config.NONE)
public class FooTest {
Foo foo;
@Before
public void setUp() throws Exception {
foo = new Foo();
}
@Test
public void display() throws Exception {
foo.display();
}
}
運行結果:
shadow foo
Robolectric單元測試,配置Shadow后,ShadowFoo會覆蓋Foo行為。你可以寫很多ShadowFoo,單元測試時配置不同的Shadow做不同的行為。
Shadow意義何在?
覆蓋Android sdk行為
在Android Studio可以看到Android大部分源;我們運行APP后,在Android Studio打斷點debug代碼,可以看到android代碼執行。實際上,APP執行的是手機Android系統的代碼,并不是我們AS依賴的sdk。那么,單元測試依賴的android sdk,真的跟我們在AS看到的代碼一樣嗎?
我們做個簡單的測試:
public class TextUtilsTest {
@Test
public void testIsEmpty() {
TextUtils.isEmpty("");
}
}
結果是這樣:
java.lang.RuntimeException: Method isEmpty in android.text.TextUtils not mocked. See http://g.co/androidstudio/not-mocked for details.
at android.text.TextUtils.isEmpty(TextUtils.java)
at com.example.robolectric.TextUtilsTest.testIsEmpty(TextUtilsTest.java:14)
...
我們在AS查看TextUtils.isEmpty
源碼:
public static boolean isEmpty(@Nullable CharSequence str) {
if (str == null || str.length() == 0)
return true;
else
return false;
}
這里都是jdk提供的基礎代碼,為什么就報錯了呢?
我們在AS查看依賴的android sdk路徑:
1.右鍵->Show in Explore
sdk路徑:{sdk目錄}/platforms/android-25 (sdk不同版本在不同目錄)
2.然后用Java Decompiler查看這個jar代碼:
android.jar的代碼,只是一個stub,里面根本沒有android源碼,全部方法都throw new RuntimeException("Stub!")
。
因此,robolectric在運行時,需要替換這些代碼。這就是Shadow機制存在的必要!
(提醒,robolectric替換android代碼,并不是所有都用shadow機制,大部分只是讓ClassLoader加載robolectric提供的android-all.jar而已。View類基本用Shadow機制。)
控制依賴外部環境的方法行為
大多數情況下,我們用mock就能做到控制方法行為。但一些靜態方法,例如NetworkUtils.isConnected()
,mockito就做不到了。當然可以用powermockito,筆者認為mockito和powermockito混合使用比較蛋疼,畢竟方法名很多雷同,引用時比較麻煩。
場景:1.網絡正常,返回mock數據;2.網絡斷開,拋出異常。
public class UserApi {
Observable<String> getMyInfo() {
if (NetworkUtils.isConnected()) {
return Observable.just("...");
} else {
return Observable.error(new RuntimeException("Network disconnected."));
}
}
}
Shadow:
@Implements(NetworkUtils.class)
public class ShadowNetworkUtils {
public static boolean sIsConnected;
@Implementation
public static boolean isConnected() {
return sIsConnected;
}
public static void setIsConnected(boolean isConnected) {
ShadowNetworkUtils.sIsConnected = isConnected;
}
}
單元測試:
@RunWith(RobolectricTestRunner.class)
@Config(shadows = ShadowNetworkUtils.class)
public class UserApiTest {
UserApi userApi;
@Before
public void setUp() throws Exception {
userApi = new UserApi();
}
@Test
public void testGetMyInfo() {
ShadowNetworkUtils.setIsConnected(true);
String data = userApi.getMyInfo()
.toBlocking()
.first();
Assert.assertEquals(data, "...");
}
// 期望拋出錯誤
@Test(expected = RuntimeException.class)
public void testNetworkDisconnected() {
ShadowNetworkUtils.setIsConnected(false);
userApi.getMyInfo()
.subscribe();
}
}
由于NetworkUtils.setIsConnected()
根據真實網絡情況返回true or false,而且使用android api,所以運行單元測試必然報錯。因此,我們希望能模擬網絡正常和網絡斷開的情況,用ShadowNetworkUtils
非常適合。
自己實現Shadow
思路
原始類方法調用Shadow類方法
這種方法需要在jvm動態改變原始類字節碼,本方法存在Shadow類對象或者調用實際Shadow類靜態方法,而不僅僅把Shadow類字節碼拷貝給原始類。這么說有點抽象,繼續看下文就懂了。
框架選型
動態修改jvm字節碼,有好幾款框架:asm、cglib、aspectJ、javassist等。
asm比較底層,非常難用;mockito就是用到cglib,筆者感覺cglib做動態代理比較在行,未試過修改字節碼,有待考究;aspectJ筆者最喜歡,語法簡潔,但最大問題是,筆者還不會在Android Studio配置成讓單元測試可用(如果你懂的請留言);javassist api跟java反射api很像,也挺簡單的,很快上手。
最后筆者選擇了javassist。
實戰
gradle
在build.gradle依賴javassist:
dependencies {
testCompile group: 'org.javassist', name: 'javassist', version: '3.21.0-GA'
}
準備工具類
Robolectric的Implements注解(你也可以自己寫)
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE})
public @interface Implements {
/**
* @return The class to shadow.
*/
Class<?> value() default void.class;
/**
* @return class name.
*/
String className() default "";
}
注解工具類:
import java.lang.reflect.Field;
import java.lang.reflect.InvocationHandler;
import javassist.ClassPool;
import javassist.CtClass;
import javassist.NotFoundException;
import javassist.bytecode.annotation.Annotation;
import javassist.bytecode.annotation.AnnotationImpl;
import javassist.bytecode.annotation.ClassMemberValue;
import javassist.bytecode.annotation.MemberValue;
import javassist.bytecode.annotation.StringMemberValue;
public class AnnotationHelper {
/**
* 獲取Shadow類{@linkplain Implements}注解的類名
*
* @param clazz
* @return
* @throws ClassNotFoundException
* @throws NotFoundException
*/
public static String getAnnotationClassName(Class clazz) throws ClassNotFoundException, NotFoundException {
ClassPool pool = ClassPool.getDefault();
CtClass cc = pool.get(clazz.getName());
Implements implememts = (Implements) cc.getAnnotation(Implements.class);
String className = implememts.className();
if (className == null || className.equals("")) {
// 獲取Implements注解value值
className = getValue(implememts, "value");
}
return className;
}
/**
* 獲取注解某參數值
*/
private static String getValue(Object obj, String param) {
AnnotationImpl annotationImpl = (AnnotationImpl) getAnnotationImpl(obj);
Annotation annotation = annotationImpl.getAnnotation();
MemberValue memberValue = annotation.getMemberValue(param);
if (memberValue instanceof ClassMemberValue) {
return ((ClassMemberValue) memberValue).getValue();
} else if (memberValue instanceof StringMemberValue) {
return ((StringMemberValue) memberValue).getValue();
}
return "";
}
private static InvocationHandler getAnnotationImpl(Object obj) {
Class clz = obj.getClass()
.getSuperclass();
try {
Field field = clz.getDeclaredField("h");
field.setAccessible(true);
InvocationHandler annotation = (InvocationHandler) field.get(obj);
return annotation;
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
}
動態改變字節碼
我們希望NetworkUtils
修改后,有如下效果:
public class NetworkUtils {
public static boolean isConnected() {
return ShadowNetworkUtils.isConnected();
}
}
因此,我們要動態生成跟上面一模一樣的源碼的字節碼,通過javassist替換原始類的方法。
public class JavassistHelper {
public static void callShadowStaticMethod(Class<?> shadowClass) {
try {
// 原始類類名
String primaryClassName = AnnotationHelper.getAnnotationClassName(shadowClass);
ClassPool cp = ClassPool.getDefault();
// 原始類CtClass
CtClass cc = cp.get(primaryClassName);
// Shadow類CtClass
CtClass shadowCt = cp.get(shadowClass.getName());
CtMethod[] methods = cc.getDeclaredMethods();
for (CtMethod method : methods) {
// 僅處理靜態方法
if (Modifier.isStatic(method.getModifiers())) {
// 從Shadow類CtClass獲取方法名、參數與原始類一致的CtMethod
CtMethod shadowMethod = shadowCt.getDeclaredMethod(method.getName(), method.getParameterTypes());
if (shadowMethod != null) {
String src = getStaticMethodSrc(shadowClass, shadowMethod);
method.setBody(src);
// 輸出該方法源碼
System.out.println(src);
}
}
}
// 最后讓jvm加載一下修改后的類
Class c = cc.toClass();
} catch (Exception e) {
e.printStackTrace();
}
}
private static String getStaticMethodSrc(Class<?> shadowClass, CtMethod method) {
StringBuilder sb = new StringBuilder();
try {
CtClass returnType = method.getReturnType();
if (!isVoid(returnType)) {
sb.append("return ");
}
sb.append(shadowClass.getName() + "." + method.getName() + "($$);");// $$表示該方法所有參數
} catch (NotFoundException e) {
e.printStackTrace();
}
return sb.toString();
}
private static boolean isVoid(CtClass returnType) {
if (returnType.equals(CtClass.voidType)) {
return true;
}
return false;
}
}
單元測試
public class NetworkUtilsTest {
@Before
public void setUp() throws Exception {
// 修改NetworkUtils靜態方法字節碼,此方法必須在jvm加載NetworkUtils之前調用
JavassistHelper.callShadowStaticMethod(ShadowNetworkUtils.class);
}
@Test
public void testIsConnected() {
ShadowNetworkUtils.setIsConnected(false);
Assert.assertFalse(NetworkUtils.isConnected());
ShadowNetworkUtils.setIsConnected(true);
Assert.assertTrue(NetworkUtils.isConnected());
}
}
單元測試通過,并輸出:
return com.example.robolectric.ShadowNetworkUtils.isConnected($$);
輸出字符串為修改的靜態方法源碼。如果是非靜態方法,建議用mockito處理。
寫在最后
筆者寫本文的初衷,一來是想擺脫powermockito和robolectric,二來借此研究robolectric shadow實現原理。不料,robolectric不是浪得虛名,shadow機制非常復雜,一時半刻筆者只了解冰山一角,希望有朝一日能弄明白跟大家分享。
希望本文給大家跟多啟發,用javassist在單元測試實現更多功能。
關于作者
我是鍵盤男。
在廣州生活,在互聯網體育公司上班,猥瑣文藝碼農。每天謀劃砍死產品經理。喜歡科學、歷史,玩玩投資,偶爾旅行。