為什么要做單元測試
學習過或者了解軟件工程的人一定對這個東西不陌生,很多人也知道這個東西很重要,但是總是以各種借口來推脫,這其中就包括我。大學我學習的并不是軟件工程,所以對什么黑盒測試、白盒測試、灰盒測試只是聽說過,并沒有什么具體感覺。前段時間正好看了bob大叔的的《代碼整潔之道》和 另外一本經典《重構:改善既有代碼的設計》,兩位作者都對單元測試以及TDD(測試驅動開發)推崇備至,我看完之后也是激動不已,正好手頭有個新項目。于是當即決定將這種開發模式引入進來。
題外話說了這么多,我們就先來看看寫單元測試的一些好處吧!
1. 開發新功能時,避免遺漏功能點
我們在開發的過程中,常常出現因為新功能太多而遺忘的情況。等我們提測之后,看到滿屏的bug,心里一定是崩潰的。但是如果我們先寫好單元測試,或者說打好樁,然后根據功能點一個一個的開發,既避免了遺忘,又能測試我們的代碼,一舉兩得啊!
2. 重構代碼時,避免影響其他功能
重構的時候,我們最擔心的就是影響其他模塊或功能。但是如果我們提前寫好了單元測試,我們就能很輕易的就發現我們在重構的過程中出現的side effect
3. 提高我們的編程能力
單元測試寫多了之后,我們很容易就能在編碼的過程中注意到各種邊界條件,從而寫出健壯性更好的程序
4. 提高我們的效率
很多人看到這點的時候會覺得奇怪。雖然看起來我們花了很多時間在編寫單元測試上,但是一旦單元測試寫好了之后,基本上就是一勞永逸的,難道你不覺得讓電腦自動去測試比我們挨個挨個去點我們的app效率更高嗎?更何況,假設有人過來接手我們寫的項目的時候,他們只需要打開單元測試就知道我們這個項目,這個模塊做了些什么事情,就能更快速的上手了。
看了這么多單元測試的好處之后,是不是有些躍躍欲試了?先不著急,我們先來看看Clean Architecture 的結構,分析分析Clean Architecture 的特點再對癥下藥。
Clean Architecture 中,它的業務邏輯代碼放在了domain 層,是純 java 代碼,data 層用到了一些 android 平臺的東西,包括網絡訪問、數據存儲等。UI 層又劃分成了P(presentor) 和 V(view),presentor 是純 java代碼,view 部分才是跟 android 緊密相關的。所以我們需要兩類工具:測試純java代碼的和測試android相關的。
Java 代碼的單元測試
純 Java 代碼測試框架很多,最出名的應該是 Junit 和 TestNG 了。Android Studio 默認使用的是 JUnit 4 ,大概是因為JUnit 4的使用人群最多吧。我們接下來就介紹JUnit 4的功能和特點。
使用JUnit進行單元測試
最開始的JUnit 是由Kent Beck 和Eric Gamma 在飛機上寫出來的(膜拜ing)。因為Android Studio已經將其內置了,所以我們就不需要再額外引入了。只需要在我們想要添加單元測試的module 的build.gradle 中添加下面這句話:
testCompile "junit:junit:4.12"
一般來說,單元測試分為三步:
setup:即new 出待測試的類,設置一些前提條件
執行動作:即調用被測類的被測方法,并獲取返回結果
驗證結果:驗證獲取的結果跟預期的結果是一樣的
一個簡單的例子
假設我們的代碼中有一個購物車類和一個商品類,每當一個商品加入到購物車之后,購物車會計算商品總價。
public class Goods {
private String name;
private long id;
private long price;
private int quantities;
public Goods(long id){
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public long getId() {
return id;
}
public long getPrice() {
return price;
}
public void setPrice(long price) {
this.price = price;
}
public int getQuantities() {
return quantities;
}
public void setQuantities(int quantities) {
this.quantities = quantities;
}
public long getTotalPrice() {
return price * quantities;
}
}
生成測試代碼
我們在Goods.java 的源碼中任意位置單擊右鍵,按照下圖所示,Android Studio 就會引導我們生成測試類。
Testing library 一欄選擇JUnit4。class name 就用默認的,否則就無法從被測試類快速跳轉到測試類中了。如果我們要做一些初始化工作,我們就需要勾選setUp。然后在下面方法列表中選擇我們想要測試哪些方法。一般來說,我們不會去測試代碼中簡單的 setter 和 getter , 除非里面有很復雜的邏輯代碼。所以,我們這里只測試 getTotalPrice 這個方法;
最后在生成的代碼中添加以下測試代碼:
public class GoodsTest {
private Goods goods;
@Before
public void setUp() throws Exception {
goods = new Goods(1);
}
@Test
public void testGetTotalPrice() throws Exception {
goods.setPrice(112);
goods.setQuantities(10);
Assert.assertEquals(112*12,goods.getTotalPrice());
}
}
其中 setup 方法對應的前面所說的 setup 部分,testGetTotalPrice 方法的前兩句對應前面所說的執行動作,而最后一句 Assert 就是第三部分驗證結果。
我們可以單擊方法名左側的綠色三角按鈕來測試單個方法,或者快捷鍵 Ctrl+Shift+F10 來運行整個測試類。測試結果會在下方顯示:
測試失敗,運行結果會告訴我們在哪一行出錯了以及期望的結果是什么,而實際結果又是什么。
使用Mock框架
在寫單元測試的過程中,一個很普遍的問題是,要測試的類會有很多依賴,這些依賴的類/對象/資源又會有別的依賴,從而形成一個大的依賴樹,要在單元測試的環境中完整地構建這樣的依賴,是一件很困難的事情。所幸我們有一個應對這個問題的解決方案:mock。我們使用 mock 框架可以模擬任何類,構建一個假對象,而不需要實際運行這個類,我們可以定義這些假對象上的行為,提供給被測試對象使用。被測試對象像使用真的對象一樣使用它們。用這種方式,我們可以把測試的目標限定于被測試對象本身,就如同在被測試對象周圍做了一個劃斷,形成了一個盡量小的被測試目標。
引入Mock框架
Mock的框架有很多,最為知名的一個是Mockito,這是一個開源項目,使用廣泛。同樣,在build.gradle中添加以下語句:
testCompile "org.mockito:mockito-core:1.9.5"
我們先來看一個官方的示例:
import org.mockito.Mockito;
// 創建mock對象
List mockedList = Mockito.mock(List.class);
// 設置mock對象的行為 - 當調用其get方法獲取第0個元素時,返回"one"
Mockito.when(mockedList.get(0)).thenReturn("one");
// 使用mock對象 - 會返回前面設置好的值"one",即便列表實際上是空的
String str = mockedList.get(0);
Assert.assertTrue("one".equals(str));
Assert.assertTrue(mockedList.size() == 0);
// 驗證mock對象的get方法被調用過,而且調用時傳的參數是0
Mockito.verify(mockedList).get(0);
代碼中的注釋描述了代碼的邏輯:先創建mock對象mockedList,然后設置mock對象上的方法get,指定當get方法被調用,并且參數為0的時候,返回”one”;然后,調用被測試方法(被測試方法會調用mock對象的get方法);
上面這個示例揭示了最簡單的使用情況,當我最開始看到這個示例的時候,對最后一句很是困惑,覺得這句完全沒有必要,直到我在項目中寫下面這些代碼:
被測試類
public class UserLoginImpl extends UseCase implements UserLogin {
UserRepository userRepository;
private String name;
private String pwd;
@Inject
public UserLoginImpl(UserRepository userRepository,ThreadExecutor threadExecutor, PostExecutionThread postExecutionThread){
super(threadExecutor,postExecutionThread);
this.userRepository = userRepository;
}
@Override
protected Observable buildUseCaseObservable() {
return userRepository.userLogin(name,pwd);
}
@Override
public UserLogin setAccount(String name) {
this.name = name;
return this;
}
@Override
public UserLogin setPwd(String pwd) {
this.pwd = MD5.parseStrToMd5U32(pwd);
return this;
}
}
測試類
public class UserLoginTest {
private UserLoginImpl userLogin;
@Mock private ThreadExecutor mockThreadExecutor;
@Mock private PostExecutionThread mockPostExecutionThread;
@Mock private UserRepository mockUserRepository;
private static final String FAKE_USER_ACCOUNT = "13478969876";
private static final String FAKE_USER_PWD = "13478969876";
@Before
public void setUp() throws Exception {
MockitoAnnotations.initMocks(this);
userLogin = new UserLoginImpl(mockUserRepository,mockThreadExecutor,mockPostExecutionThread);
userLogin.setAccount(FAKE_USER_ACCOUNT);
userLogin.setPwd(FAKE_USER_PWD);
}
@Test
public void testBuildUseCaseObservable() throws Exception {
userLogin.buildUseCaseObservable();
verify(mockUserRepository).userLogin(FAKE_USER_ACCOUNT,FAKE_USER_PWD);
verifyNoMoreInteractions(mockUserRepository);//驗證mockUserRepository是否還有其他地方被調用過
verifyZeroInteractions(mockPostExecutionThread); //驗證mockPostExecutionThread是否被調用過
verifyZeroInteractions(mockThreadExecutor);//驗證mockThreadExecutor是否被調用過
}
}
竟然失敗了!它告訴我它期望的密碼是13478969876,而實際調用的確是一長串的字符。研究了很久,終于發現了原因:在設置密碼的時候,我算出密碼的MD5值后,將MD5值存成為密碼,執行 userLogin 方法的時候調用的是加密后的字符串,而我在 verify 函數中使用的是沒有加密的密碼,兩者不一致,因而編譯器報錯。
使用 Mock 框架,不但能讓其返回任何我們任何我們需要的數據,而且還能驗證我們是否正確調用了,這么強大又好用的功能,還不趕快用起來!
Android 代碼的單元測試
android 的單元測試比純java代碼的復雜多了。純 java 代碼我們直接在PC上就能運行了,因為它只依賴JVM。但是android 代碼要跑起來當然需要android的運行環境了,比如TextView、Toast等,雖然它也是一個變種的JVM。如果我們的代碼還需要在模擬器或真機上才能測試,那效率可想而知有多慢了。有沒有能在PC的JVM上就能運行測試代碼的工具呢?當然有,接下來我們就介紹我們的主角——Robolectric。
使用Robolectric進行單元測試
Robolectric 是一個開源框架,它實現了一套在 JVM 上能運行的 Android 開發環境。它實現一套 Shadow* 的東西,比如ShadowTextView , ShadowToast等控件。顧名思義,影子對象(Shadow Object)并不是真正的對象,它只是真實對象的一個影子。真實對象做了任何動作,產生了任何效果,我們通過影子對象就能知道,并能夠通過影子對象就能知道真實對象的結果。
Robolectric環境搭建
把下面這段話加入到您的build.gradle中來就可以將Robolectric 引入你的項目中了:
testCompile "org.robolectric:robolectric:3.0"
但是如果您的項目使用了MultiDex,那您就需要使用最新的3.2了。
按照前文介紹過的方法,我們生成對應的測試代碼,然后通過注解配置TestRunner
@RunWith(RobolectricGradleTestRunner.class)
@Config(constants = BuildConfig.class)
public class LoginActivityTest {
}
Activity 的測試
創建activity 實例
@Test
public void testActivity() {
SampleActivity sampleActivity = Robolectric.setupActivity(SampleActivity.class);
assertNotNull(sampleActivity);
assertEquals(sampleActivity.getTitle(), "SimpleActivity");
}
activity 生命周期
@Test
public void testLifecycle() {
ActivityController<SampleActivity> activityController = Robolectric.buildActivity(SampleActivity.class).create().start();
Activity activity = activityController.get();
TextView textview = (TextView) activity.findViewById(R.id.tv_lifecycle_value);
assertEquals("onCreate",textview.getText().toString());
activityController.resume();
assertEquals("onResume", textview.getText().toString());
activityController.destroy();
assertEquals("onDestroy", textview.getText().toString());
}
跳轉
@Test
public void testStartActivity() {
forwardBtn.performClick();
Intent expectedIntent = new Intent(sampleActivity, LoginActivity.class);
Intent actualIntent = ShadowApplication.getInstance().getNextStartedActivity();
assertEquals(expectedIntent, actualIntent);
}
UI組件狀態
@Test
public void testViewState(){
CheckBox checkBox = (CheckBox) sampleActivity.findViewById(R.id.checkbox);
Button inverseBtn = (Button) sampleActivity.findViewById(R.id.btn_inverse);
assertTrue(inverseBtn.isEnabled());
checkBox.setChecked(true);
inverseBtn.performClick();
assertTrue(!checkBox.isChecked());
inverseBtn.performClick();
assertTrue(checkBox.isChecked());
}
Dialog
@Test
public void testDialog(){
//點擊按鈕,出現對話框
dialogBtn.performClick();
AlertDialog latestAlertDialog = ShadowAlertDialog.getLatestAlertDialog();
assertNotNull(latestAlertDialog);
}
Toast
@Test
public void testToast(){
toastBtn.performClick();
assertEquals(ShadowToast.getTextOfLatestToast(),"we love UT");
}
Fragment
如果使用support的Fragment,需添加以下依賴
testCompile "org.robolectric:shadows-support-v4:3.0"
shadow-support包提供了將Fragment主動添加到Activity中的方法:SupportFragmentTestUtil.startFragment(),簡易的測試代碼如下
@Test
public void testFragment(){
SampleFragment sampleFragment = new SampleFragment();
//此api可以主動添加Fragment到Activity中,因此會觸發Fragment的onCreateView()
SupportFragmentTestUtil.startFragment(sampleFragment);
assertNotNull(sampleFragment.getView());
}
訪問資源
@Test
public void testResources() {
Application application = RuntimeEnvironment.application;
String appName = application.getString(R.string.app_name);
String activityTitle = application.getString(R.string.title_activity_simple);
assertEquals("LoveUT", appName);
assertEquals("SimpleActivity",activityTitle);
}
Service的測試
Service的測試類似于BroadcastReceiver,以IntentService為例,可以直接觸發onHandleIntent()方法,用來驗證Service啟動后的邏輯是否正確。
public class SampleIntentService extends IntentService {
public SampleIntentService() {
super("SampleIntentService");
}
@Override
protected void onHandleIntent(Intent intent) {
SharedPreferences.Editor editor = getApplicationContext().getSharedPreferences(
"example", Context.MODE_PRIVATE).edit();
editor.putString("SAMPLE_DATA", "sample data");
editor.apply();
}
}
以上代碼的單元測試用例:
@Test
public void addsDataToSharedPreference() {
Application application = RuntimeEnvironment.application;
RoboSharedPreferences preferences = (RoboSharedPreferences) application
.getSharedPreferences("example", Context.MODE_PRIVATE);
SampleIntentService registrationService = new SampleIntentService();
registrationService.onHandleIntent(new Intent());
assertEquals(preferences.getString("SAMPLE_DATA", ""), "sample data");
}
BroadcastReceiver 的測試
首先看下廣播接收者的代碼
public class MyReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
SharedPreferences.Editor editor = context.getSharedPreferences(
"account", Context.MODE_PRIVATE).edit();
String name = intent.getStringExtra("EXTRA_USERNAME");
editor.putString("USERNAME", name);
editor.apply();
}
}
廣播的測試點可以包含兩個方面,一是應用程序是否注冊了該廣播,二是廣播接受者的處理邏輯是否正確,關于邏輯是否正確,可以直接人為的觸發onReceive()方法,驗證執行后所影響到的數據。
@Test
public void testBoradcast(){
ShadowApplication shadowApplication = ShadowApplication.getInstance();
String action = "com.geniusmart.loveut.login";
Intent intent = new Intent(action);
intent.putExtra("EXTRA_USERNAME", "geniusmart");
//測試是否注冊廣播接收者
assertTrue(shadowApplication.hasReceiverForIntent(intent));
//以下測試廣播接受者的處理邏輯是否正確
MyReceiver myReceiver = new MyReceiver();
myReceiver.onReceive(RuntimeEnvironment.application,intent);
SharedPreferences preferences = shadowApplication.getSharedPreferences("account", Context.MODE_PRIVATE);
assertEquals( "geniusmart",preferences.getString("USERNAME", ""));
}
自定義控件的測試
Robolectric 定義了很多影子類,它擴展或繼承了Android OS對應的類。當創建一個Android 類,Robolectric 就會查找對應的影子類,如果找到了,Robolectric 就會創建一個影子對象與之對應。當調用Android 類的方法的時候,Robolectric 就會確保對應的方法被調用了。如果 Robolectric 的影子類不能滿足您的要求,你還可以安裝一定的要求編寫自己的影子類。最常見的應該就是自定義控件了。
我們封裝了一個toast,它的代碼如下所示:
public class FBToast {
private static Toast toast = null;
private static TextView view = null;
public static void showShortToast(Context context, String msg) {
showToast(context,msg,Toast.LENGTH_SHORT);
}
public static void showToast(Context context, String msg, int duration) {
if (toast == null) {
toast = new Toast(context);
view = (TextView) LayoutInflater.from(context)
.inflate(R.layout.publish_toast, null);
view.setText(msg);
view.setPadding(30, 80, 30, 80);
toast.setView(view);
toast.setDuration(duration);
toast.setGravity(Gravity.CENTER, 0, 0);
} else {
view.setText(msg);
toast.setDuration(duration);
}
toast.show();
}
public static void cancel(){
if(toast != null) {
toast.cancel();
}
}
}
代碼中使用如下:
@Override
public void showErrorMessage(String message) {
FBToast.showShortToast(this,message);
}
如果我們不擴展它的影子類,那我們是無法測試程序是否正確調用了Toast的相關代碼。
FBToast的shadow 對象:
@Implements(FBToast.class)
public class ShadowFBToast extends ShadowToast{
@Implementation
public static void showShortToast(Context context, String msg){
showToast(context,msg,Toast.LENGTH_SHORT);
}
@Implementation
public static void showToast(Context context, String msg, int duration){
ShadowToast.makeText(context,msg,duration).show();
}
public static String getTextOfLatestToast(){
return ShadowToast.getTextOfLatestToast();
}
}
自定義shadow對象要求必須在類定義中加上@Implements(AndroidClassName.class)
注解,并在你在代碼中使用的公共方法上也加上@Implementation
注解。最后,你需要在你的測試代碼的config注解加上這個自定義Shadow 對象。
@RunWith(RobolectricGradleTestRunner.class)
@Config(constants = BuildConfig.class,shadows = {ShadowFBToast.class})
public class LoginActivityTest {
....
}
RxJava 單元測試的注意事項
RxJava 給我們帶來了非常大的便利,它簡化了邏輯,避免了"嵌套地獄",邏輯清晰簡單,如水銀泄地。但是它也給我們進行單元測試帶來了一些麻煩。
匿名類的問題
由于Mockito 不支持匿名類,所以我們在使用RxJava的時候要特別注意。相信很多人跟我一樣,Subscriber 都是像下面這樣寫的。
userLogin.setAccount(account)
.setPwd(pwd)
.execute(new BaseSubscriber<Boolean>() {
@Override
public void onCompleted() {
}
@Override
public void onError(Throwable e) {
}
@Override
public void onNext(Boolean o) {
view.renderSuccessView();
}
});
@Test
public void testSubmit() throws Exception {
TestSubscriber<Boolean> testSubscriber = new TestSubscriber<>();
loginPresenter.submit(FAKE_ACCOUNT,FAKE_PWD);
Mockito.verify(userLogin).setAccount(FAKE_ACCOUNT);
Mockito.verify(userLogin).setPwd(FAKE_ACCOUNT);
Mockito.verify(userLogin).execute(testSubscriber);
}
控制臺就會報下面這個錯誤:
為了解決這個問題,你就必須將匿名類變成內部類。
@Override
public void submit(String account, String pwd) {
if(validate(account,pwd)){
userLogin.setAccount(account)
.setPwd(pwd)
.execute(new UserLoginSubscriber());
}
}
public final class UserLoginSubscriber extends BaseSubscriber<Boolean> {
@Override
public void onCompleted() {
}
@Override
public void onError(Throwable e) {
}
@Override
public void onNext(Boolean aBoolean) {
view.renderSuccessView();
}
}
異步回調的測試
還是以前一節的代碼為例,如果我們想測試UserLoginSubscriber這個類里面的三個方法,我們該怎么做呢?
Mockito 為我們提供了兩個解決方案:
1. doAnswer
我們可以使用都doAnswer為一個函數進行打樁以測試異步函數。當被測試的方法被調用時我們生成了一個通用的anwser,這個回調會被執行。UserLoginSubscriber 是回調函數所在的類,LoginPresenter是我們要測試的類,UserLogin是我們mock的對象,UserLogin執行了UserLoginSubscriber的回調方法。具體看代碼:
@Mock
UserLogin userLogin;
@Mock
LoginPresenter.View mockView;
private LoginPresenter loginPresenter;
@Test
public void testSubmit() throws Exception {
Mockito.doAnswer(new Answer() {
@Override
public Object answer(InvocationOnMock invocation) throws Throwable {
((UserLoginSubscriber)invocation.getArguments()[0]).onNext(true);
return null;
}
}).when(userLogin).execute(Mockito.any(UserLoginSubscriber.class));
loginPresenter.submit(FAKE_ACCOUNT,FAKE_PWD);
Mockito.verify(userLogin,Mockito.times(1)).execute(Mockito.any(BaseSubscriber.class));
Mockito.verify(mockView).renderSuccessView();
}
2. ArgumentCaptor
在這里我們的UserLoginSubscriber是異步的: 我們通過ArgumentCaptor捕獲傳遞到UserLogin對象的UserLoginSubscriber回調。
@Mock
UserLogin userLogin;
@Mock
LoginPresenter.View mockView;
@Captor
ArgumentCaptor<LoginPresenterImpl.UserLoginSubscriber> argumentCaptor;
private LoginPresenter loginPresenter;
@Test
public void testSubmit() throws Exception {
loginPresenter.submit(FAKE_ACCOUNT,FAKE_PWD);
Mockito.verify(userLogin).execute(argumentCaptor.capture());
argumentCaptor.getValue().onNext(true);
Mockito.verify(mockView).renderSuccessView();
}
doAnswer 和 ArgumentCaptor 都是值得大書特書的東西,這里我們就只簡單介紹到這里。如果有機會,會單獨寫一篇來介紹這兩個東西。
RxJava 的Subscriber 的測試
RxJava 由于使用鏈式調用,而且通常最后subscribe方法是沒有返回值的,所以我們沒有辦法去像常規單元測試一樣對其進行測試。所以,我們不得不動用一些非常規武器---TestSubscriber。哈哈,其實不是,這是官方提供的,使用方法如下:
@Test
public void testGetGoodsCategories() throws Exception {
TestSubscriber<List<GoodsCategory>> testSubscriber = new TestSubscriber<>();
GoodsRepository.getGoodsCategories(FAKE_CATEGORY_ID).subscribe(testSubscriber);
testSubscriber.assertNoErrors();
}
@Test
public void testInvalidCategoryId(){
TestSubscriber<List<GoodsCategory>> testSubscriber = new TestSubscriber<>();
GoodsRepository.getGoodsCategories(INVALID_CATEGORY_ID).subscribe(testSubscriber);
testSubscriber.assertError(IllegalArgumentException.class);
}
非常簡單,我們通過檢查TestSubscriber 的回調結果,就能知道我們的程序是否按照我們預想的運行。TestSubscriber 還有其他方法,諸如assertValue(),assertCompleted()等等。總之,RxJava 為我們提供了豐富的工具來進行測試。
結束語
本文只是最基本的單元測試指南,許多高級使用技巧我們并沒有涉及到,比如JUnit 的Rule。 這需要我們在日后的工作中一點點去學習和積累。其實,關于Dagger2 的,還有一個據說很神奇的開源庫DaggerMock,但由于我沒有試驗成功,所以這里就不介紹了,等到哪天我學會如何使用之后,再把這塊內容補上。