本文由
玉剛說寫作平臺
提供寫作贊助原作者:
Jdqm
版權宣告:本文版權歸微信公眾號
玉剛說
所有,未經許可,不得以任何形式轉載
單元測試是應用程式測試策略中的基本測試,通過對程式碼進行單元測試,可以輕鬆地驗證單個單元的邏輯是否正確,在每次構建之後執行單元測試,可以幫助您快速捕獲和修復因程式碼更改(重構、優化等)帶來的迴歸問題。本文主要聊聊Android中的單元測試。
單元測試的目的以及測試內容
為什麼要進行單元測試?
- 提高穩定性,能夠明確地瞭解是否正確的完成開發;
- 快速反饋bug,跑一遍單元測試用例,定位bug;
- 在開發週期中儘早通過單元測試檢查bug,最小化技術債,越往後可能修復bug的代價會越大,嚴重的情況下會影響專案進度;
- 為程式碼重構提供安全保障,在優化程式碼時不用擔心迴歸問題,在重構後跑一遍測試用例,沒通過說明重構可能是有問題的,更加易於維護。
單元測試要測什麼?
- 列出想要測試覆蓋的正常、異常情況,進行測試驗證;
- 效能測試,例如某個演算法的耗時等等。
單元測試的分類
-
本地測試(Local tests): 只在本地機器JVM上執行,以最小化執行時間,這種單元測試不依賴於Android框架,或者即使有依賴,也很方便使用模擬框架來模擬依賴,以達到隔離Android依賴的目的,模擬框架如google推薦的[Mockito][1];
-
儀器化測試(Instrumented tests): 在真機或模擬器上執行的單元測試,由於需要跑到裝置上,比較慢,這些測試可以訪問儀器(Android系統)資訊,比如被測應用程式的上下文,一般地,依賴不太方便通過模擬框架模擬時採用這種方式。
JUnit 註解
瞭解一些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依賴、其他單元的依賴),將本地測試分為兩類,首先看看沒有依賴的情況:
1. 新增依賴,google官方推薦:
dependencies {
// Required -- JUnit 4 framework
testImplementation 'junit:junit:4.12'
// Optional -- Mockito framework(可選,用於模擬一些依賴物件,以達到隔離依賴的效果)
testImplementation 'org.mockito:mockito-core:2.19.0'
}
複製程式碼
2. 單元測試程式碼儲存位置:
事實上,AS已經幫我們建立好了測試程式碼儲存目錄。
app/src
├── androidTestjava (儀器化單元測試、UI測試)
├── main/java (業務程式碼)
└── test/java (本地單元測試)
複製程式碼
3. 建立測試類:
可以自己手動在相應目錄建立測試類,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));
}
}
複製程式碼
4. 執行測試用例:
- 執行單個測試方法:選中@Test註解或者方法名,右鍵選擇Run;
- 執行一個測試類中的所有測試方法:開啟類檔案,在類的範圍內右鍵選擇Run,或者直接選擇類檔案直接右鍵Run;
- 執行一個目錄下的所有測試類:選擇這個目錄,右鍵Run。
執行前面測試驗證郵箱格式的例子,測試結果會在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列印的也會輸出。
5. 通過模擬框架模擬依賴,隔離依賴:
前面驗證郵件格式的例子,本地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],下一小節講開源框庫的時候會將這個例子改成本地本地測試。
常用單元測試開源庫
1. Mocktio
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));
}
}
複製程式碼
2. powermock
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();
}
複製程式碼
3. Robolectric
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));
}
}
複製程式碼
像本地一樣把它跑起來即可。
實踐經驗
1. 程式碼中用到了TextUtil.isEmpty()的如何測試
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;
}
}
複製程式碼
2. 隔離native方法
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());
}
}
複製程式碼
3. 在內部new,不方便Mock
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());
}
}
複製程式碼
從這個例子可以看出,程式碼的框架是否對單元測試友好,也是推進單元測試的一個因素。
4. 本地單元測試-檔案操作
在一些涉及到檔案讀寫的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);
}
}
複製程式碼
5. 一些測試心得
- 考慮可讀性:對於方法名使用表達能力強的方法名,對於測試正規化可以考慮使用一種規範, 如 RSpec-style。方法名可以採用一種格式,如: [測試的方法][測試的條件][符合預期的結果]。
- 不要使用邏輯流關鍵字:比如(If/else、for、do/while、switch/case),在一個測試方法中,如果需要有這些,拆分到單獨的每個測試方法裡。
- 測試真正需要測試的內容:需要覆蓋的情況,一般情況只考慮驗證輸出(如某操作後,顯示什麼,值是什麼)。
- 不需要考慮測試private的方法:將private方法當做黑盒內部元件,測試對其引用的public方法即可;不考慮測試瑣碎的程式碼,如getter或者setter。
- 每個單元測試方法,應沒有先後順序:儘可能的解耦對於不同的測試方法,不應該存在Test A與Test B存在時序性的情況。
文章給出的一些示例性程式碼片段中,有一些類程式碼沒有貼出來,有需要可到以下地址獲取完整程式碼: 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