本文由
玉剛說寫做平臺
提供寫做贊助java原做者:
Jdqm
android版權聲明:本文版權歸微信公衆號
玉剛說
全部,未經許可,不得以任何形式轉載git
單元測試是應用程序測試策略中的基本測試,經過對代碼進行單元測試,能夠輕鬆地驗證單個單元的邏輯是否正確,在每次構建以後運行單元測試,能夠幫助您快速捕獲和修復因代碼更改(重構、優化等)帶來的迴歸問題。本文主要聊聊Android中的單元測試。github
爲何要進行單元測試?算法
單元測試要測什麼?shell
單元測試的分類數據庫
本地測試(Local tests): 只在本地機器JVM上運行,以最小化執行時間,這種單元測試不依賴於Android框架,或者即便有依賴,也很方便使用模擬框架來模擬依賴,以達到隔離Android依賴的目的,模擬框架如google推薦的[Mockito][1];api
儀器化測試(Instrumented tests): 在真機或模擬器上運行的單元測試,因爲須要跑到設備上,比較慢,這些測試能夠訪問儀器(Android系統)信息,好比被測應用程序的上下文,通常地,依賴不太方便經過模擬框架模擬時採用這種方式。安全
JUnit 註解bash
瞭解一些JUnit註解,有助於更好理解後續的內容。
Annotation | 描述 |
---|---|
@Test public void method() | 定義所在方法爲單元測試方法 |
@Test (expected = Exception.class) public void method() | 測試方法若沒有拋出Annotation中的Exception類型(子類也能夠)->失敗 |
@Test(timeout=100) public void method() | 性能測試,若是方法耗時超過100毫秒->失敗 |
@Before public void method() | 這個方法在每一個測試以前執行,用於準備測試環境(如: 初始化類,讀輸入流等),在一個測試類中,每一個@Test方法的執行都會觸發一次調用。 |
@After public void method() | 這個方法在每一個測試以後執行,用於清理測試環境數據,在一個測試類中,每一個@Test方法的執行都會觸發一次調用。 |
@BeforeClass public static void method() | 這個方法在全部測試開始以前執行一次,用於作一些耗時的初始化工做(如: 鏈接數據庫),方法必須是static |
@AfterClass public static void method() | 這個方法在全部測試結束以後執行一次,用於清理數據(如: 斷開數據鏈接),方法必須是static |
@Ignore或者@Ignore("太耗時") public void method() | 忽略當前測試方法,通常用於測試方法尚未準備好,或者太耗時之類的 |
@FixMethodOrder(MethodSorters.NAME_ASCENDING) public class TestClass{} | 使得該測試類中的全部測試方法都按照方法名的字母順序執行,能夠指定3個值,分別是DEFAULT、JVM、NAME_ASCENDING |
根據單元有沒有外部依賴(如Android依賴、其餘單元的依賴),將本地測試分爲兩類,首先看看沒有依賴的狀況:
dependencies {
// Required -- JUnit 4 framework
testImplementation 'junit:junit:4.12'
// Optional -- Mockito framework(可選,用於模擬一些依賴對象,以達到隔離依賴的效果)
testImplementation 'org.mockito:mockito-core:2.19.0'
}
複製代碼
事實上,AS已經幫咱們建立好了測試代碼存儲目錄。
app/src
├── androidTestjava (儀器化單元測試、UI測試)
├── main/java (業務代碼)
└── test/java (本地單元測試)
複製代碼
能夠本身手動在相應目錄建立測試類,AS也提供了一種快捷方式:選擇對應的類->將光標停留在類名上->按下ALT + ENTER->在彈出的彈窗中選擇Create Test
Note: 勾選
setUp/@Before
會生成一個帶@Before
註解的setUp()
空方法,tearDown/@After
則會生成一個帶@After
的空方法。
import static org.hamcrest.core.Is.is;
import static org.junit.Assert.assertThat;
public class EmailValidatorTest {
@Test
public void isValidEmail() {
assertThat(EmailValidator.isValidEmail("name@email.com"), is(true));
}
}
複製代碼
運行前面測試驗證郵箱格式的例子,測試結果會在Run窗口展現,以下圖:
從結果能夠清晰的看出,測試的方法爲 EmailValidatorTest
類中的 isValidEmail()
方法,測試狀態爲passed,耗時12毫秒。
修改一下前面的例子,傳入一個非法的郵箱地址:
@Test
public void isValidEmail() {
assertThat(EmailValidator.isValidEmail("#name@email.com"), is(true));
}
複製代碼
測試狀態爲failed,耗時14毫秒,同時也給出了詳細的錯誤信息:在15行出現了斷言錯誤,錯誤緣由是指望值(Expected)爲true,但實際(Actual)結果爲false。
也能夠經過命令 gradlew test
來運行全部的測試用例,這種方式能夠添加以下配置,輸出單元測試過程當中各種測試信息:
android {
...
testOptions.unitTests.all {
testLogging {
events 'passed', 'skipped', 'failed', 'standardOut', 'standardError'
outputs.upToDateWhen { false }
showStandardStreams = true
}
}
}
複製代碼
仍是驗證郵箱地址格式的例子 gradlew test
:
在單元測試中經過System.out或者System.err打印的也會輸出。
前面驗證郵件格式的例子,本地JVM虛擬機就能提供足夠的運行環境,但若是要測試的單元依賴了Android框架,好比用到了Android中的Context類的一些方法,本地JVM將沒法提供這樣的環境,這時候模擬框架[Mockito][1]就派上用場了。
下面是一個Context#getString(int)的測試用例
import static org.hamcrest.core.Is.is;
import static org.junit.Assert.assertThat;
import static org.mockito.Mockito.when;
@RunWith(MockitoJUnitRunner.class)
public class MockUnitTest {
private static final String FAKE_STRING = "AndroidUnitTest";
@Mock
Context mMockContext;
@Test
public void readStringFromContext_LocalizedString() {
//模擬方法調用的返回值,隔離對Android系統的依賴
when(mMockContext.getString(R.string.app_name)).thenReturn(FAKE_STRING);
assertThat(mMockContext.getString(R.string.app_name), is(FAKE_STRING));
when(mMockContext.getPackageName()).thenReturn("com.jdqm.androidunittest");
System.out.println(mMockContext.getPackageName());
}
}
複製代碼
經過模擬框架[Mockito][1],指定調用context.getString(int)方法的返回值,達到了隔離依賴的目的,其中[Mockito][1]使用的是[cglib][2]動態代理技術。
在某些狀況下,雖然能夠經過模擬的手段來隔離Android依賴,但代價很大,這種狀況下能夠考慮儀器化的單元測試,有助於減小編寫和維護模擬代碼所需的工做量。
儀器化測試是在真機或模擬器上運行的測試,它們能夠利用Android framework APIs 和 supporting APIs。若是測試用例須要訪問儀器(instrumentation)信息(如應用程序的Context),或者須要Android框架組件的真正實現(如Parcelable或SharedPreferences對象),那麼應該建立儀器化單元測試,因爲要跑到真機或模擬器上,因此會慢一些。
dependencies {
androidTestImplementation 'com.android.support:support-annotations:27.1.1'
androidTestImplementation 'com.android.support.test:runner:1.0.2'
androidTestImplementation 'com.android.support.test:rules:1.0.2'
}
複製代碼
android {
...
defaultConfig {
...
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
}
}
複製代碼
這裏舉一個操做SharedPreference的例子,這個例子須要訪問Context類以及SharedPreference的具體實現,採用模擬隔離依賴的話代價會比較大,因此採用儀器化測試比較合適。
這是業務代碼中操做SharedPreference的實現
public class SharedPreferenceDao {
private SharedPreferences sp;
public SharedPreferenceDao(SharedPreferences sp) {
this.sp = sp;
}
public SharedPreferenceDao(Context context) {
this(context.getSharedPreferences("config", Context.MODE_PRIVATE));
}
public void put(String key, String value) {
SharedPreferences.Editor editor = sp.edit();
editor.putString(key, value);
editor.apply();
}
public String get(String key) {
return sp.getString(key, null);
}
}
複製代碼
建立儀器化測試類(app/src/androidTest/java)
// @RunWith 只在混合使用 JUnit3 和 JUnit4 須要,若只使用JUnit4,可省略
@RunWith(AndroidJUnit4.class)
public class SharedPreferenceDaoTest {
public static final String TEST_KEY = "instrumentedTest";
public static final String TEST_STRING = "玉剛說";
SharedPreferenceDao spDao;
@Before
public void setUp() {
spDao = new SharedPreferenceDao(App.getContext());
}
@Test
public void sharedPreferenceDaoWriteRead() {
spDao.put(TEST_KEY, TEST_STRING);
Assert.assertEquals(TEST_STRING, spDao.get(TEST_KEY));
}
}
複製代碼
運行方式和本地單元測試同樣,這個過程會向鏈接的設備安裝apk,測試結果將在Run窗口展現,以下圖:
經過測試結果能夠清晰看到狀態passed,仔細看打印的log,能夠發現,這個過程向模擬器安裝了兩個apk文件,分別是app-debug.apk和app-debug-androidTest.apk,instrumented測試相關的邏輯在app-debug-androidTest.apk中。簡單介紹一下安裝apk命令pm install:
// 安裝apk
//-t:容許安裝測試 APK
//-r:從新安裝現有應用,保留其數據,相似於替換安裝
//更多請參考 https://developer.android.com/studio/command-line/adb?hl=zh-cn
adb shell pm install -t -r filePath
複製代碼
安裝完這兩個apk後,經過am instrument
命令運行instrumented測試用例,該命令的通常格式:
am instrument [flags] <test_package>/<runner_class>
複製代碼
例如本例子中的實際執行命令:
adb shell am instrument -w -r -e debug false -e class 'com.jdqm.androidunittest.SharedPreferenceDaoTest#sharedPreferenceDaoWriteRead' com.jdqm.androidunittest.test/android.support.test.runner.AndroidJUnitRunner
複製代碼
-w: 強制 am instrument 命令等待儀器化測試結束才結束本身(wait),保證命令行窗口在測試期間不關閉,方便查看測試過程的log
-r: 以原始格式輸出結果(raw format)
-e: 以鍵值對的形式提供測試選項,例如 -e debug false
關於這個命令的更多信息請參考
https://developer.android.com/studio/test/command-line?hl=zh-cn
複製代碼
若是你實在無法忍受instrumented test的耗時問題,業界也提供了一個現成的方案[Robolectric][3],下一小節講開源框庫的時候會將這個例子改爲本地本地測試。
https://github.com/mockito/mockito
Mock對象,模擬控制其方法返回值,監控其方法的調用等。
添加依賴
testImplementation 'org.mockito:mockito-core:2.19.0'
複製代碼
例子
import static org.hamcrest.core.Is.is;
import static org.junit.Assert.*;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.*;
import static org.mockito.internal.verification.VerificationModeFactory.atLeast;
@RunWith(MockitoJUnitRunner.class)
public class MyClassTest {
@Mock
MyClass test;
@Test
public void mockitoTestExample() throws Exception {
//但是使用註解@Mock替代
//MyClass test = mock(MyClass.class);
// 當調用test.getUniqueId()的時候返回43
when(test.getUniqueId()).thenReturn(18);
// 當調用test.compareTo()傳入任意的Int值都返回43
when(test.compareTo(anyInt())).thenReturn(18);
// 當調用test.close()的時候,拋NullPointerException異常
doThrow(new NullPointerException()).when(test).close();
// 當調用test.execute()的時候,什麼都不作
doNothing().when(test).execute();
assertThat(test.getUniqueId(), is(18));
// 驗證是否調用了1次test.getUniqueId()
verify(test, times(1)).getUniqueId();
// 驗證是否沒有調用過test.getUniqueId()
verify(test, never()).getUniqueId();
// 驗證是否至少調用過2次test.getUniqueId()
verify(test, atLeast(2)).getUniqueId();
// 驗證是否最多調用過3次test.getUniqueId()
verify(test, atMost(3)).getUniqueId();
// 驗證是否這樣調用過:test.query("test string")
verify(test).query("test string");
// 經過Mockito.spy() 封裝List對象並返回將其mock的spy對象
List list = new LinkedList();
List spy = spy(list);
//指定spy.get(0)返回"Jdqm"
doReturn("Jdqm").when(spy).get(0);
assertEquals("Jdqm", spy.get(0));
}
}
複製代碼
https://github.com/powermock/powermock
對於靜態方法的mock
添加依賴
testImplementation 'org.powermock:powermock-api-mockito2:1.7.4'
testImplementation 'org.powermock:powermock-module-junit4:1.7.4'
複製代碼
Note: 若是使用了Mockito,須要這二者使用兼容的版本,具體參考 https://github.com/powermock/powermock/wiki/Mockito#supported-versions
例子
@RunWith(PowerMockRunner.class)
@PrepareForTest({StaticClass1.class, StaticClass2.class})
public class StaticMockTest {
@Test
public void testSomething() throws Exception{
// mock完靜態類之後,默認全部的方法都不作任何事情
mockStatic(StaticClass1.class);
when(StaticClass1.getStaticMethod()).thenReturn("Jdqm");
StaticClass1.getStaticMethod();
//驗證是否StaticClass1.getStaticMethod()這個方法被調用了一次
verifyStatic(StaticClass1.class, times(1));
}
}
複製代碼
或者是封裝爲非靜態,而後用[Mockito][1]:
class StaticClass1Wraper{
void someMethod() {
StaticClass1.someStaticMethod();
}
複製代碼
http://robolectric.org
主要是解決儀器化測試中耗時的缺陷,儀器化測試須要安裝以及跑在Android系統上,也就是須要在Android虛擬機或真機上面,因此十分的耗時,基本上每次來來回回都須要幾分鐘時間。針對這類問題,業界其實已經有了一個現成的解決方案: Pivotal實驗室推出的Robolectric,經過使用Robolectrict模擬Android系統核心庫的Shadow Classes的方式,咱們能夠像寫本地測試同樣寫這類測試,而且直接運行在工做環境的JVM上,十分方便。
添加配置
testImplementation "org.robolectric:robolectric:3.8"
android {
...
testOptions {
unitTests {
includeAndroidResources = true
}
}
}
複製代碼
例子 模擬打開MainActivity,點擊界面上面的Button,讀取TextView的文本信息。
MainActivity.java
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
final TextView tvResult = findViewById(R.id.tvResult);
Button button = findViewById(R.id.button);
button.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
tvResult.setText("Robolectric Rocks!");
}
});
}
}
複製代碼
測試類(app/src/test/java/)
@RunWith(RobolectricTestRunner.class)
public class MyActivityTest {
@Test
public void clickingButton_shouldChangeResultsViewText() throws Exception {
MainActivity activity = Robolectric.setupActivity(MainActivity.class);
Button button = activity.findViewById(R.id.button);
TextView results = activity.findViewById(R.id.tvResult);
//模擬點擊按鈕,調用OnClickListener#onClick
button.performClick();
Assert.assertEquals("Robolectric Rocks!", results.getText().toString());
}
}
複製代碼
測試結果
耗時917毫秒,是要比單純的本地測試慢一些。這個例子很是相似於直接跑到真機或模擬器上,然而它只須要跑在本地JVM便可,這都是得益於Robolectric的Shadow。
Note: 第一次跑須要下載一些依賴,可能時間會久一點,但後續的測試確定比儀器化測試打包兩個apk並安裝的過程快。
在第六小節介紹了經過儀器化測試的方式跑到真機上進行測試SharedPreferences操做,可能吐槽的點都在於耗時太長,如今經過Robolectric改寫爲本地測試來嘗試減小一些耗時。
在實際的項目中,Application可能建立時可能會初始化一些其餘的依賴庫,不太方便單元測試,這裏額外建立一個Application類,不須要在清單文件註冊,直接寫在本地測試目錄便可。
public class RoboApp extends Application {}
複製代碼
在編寫測試類的時候須要經過@Config(application = RoboApp.class)來配置Application,當須要傳入Context的時候調用RuntimeEnvironment.application來獲取:
app/src/test/java/
@RunWith(RobolectricTestRunner.class)
@Config(application = RoboApp.class)
public class SharedPreferenceDaoTest {
public static final String TEST_KEY = "instrumentedTest";
public static final String TEST_STRING = "玉剛說";
SharedPreferenceDao spDao;
@Before
public void setUp() {
//這裏的Context採用RuntimeEnvironment.application來替代應用的Context
spDao = new SharedPreferenceDao(RuntimeEnvironment.application);
}
@Test
public void sharedPreferenceDaoWriteRead() {
spDao.put(TEST_KEY, TEST_STRING);
Assert.assertEquals(TEST_STRING, spDao.get(TEST_KEY));
}
}
複製代碼
像本地同樣把它跑起來便可。
public static boolean isValidEmail(CharSequence email) {
if (TextUtils.isEmpty(email)) {
return false;
}
return EMAIL_PATTERN.matcher(email).matches();
}
複製代碼
當你嘗試本地測試這樣的代碼,就會收到一下的異常:
java.lang.RuntimeException: Method isEmpty in android.text.TextUtils not mocked.
複製代碼
這種狀況,直接在本地測試目錄(app/src/test/java)下添加TextUtils類的實現,但必須保證包名相同。
package android.text;
public class TextUtils {
public static boolean isEmpty(CharSequence str) {
return str == null || str.length() == 0;
}
}
複製代碼
public class Model {
public native boolean nativeMethod();
}
複製代碼
public class ModelTest {
Model model;
@Before
public void setUp() throws Exception {
model = mock(Model.class);
}
@Test
public void testNativeMethod() throws Exception {
when(model.nativeMethod()).thenReturn(true);
Assert.assertTrue(model.nativeMethod());
}
}
複製代碼
public class Presenter {
Model model;
public Presenter() {
model = new Model();
}
public boolean getBoolean() {
return model.getBoolean());
}
}
複製代碼
這種狀況,須要改進一下代碼的寫法,不在內部new,而是經過參數傳遞。
public class Presenter {
Model model;
public Presenter(Model model) {
this.model = model;
}
public boolean getBoolean() {
return model.getBoolean();
}
}
複製代碼
這樣作方便Mock Model對象。
public class PresenterTest {
Model model;
Presenter presenter;
@Before
public void setUp() throws Exception {
// mock Model對象
model = mock(Model.class);
presenter = new Presenter(model);
}
@Test
public void testGetBoolean() throws Exception {
when(model.getBoolean()).thenReturn(true);
Assert.assertTrue(presenter.getBoolean());
}
}
複製代碼
從這個例子能夠看出,代碼的框架是否對單元測試友好,也是推動單元測試的一個因素。
在一些涉及到文件讀寫的App,一般都會在運行時調用Environment.getExternalStorageDirectory()獲得機器的外存路徑,一般的作法是跑到真機或者模擬器上進行調試,耗時比較長,能夠經過模擬的方式,在本地JVM完成文件操做。
//注意包名保持一致
package android.os;
public class Environment {
public static File getExternalStorageDirectory() {
return new File("本地文件系統目錄");
}
}
複製代碼
直接在本地單元測試進行調試,再也不須要跑到真機,再把文件pull出來查看。
public class FileDaoTest {
public static final String TEST_STRING = "Hello Android Unit Test.";
FileDao fileDao;
@Before
public void setUp() throws Exception {
fileDao = new FileDao();
}
@Test
public void testWrite() throws Exception {
String name = "readme.md";
fileDao.write(name, TEST_STRING);
String content = fileDao.read(name);
Assert.assertEquals(TEST_STRING, content);
}
}
複製代碼
文章給出的一些示例性代碼片斷中,有一些類代碼沒有貼出來,有須要可到如下地址獲取完整代碼: https://github.com/jdqm/AndroidUnitTest
參考資料
https://developer.android.com/training/testing/unit-testing/ https://developer.android.com/training/testing/unit-testing/local-unit-tests https://developer.android.com/training/testing/unit-testing/instrumented-unit-tests https://blog.dreamtobe.cn/2016/05/15/android_test/ https://www.jianshu.com/p/bc99678b1d6e https://developer.android.com/studio/test/command-line?hl=zh-cn https://developer.android.com/studio/command-line/adb?hl=zh-cn