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 是自 2010 年 1 月以来一直存在的开源 Android 测试自动化框架。值得一提的是,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 挂钩、工件版本控制以及管理大型移动应用程序项目的最佳实践。