今天講講Android上做單元測試的最後一個難點,那就是在JVM上無法呼叫安卓相關的類,不然的話,會報類似於下的錯誤: java.lang.RuntimeException: Method isEmpty in android.text.TextUtils not mocked.
作為一隻本科非計算機專業的程式猿,手動寫單元測試是我從來沒接觸過的東西,甚至在幾個月前,我都不知道單元測試是什麼東西。倒不是說沒聽過這個詞,也不是不知道它的大概是什麼東西——“用來測試一個方法,或者是一小塊程式碼的測試程式碼”。然而真正是怎麼做的?我並沒有一個概念,或者說並沒有一個感覺。
記得第一份工作在創新工場的時候,聽當時的boss說,公司有個神級的程式設計師,他會寫大量的單元測試,甚至50%以上的程式碼都是單元測試。當時崇拜之極,卻仍然覺得寫單元測試是很麻煩的一件事情。
扯遠了,話說回來,當你接觸多了國外的技術部落格,視訊之後,你會發現,單元測試甚至TDD,在國外是非常流行的事情。很多人甚至說離開了單元測試,他們便沒有辦法寫程式碼。這些都讓我對單元測試的好感度逐漸的上升。然而,真正讓我下定決心,一定要研究一下這個東西的,是前段時間看大名鼎鼎的《重構:改善現有程式碼的藝術》裡面的一段話:
I’ve found that writing good tests greatly speeds my programming, even if I’m not refactoring. This was a surprise for me, and it is counterintuitive for many programmers…
–Martin Fowler 《Refactoring: Improving the Design of Existing Code》
是的,你沒看錯,他說單元測試可以節約時間,提高開發速度!!!身為一個無可救藥的懶癌患者,看了這句話簡直就像看到了一道神光似的!既然都可以節省時間,那肯定是要看看的啊!
有趣的是,Martin Fowler在《重構》裡面說他最初是因為 Dave Thomas說的一句話,讓他走上了單元測試的不歸路。而我這幾天剛好又在看Dave Thomas寫的《Programming Ruby 1.9 & 2.0》,也算是個巧合啊!
Martin Fowler在《重構》裡面還解釋了為什麼單元測試可以節省時間,大意是我們寫程式的時候,其實大部分時間不是花在寫程式碼上面,而是花在debug上面,是花在找出問題到底出在哪上面,而單元測試可以最快的發現你的新程式碼哪裡不work,這樣你就可以很快的定位到問題所在,然後給以及時的解決,這也可以在很大程度上防止regression(相信QE和QA們一定很喜歡哈哈。。。),這也是個大部分程式設計師和測試都很痛恨的問題。
之後不久,就開始花了點時間瞭解了一下Android裡面怎麼做unit testing,結果卻發現那是個非常難辦的事情。。。
為什麼android unit testing不好做
我們知道安卓的app需要執行在delvik上面,我們開發Android app是在JVM上面,在開發之前我們需要下載各個API-level的SDK的,下載的每個SDK都有一個android.jar的包,這些可以在你的androidsdkhome/platforms/下面看到。當我們開發一個專案的時候,我們需要指定一個API-level,其實就是將對應的android.jar 加到這個專案的build path裡面去。這樣我們的專案就可以編譯打包了。然而現在的問題是,我們的程式碼必須執行在emulator或者是device上面,說白了,就是我們的IDE和SDK只提供了開發和編譯一個專案的環境,並沒有提供執行這個專案的環境,原因是因為android.jar裡面的class實現是不完整的,它們只是一些stub,如果你開啟android.jar下面的程式碼去看看,你會發現所有的方法都只有一行實現:
throw RuntimeException("stub!!");
而執行unit test,說白了還是個執行的過程,所以如果你的unit test程式碼裡面有android相關的程式碼的話,那執行的時候將會丟擲RuntimeException(“stub!!”)。為了解決這個問題,現在業界提出了很多不同的程式架構,比如MVP、MVVM等等,這些架構的優勢之一,就是將其中一層抽出來,變成pure Java實現,這樣做unit testing就不會遇到上面這個問題了,因為其中沒有android相關的程式碼。
好奇的童鞋可能會問了,既然android.jar的實現是不完整的,那為什麼我們可以編譯這個專案呢?那是因為編譯程式碼的過程並沒有真正的執行這些程式碼,它只會檢查你的介面有沒有定義,以及其他的一些語法是不是正確。舉個簡單的例子:
1 2 3 4 5 6 7 8 |
public class Test { public static void main(String[] argv) {
testMethod(); } public static void testMethod() { throw RuntimeException("stub!!"); } } |
上面的程式碼你同樣可以編譯通過,但你執行的時候,就會丟擲異常RuntimeException("stub!!")
。當我們的專案執行在emulator或者是device上面的時候,android.jar被替換成了emulator或者是device上面的系統的實現,那上面的實現是真正實現了那些方法的,所以執行起來沒有問題。
話說回來,MVP、MVVM這些架構模式雖然解決了部分問題,可以測試專案中不含android相關的類的程式碼,然而一個專案中還是有很大部分是android相關的程式碼的,所以上面那種解決方案,其實是放棄了其中一大塊程式碼的unit test。
當然,話說回來,android還是提供了他自己的testing framework,叫instrumentation,但是這套框架還是繞不開剛剛提到的問題,他們必須跑在emulator或者是device上面。這是個很慢的過程,因為要打包、dexing、上傳到機器、執行起來介面。。。這個相信大家都有體會,尤其是專案大了以後,執行一次甚至需要一兩分鐘,專案小的話至少也要十幾秒或幾十秒。以這個速度是沒有辦法做unit test的。
那麼怎麼樣即可以給android相關的程式碼做測試,又可以很快的執行這些測試呢?
Robolectric to the rescue
解決的辦法就是使用一個開源的framework,叫robolectric,他們的做法是通過實現一套JVM能執行的Android程式碼,然後在unit test執行的時候去擷取android相關的程式碼呼叫,然後轉到他們的他們實現的程式碼去執行這個呼叫的過程。舉個例子說明一下,比如android裡面有個類叫TextView
,他們實現了一個類叫ShadowTextView
。這個類基本上實現了TextView
的所有公共介面,假設你在unit test裡面寫到
String text = textView.getText().toString();
。在這個unit test執行的時候,Robolectric會自動判斷你呼叫了Android相關的程式碼textView.getText()
,然後這個呼叫過程在底層擷取了,轉到ShadowTextView
的getText
實現。而ShadowTextView
是真正實現了getText
這個方法的,所以這個過程便可以正常執行。
除了實現Android裡面的類的現有介面,Robolectric還做了另外一件事情,極大地方便了unit testing的工作。那就是他們給每個Shadow類額外增加了很多介面,可以讀取對應的Android類的一些狀態。比如我們知道ImageView
有一個方法叫
setImageResource(resourceId)
,然而並沒有一個對應的getter方法叫getImageResourceId()
,這樣你是沒有辦法測試這個ImageView
是不是顯示了你想要的image。而在Robolectric實現的對應的ShadowImageView
裡面,則提供了getImageResourceId()
這個介面。你可以用來測試它是不是正確的顯示了你想要的Image。
Talk is cheap. Show me the code!
下面簡單的介紹一下使用Robolectric來做unit testing。注意:下面的配置方法指的是AndroidStudio上面的,Eclipse使用者自行google一下配製方法。
要使用Robolectric,需要做幾步配置工作。
- 首先需要將它和JUnit4加到你專案的dependencies裡面,
1 2 |
testCompile 'junit:junit:4.12' testCompile ’org.robolectric:robolectric:3.0-rc3’ |
其中的Robolectric的最新版本號可能會變,具體可以上jcenter檢視一下當前的最新版本號。
2. 如果你用的是AndroidStudio2.0一下的版本,需要將Build Variant
裡面的Test Artifact
選擇為Unit Test,如果你找不到Build Variant
,可以在選單欄選擇View -> Tool Windows -> Build Variant
. 正常情況下它會出現在左下角。AndroidStudio2.0以上的版本已經不需要了。
3. 如果是Mac的話,還需要配置一個東西,選單欄選擇 Run -> Edit Configuration -> Defaults -> JUnit
,在Configuration tab將working directory改成$MODULE_DIR$
。這個配置是Robolectric官方文件提到的,但我用最新的AndroidStudio1.3實驗的時候,忘了配置這個,貌似也可以正確執行,anyway,配置一下也無所謂。具體見Robolectric的官方文件,最下面那部分。
到這裡,就可以開始code了。
測試程式碼是放在app/src/test
下面的,test class的位置最好跟target class的位置對應,比如MainActivity
放在
app/src/main/java/com/domain/appname/MainActivity.java
那麼對應的test class MainActivityTest最好放在
app/src/test/java/com/domain/appname/MainActivityTest.java
這裡舉個簡單又稍微有點用的例子,假設app裡面有兩個Activity:MainActivity
和SecondActivity
,MainActivity
裡面有一個TextView
,點選一下這個TextView
將跳轉到SecondActivity
,MainActivity
裡面的程式碼大概如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
public class MainActivity extends AppCompatActivity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); TextView textView = (TextView)findViewById(R.id.textView1); textView.setOnClickListener(new OnClickListener() { @Override public void onClick(View v) { startActivity(new Intent(MainActivity.this, SecondActivity.class)); } }); } } |
對應的測試類,MainActivityTest
的程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
@RunWith(RobolectricGradleTestRunner.class) @Config(constants = BuildConfig.class, sdk = 21) public class MainActivityTest { @ Test public void testMainActivity() { MainActivity mainActivity = Robolectric.setupActivity(MainActivity.class); mainActivity.findViewById(R.id.textView1).performClick(); Intent expectedIntent = new Intent(mainActivity, SecondActivity.class); ShadowActivity shadowActivity = Shadows.shadowOf(mainActivity); Intent actualIntent = shadowActivity.getNextStartedActivity(); Assert.assertEquals(expectedIntent, actualIntent); } } |
上面的程式碼測試的就是當使用者點選textView
的時候,程式會正確的跳轉到SecondActivity
。其中@RunWith(RobolectricGradleTestRunner.class)
表示用Robolectric的TestRunner來跑這些test,這就是為什麼Robolectric可以檢測到你呼叫了Android相關的類,然後擷取這些呼叫,轉到他們的Shadow類的原因。此外,@Config
用來配置一些東西。
程式碼中的
MainActivity mainActivity = Robolectric.setupActivity(MainActivity.class);
用來建立MainActivity
的instance,或者說,用來啟動這個Activity,當Robolectric.setupActivity
返回的時候,這個Activity已經完成了onCreate、onStart、onResume這幾個生命週期的回撥了。
mainActivity.findViewById(R.id.textView1).performClick();
用來觸發點選事件。ShadowActivity shadowActivity = Shadows.shadowOf(mainActivity);
用來獲取mainActivity對應的ShadowActivity的instance。
shadowActivity.getNextStartedActivity();
用來獲取mainActivity呼叫的startActivity的intent。這也是正常的Activity類裡面不具有的一個介面。
最後,呼叫Assert.assertEquals
來assert啟動的intent是我們期望的intent。
執行這個unit test,啟動命令列,cd到專案的根目錄,執行./gradlew test
,幾秒鐘後,你將看到測試執行的結果
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
... :app:processDebugJavaRes UP-TO-DATE :app:compileDebugJava UP-TO-DATE :app:preCompileDebugUnitTestJava :app:preDebugUnitTestBuild UP-TO-DATE :app:prepareDebugUnitTestDependencies :app:processDebugUnitTestJavaRes UP-TO-DATE :app:compileDebugUnitTestJava :app:compileDebugUnitTestSources :app:mockableAndroidJar UP-TO-DATE :app:assembleDebugUnitTest :app:testDebug BUILD SUCCESSFUL Total time: 12.884 secs |
在我的機器上(MacBook Air 2013款,8G記憶體,算比較低的配置),執行這個test只需要不到12秒鐘,如果直接在AndroidStudio裡面執行的話,這個速度會更快,一般可以再10秒之內完成,或許沒有達到普通JUnit的秒級速度,然而相對於用Instrumentation來說已經是極大的提升了。
注:第一次執行可能需要下載一些library,或者是gradle本身,可能需要花一點時間,這個跟unit test本身沒關。
整個專案已經放到github上面:robolectric-demo
小結
總體來說,Robolectric是個非常強大好用的unit testing framework。雖然使用的過程中肯定也會遇到問題,我個人就遇到不少問題,尤其是跟第三方的library比如Retrofit、ActiveAndroid結合使用的時候,會有不少問題,但瑕不掩瑜,我們依然可以用它完成很大部分的unit testing工作。
————————–原文結束————————–
今天回過頭來看,我想強調的是,Robolectric到底應該充當什麼樣的一個角色。在沒有Robolectric的pure JUnit世界,我們是很難對一整個流程進行測試的,因為上層的介面是安卓的類,底層的資料庫和Preference等等是安卓的類。因此,我們沒有辦法對一整個流程做一個完整的測試。然而有了robolectric以後,我們就可以這麼做了:啟動activity,向網路或資料庫請求資料,更新介面。。。因此,有了這個東西以後,我們的第一反應可能就是去測試這整個app流程。所以經常有小夥伴問我,Robolectric到底是做單元測試的框架,還是做整合測試,甚至UI測試的框架?
這就是我想強調的,需要避免的陷阱。對於上面的問題,我的回答是:Robolectric就是一個能夠讓我們在JVM上跑 測試 時夠呼叫安卓的類的框架,至於我們是拿它來做單元測試還是整合測試,完全取決於我們自己。而回到我們強調的 單元測試,測一個小的獨立的程式碼單元,Robolectric的角色,應該是一個讓我們在做 單元測試 的過程中,能夠呼叫安卓的類,測試安卓的類,把安卓的類當做普通的純java類的一個framework,僅此而已。
這點,謹記。
有任何意見或建議,或者發現文中任何問題,歡迎留言評論!
打賞支援我寫出更多好文章,謝謝!
打賞作者
打賞支援我寫出更多好文章,謝謝!