Android 測試教程:像真正的綠色機器人一樣進行單元測試
已發表: 2022-03-11作為經驗豐富的應用程序開發人員,隨著我們開發的應用程序的成熟,我們有一種直覺,是時候開始測試了。 業務規則通常意味著系統必須在不同版本中提供穩定性。 理想情況下,我們還希望自動化構建過程並自動發布應用程序。 為此,我們需要適當的 Adnroid 測試工具來保證構建按預期工作。
測試可以為我們構建的東西提供額外的信心。 構建一個完美的、沒有錯誤的產品是困難的(如果不是不可能的話)。 因此,我們的目標是通過建立一個能夠快速發現我們應用程序中新引入的錯誤的測試套件來提高我們在市場上取得成功的機率。
對於 Android 以及一般的各種移動平台,應用程序測試可能是一個挑戰。 至少,實施單元測試並遵循測試驅動開發或類似原則通常會讓人感覺不直觀。 儘管如此,測試很重要,不應被視為理所當然或忽視。 David、Kent 和 Martin 在一篇題為“TDD 死了嗎?”的文章中進行了一系列對話,討論了測試的好處和陷阱。 如果測試適合您的開發過程,您還可以在那裡找到實際的視頻對話,並獲得更多洞察力,以及您可以在多大程度上整合它,從現在開始。
在這個 Android 測試教程中,我將引導您完成 Android 上的單元和驗收、回歸測試。 我們將專注於 Android 上測試單元的抽象,然後是驗收測試的示例,重點是使流程盡可能快速和簡單,以縮短開發人員-QA 的反饋週期。
我應該讀嗎?
本教程將探討測試 Android 應用程序的不同可能性。 想要更好地了解 Android 平台當前測試可能性的開發人員或項目經理可以決定是否使用本文中提到的任何方法。 然而,這不是靈丹妙藥,因為涉及此類主題的討論本質上因產品而異,以及截止日期、代碼的代碼庫質量、系統的耦合程度、開發人員在架構設計中的偏好、功能的預計壽命測試等
單元思考:Android 測試
理想情況下,我們希望獨立測試架構的一個邏輯單元/組件。 通過這種方式,我們可以保證我們的組件對於我們期望的輸入集正常工作。 可以模擬依賴項,這將使我們能夠編寫快速執行的測試。 此外,我們將能夠根據提供的測試輸入來模擬不同的系統狀態,涵蓋過程中的特殊情況。
Android 單元測試的目標是隔離程序的每個部分並顯示各個部分是正確的。 單元測試提供了一段代碼必須滿足的嚴格的書面契約。 因此,它提供了幾個好處。 ——維基百科
機器人電動
Robolectric 是一個 Android 單元測試框架,允許您在開發工作站上的 JVM 內運行測試。 Robolectric 會在加載 Android SDK 類時重寫它們,並使其能夠在常規 JVM 上運行,從而縮短測試時間。 此外,它還可以處理視圖膨脹、資源加載以及在 Android 設備上以原生 C 代碼實現的更多內容,從而不再需要模擬器和物理設備來運行自動化測試。
莫基托
Mockito 是一個模擬框架,使我們能夠在 java 中編寫乾淨的測試。 它簡化了創建測試替身(模擬)的過程,用於替換生產中使用的組件/模塊的原始依賴項。 StackOverflow 的答案以相當簡單的術語討論了模擬和存根之間的差異,您可以閱讀以了解更多信息。
// you can mock concrete classes, not only interfaces LinkedList mockedList = mock(LinkedList.class); // stubbing appears before the actual execution when(mockedList.get(0)).thenReturn("first"); // the following prints "first" System.out.println(mockedList.get(0)); // the following prints "null" because get(999) was not stubbed System.out.println(mockedList.get(999));
此外,使用 Mockito,我們可以驗證是否調用了方法:
// mock creation List mockedList = mock(List.class); // using mock object - it does not throw any "unexpected interaction" exception mockedList.add("one"); mockedList.clear(); // selective, explicit, highly readable verification verify(mockedList).add("one"); verify(mockedList).clear();
現在,我們知道我們可以指定動作-反應對來定義一旦我們對模擬對象/組件執行特定動作時會發生什麼。 因此,我們可以模擬我們應用程序的整個模塊,並且對於每個測試用例,使模擬的模塊以不同的方式做出反應。 不同的方式將反映測試組件和模擬組件對的可能狀態。
單元測試
在本節中,我們將假設 MVP(Model View Presenter)架構。 活動和片段是視圖,模型是調用數據庫或遠程服務的存儲庫層,演示者是將所有這些綁定在一起的“大腦”,實現特定邏輯來控制視圖、模型和通過應用。
抽象組件
模擬視圖和模型
在這個 Android 測試示例中,我們將模擬視圖、模型和存儲庫組件,並對演示者進行單元測試。 這是最小的測試之一,針對架構中的單個組件。 此外,我們將使用方法存根來建立適當的、可測試的反應鏈:
@RunWith(RobolectricTestRunner.class) @Config(manifest = "app/src/main/AndroidManifest.xml", emulateSdk = 18) public class FitnessListPresenterTest { private Calendar cal = Calendar.getInstance(); @Mock private IFitnessListModel model; @Mock private IFitnessListView view; private IFitnessListPresenter presenter; @Before public void setup() { MockitoAnnotations.initMocks(this); final FitnessEntry entryMock = mock(FitnessEntry.class); presenter = new FitnessListPresenter(view, model); /* Define the desired behaviour. Queuing the action in "doAnswer" for "when" is executed. Clear and synchronous way of setting reactions for actions (stubbing). */ doAnswer((new Answer<Object>() { @Override public Object answer(InvocationOnMock invocation) throws Throwable { ArrayList<FitnessEntry> items = new ArrayList<>(); items.add(entryMock); ((IFitnessListPresenterCallback) presenter).onFetchAllSuccess(items); return null; } })).when(model).fetchAllItems((IFitnessListPresenterCallback) presenter); } /** Verify if model.fetchItems was called once. Verify if view.onFetchSuccess is called once with the specified list of type FitnessEntry The concrete implementation of ((IFitnessListPresenterCallback) presenter).onFetchAllSuccess(items); calls the view.onFetchSuccess(...) method. This is why we verify that view.onFetchSuccess is called once. */ @Test public void testFetchAll() { presenter.fetchAllItems(false); // verify can be called only on mock objects verify(model, times(1)).fetchAllItems((IFitnessListPresenterCallback) presenter); verify(view, times(1)).onFetchSuccess(new ArrayList<>(anyListOf(FitnessEntry.class))); } }
使用 MockWebServer 模擬全局網絡層
能夠模擬全局網絡層通常很方便。 MockWebServer 允許我們對我們在測試中執行的特定請求的響應進行排隊。 這使我們有機會模擬我們期望從服務器獲得的模糊響應,但不容易重現。 它允許我們在編寫少量額外代碼的同時確保完全覆蓋。
MockWebServer 的代碼庫提供了一個簡潔的示例,您可以參考它來更好地理解這個庫。
自定義測試替身
您可以編寫自己的模型或 respoistory 組件並將其註入測試,方法是使用 Dagger (http://square.github.io/dagger/) 為對像圖提供不同的模塊。 我們可以選擇根據模擬模型組件提供的數據檢查視圖狀態是否正確更新:
/** Custom mock model class */ public class FitnessListErrorTestModel extends FitnessListModel { // ... @Override public void fetchAllItems(IFitnessListPresenterCallback callback) { callback.onError(); } @Override public void fetchItemsInRange(final IFitnessListPresenterCallback callback, DateFilter filter) { callback.onError(); } }
@RunWith(RobolectricTestRunner.class) @Config(manifest = "app/src/main/AndroidManifest.xml", emulateSdk = 18) public class FitnessListPresenterDaggerTest { private FitnessActivity activity; private FitnessListFragment fitnessListFragment; @Before public void setup() { /* setupActivity runs the Activity lifecycle methods on the specified class */ activity = Robolectric.setupActivity(FitnessActivity.class); fitnessListFragment = activity.getFitnessListFragment(); /* Create the objectGraph with the TestModule */ ObjectGraph localGraph = ObjectGraph.create(TestModule.newInstance(fitnessListFragment)); /* Injection */ localGraph.inject(fitnessListFragment); localGraph.inject(fitnessListFragment.getPresenter()); } @Test public void testInteractorError() { fitnessListFragment.getPresenter().fetchAllItems(false); /* suppose that our view shows a Toast message with the specified text below when an error is reported, so we check for it. */ assertEquals(ShadowToast.getTextOfLatestToast(), "Something went wrong!"); } @Module( injects = { FitnessListFragment.class, FitnessListPresenter.class }, overrides = true, library = true ) static class TestModule { private IFitnessListView view; private TestModule(IFitnessListView view){ this.view = view; } public static TestModule newInstance(IFitnessListView view){ return new TestModule(view); } @Provides public IFitnessListInteractor provideFitnessListInteractor(){ return new FitnessListErrorTestModel(); } @Provides public IFitnessListPresenter provideFitnessPresenter(){ return new FitnessListPresenter(view); } } }
運行測試
安卓工作室
您可以輕鬆地右鍵單擊測試類、方法或整個測試包,然後從 IDE 中的選項對話框運行測試。
終端
從終端運行 Android 應用程序測試會為目標模塊的“build”文件夾中的測試類創建報告。 更重要的是,如果您計劃設置自動構建過程,您將使用終端方法。 使用 Gradle,您可以通過執行以下命令來運行所有調試風格的測試:
gradle testDebug
從 Android Studio 版本訪問源集“測試”
Android Studio 1.1 版和 Android Gradle 插件支持對代碼進行單元測試。 您可以通過閱讀他們出色的文檔來了解更多信息。 該功能是實驗性的,但也是一個很好的包含,因為您現在可以輕鬆地從 IDE 在單元測試和儀器測試源集之間切換。 它的行為方式與您在 IDE 中切換風格相同。
簡化流程
編寫 Android 應用程序測試可能不如開發原始應用程序有趣。 因此,一些關於如何簡化編寫測試過程並在設置項目時避免常見問題的技巧將大有幫助。
斷言J Android
AssertJ Android,正如您可能從名稱中猜到的那樣,是一組基於 Android 構建的輔助函數。 它是流行庫 AssertJ 的擴展。 AssertJ Android 提供的功能範圍從簡單的斷言,例如“assertThat(view).isGone()”,到復雜的事情:
assertThat(layout).isVisible() .isVertical() .hasChildCount(4) .hasShowDividers(SHOW_DIVIDERS_MIDDLE)
借助 AssertJ Android 及其可擴展性,您可以確保為 Android 應用程序編寫測試提供一個簡單、良好的起點。
Robolectric 和清單路徑
在使用 Robolectric 時,您可能會注意到您必須指定清單位置,並且 SDK 版本設置為 18。您可以通過包含“Config”註釋來做到這一點。

@Config(manifest = "app/src/main/AndroidManifest.xml", emulateSdk = 18)
從終端運行需要 Robolectric 的測試可能會帶來新的挑戰。 例如,您可能會看到“未設置主題”之類的例外情況。 如果測試可以從 IDE 正確執行,但不能從終端執行,則您可能嘗試從無法解析指定清單路徑的終端路徑運行它。 清單路徑的硬編碼配置值可能未從命令執行點指向正確的位置。 這可以通過使用自定義運行器來解決:
public class RobolectricGradleTestRunner extends RobolectricTestRunner { public RobolectricGradleTestRunner(Class<?> testClass) throws InitializationError { super(testClass); } @Override protected AndroidManifest getAppManifest(Config config) { String appRoot = "../app/src/main/"; String manifestPath = appRoot + "AndroidManifest.xml"; String resDir = appRoot + "res"; String assetsDir = appRoot + "assets"; AndroidManifest manifest = createAppManifest(Fs.fileFromPath(manifestPath), Fs.fileFromPath(resDir), Fs.fileFromPath(assetsDir)); return manifest; } }
搖籃配置
您可以使用以下內容配置 Gradle 以進行單元測試。 您可能需要根據項目需要修改所需的依賴項名稱和版本。
// Robolectric testCompile 'junit:junit:4.12' testCompile 'org.mockito:mockito-core:1.9.5' testCompile 'com.squareup.dagger:dagger:1.2.2' testProvided 'com.squareup.dagger:dagger-compiler:1.2.2' testCompile 'com.android.support:support-v4:21.0.+' testCompile 'com.android.support:appcompat-v7:21.0.3' testCompile('org.robolectric:robolectric:2.4') { exclude module: 'classworlds' exclude module: 'commons-logging' exclude module: 'httpclient' exclude module: 'maven-artifact' exclude module: 'maven-artifact-manager' exclude module: 'maven-error-diagnostics' exclude module: 'maven-model' exclude module: 'maven-project' exclude module: 'maven-settings' exclude module: 'plexus-container-default' exclude module: 'plexus-interpolation' exclude module: 'plexus-utils' exclude module: 'wagon-file' exclude module: 'wagon-http-lightweight' exclude module: 'wagon-provider-api' }
Robolectric 和遊戲服務
如果您使用的是 Google Play 服務,則必須為 Play 服務版本創建自己的整數常量,以便 Robolectric 在此應用程序配置中正常工作。
<meta-data android:name="com.google.android.gms.version" android:value="@integer/gms_version" tools:replace="android:value" />
支持庫的 Robolectric 依賴項
另一個有趣的測試問題是 Robolectric 無法正確引用支持庫。 解決方案是將“project.properties”文件添加到測試所在的模塊中。 例如,對於 Support-v4 和 AppCompat 庫,該文件應包含:
android.library.reference.1=../../build/intermediates/exploded-aar/com.android.support/support-v4/21.0.3 android.library.reference.2=../../build/intermediates/exploded-aar/com.android.support/appcompat-v7/21.0.3
驗收/回歸測試
驗收/回歸測試在真實的 100% Android 環境中自動完成測試的最後一步。 我們在這個級別不使用模擬的 Android OS 類 - 測試在真實設備和模擬器上運行。
由於物理設備、仿真器配置、設備狀態和每個設備的功能集的多樣性,這些情況使該過程更加不穩定。 此外,它高度依賴於操作系統的版本和手機的屏幕尺寸來決定內容的顯示方式。
創建在各種設備上通過的正確測試有點複雜,但與往常一樣,您應該夢想遠大,從小處著手。 使用 Robotium 創建測試是一個迭代過程。 通過一些技巧,它可以簡化很多。
機器人館
Robotium 是一個開源的 Android 測試自動化框架,自 2010 年 1 月以來一直存在。值得一提的是,Robotium 是一種付費解決方案,但提供公平的免費試用。
為了加快編寫 Robotium 測試的過程,我們將從手動編寫測試轉向測試記錄。 權衡是在代碼質量和速度之間。 如果您對用戶界面進行重大更改,您將從測試記錄方法中受益匪淺,並且能夠快速記錄新測試。
Testdroid Recorder 是一個免費的測試記錄器,它在記錄您在用戶界面上執行的點擊時創建 Robotium 測試。 安裝該工具非常簡單,如他們的文檔中所述,並附有分步視頻。
由於 Testdroid Recorder 是一個 Eclipse 插件,並且我們在本文中都提到了 Android Studio,因此理想情況下這將是一個值得關注的原因。 但是,在這種情況下,這不是問題,因為您可以直接將插件與 APK 一起使用並記錄針對它的測試。
創建測試後,您可以將它們與 Testdroid 記錄器所需的任何依賴項一起復制並粘貼到 Android Studio 中,然後就可以開始了。 記錄的測試看起來像下面的類:
public class LoginTest extends ActivityInstrumentationTestCase2<Activity> { private static final String LAUNCHER_ACTIVITY_CLASSNAME = "com.toptal.fitnesstracker.view.activity.SplashActivity"; private static Class<?> launchActivityClass; static { try { launchActivityClass = Class.forName(LAUNCHER_ACTIVITY_CLASSNAME); } catch (ClassNotFoundException e) { throw new RuntimeException(e); } } private ExtSolo solo; @SuppressWarnings("unchecked") public LoginTest() { super((Class<Activity>) launchActivityClass); } // executed before every test method @Override public void setUp() throws Exception { super.setUp(); solo = new ExtSolo(getInstrumentation(), getActivity(), this.getClass() .getCanonicalName(), getName()); } // executed after every test method @Override public void tearDown() throws Exception { solo.finishOpenedActivities(); solo.tearDown(); super.tearDown(); } public void testRecorded() throws Exception { try { assertTrue( "Wait for edit text (id: com.toptal.fitnesstracker.R.id.login_username_input) failed.", solo.waitForEditTextById( "com.toptal.fitnesstracker.R.id.login_username_input", 20000)); solo.enterText( (EditText) solo .findViewById("com.toptal.fitnesstracker.R.id.login_username_input"), "[email protected]"); solo.sendKey(ExtSolo.ENTER); solo.sleep(500); assertTrue( "Wait for edit text (id: com.toptal.fitnesstracker.R.id.login_password_input) failed.", solo.waitForEditTextById( "com.toptal.fitnesstracker.R.id.login_password_input", 20000)); solo.enterText( (EditText) solo .findViewById("com.toptal.fitnesstracker.R.id.login_password_input"), "123456"); solo.sendKey(ExtSolo.ENTER); solo.sleep(500); assertTrue( "Wait for button (id: com.toptal.fitnesstracker.R.id.parse_login_button) failed.", solo.waitForButtonById( "com.toptal.fitnesstracker.R.id.parse_login_button", 20000)); solo.clickOnButton((Button) solo .findViewById("com.toptal.fitnesstracker.R.id.parse_login_button")); assertTrue("Wait for text fitness list activity.", solo.waitForActivity(FitnessActivity.class)); assertTrue("Wait for text KM.", solo.waitForText("KM", 20000)); /* Custom class that enables proper clicking of ActionBar action items */ TestUtils.customClickOnView(solo, R.id.action_logout); solo.waitForDialogToOpen(); solo.waitForText("OK"); solo.clickOnText("OK"); assertTrue("waiting for ParseLoginActivity after logout", solo.waitForActivity(ParseLoginActivity.class)); assertTrue( "Wait for button (id: com.toptal.fitnesstracker.R.id.parse_login_button) failed.", solo.waitForButtonById( "com.toptal.fitnesstracker.R.id.parse_login_button", 20000)); } catch (AssertionFailedError e) { solo.fail( "com.example.android.apis.test.Test.testRecorded_scr_fail", e); throw e; } catch (Exception e) { solo.fail( "com.example.android.apis.test.Test.testRecorded_scr_fail", e); throw e; } } }
如果你仔細觀察,你會注意到有多少代碼是相當直接的。
記錄測試時,不要少用“等待”語句。 等待對話框出現、活動出現、文本出現。 這將保證當您在當前屏幕上執行操作時,活動和視圖層次結構已準備好與之交互。 同時,截圖。 自動化測試通常是無人值守的,屏幕截圖是您了解這些測試期間實際發生的情況的一種方式。
無論測試通過還是失敗,報告都是您最好的朋友。 您可以在構建目錄“module/build/outputs/reports”下找到它們:
理論上,QA 團隊可以記錄測試並對其進行優化。 通過致力於優化測試用例的標準化模型,可以做到這一點。 當您通常記錄測試時,您總是需要調整一些事情以使其完美運行。
最後,要從 Android Studio 運行這些測試,您可以選擇它們並像運行單元測試一樣運行。 從碼頭出發,這是一條單線:
gradle connectedAndroidTest
測試性能
使用 Robolectric 進行 Android 單元測試非常快,因為它直接在您機器上的 JVM 中運行。 與此相比,仿真器和物理設備的驗收測試要慢得多。 根據您正在測試的流的大小,每個測試用例可能需要幾秒鐘到幾分鐘的時間。 驗收測試階段應作為持續集成服務器上自動構建過程的一部分。
通過在多個設備上並行化可以提高速度。 從 Jake Wharton 和 Square http://square.github.io/spoon/ 的人那裡查看這個很棒的工具。 它也有一些不錯的報告。
外賣
有多種 Android 測試工具可供使用,隨著生態系統的成熟,設置可測試環境和編寫測試的過程將變得更加容易。 還有更多的挑戰需要解決,並且由於有大量開發人員社區致力於解決日常問題,因此有很大的建設性討論和快速反饋的空間。
使用本 Android 測試教程中描述的方法來指導您應對擺在您面前的挑戰。 如果您遇到問題,請查看本文或其中鏈接的參考資料,了解已知問題的解決方案。
在以後的文章中,我們將更深入地討論並行化、構建自動化、持續集成、Github/BitBucket 掛鉤、工件版本控制以及管理大型移動應用程序項目的最佳實踐。