本文概述
作为经验丰富的应用程序开发人员, 随着我们开发的应用程序的成熟, 我们会感到直觉, 是时候开始进行测试了。业务规则通常暗示系统必须在不同版本中提供稳定性。理想情况下, 我们还希望自动化构建过程并自动发布应用程序。为此, 我们需要适当的Adnroid测试工具, 以确保构建工作按预期进行。
测试可以使我们对构建的东西充满信心。很难(如果不是不可能)构建一个完美的, 没有错误的产品。因此, 我们的目标是通过建立一个测试套件来提高我们在市场上成功的几率, 该套件将在我们的应用程序中快速发现新引入的错误。
当涉及到Android以及各种移动平台时, 应用程序测试可能是一个挑战。至少, 实施单元测试和遵循测试驱动开发或类似原则的做法通常会感觉不直观。不过, 测试很重要, 不应视作理所当然或忽略。 David, Kent和Martin在题为” TDD是否已死?”的文章中进行的一系列相互讨论中, 讨论了测试的好处和陷阱。从现在开始, 你还可以在此处找到实际的视频对话, 并获得更多的见解, 以了解测试是否适合你的开发过程以及你可以在多大程度上整合它。
在本Android测试教程中, 我将引导你完成Android的单元和验收, 回归测试。我们将重点介绍Android上测试单元的抽象, 然后是验收测试的示例, 重点是使过程尽可能快速和简单, 以缩短开发人员QA反馈周期。
我应该读吗?
本教程将探讨测试Android应用程序时的各种可能性。希望更好地了解Android平台当前测试可能性的开发人员或项目经理可以决定是否使用本文中提到的任何方法来使用本教程。但是, 这并不是灵丹妙药, 因为涉及该主题的讨论会因产品, 期限, 代码库质量, 系统耦合程度, 开发人员对体系结构设计的偏爱, 功能的预期使用寿命而固有地有所不同。测试等
单元思考:Android测试
理想情况下, 我们要独立测试体系结构的一个逻辑单元/组件。这样, 我们可以保证我们的组件对于我们期望的一组输入能够正常工作。可以对依赖关系进行模拟, 这将使我们能够编写能够快速执行的测试。此外, 我们将能够基于提供给测试的输入来模拟不同的系统状态, 涵盖过程中的特殊情况。
Android单元测试的目的是隔离程序的每个部分, 并证明各个部分是正确的。单元测试提供了一段代码必须满足的严格的书面合同。结果, 它具有几个好处。 —维基百科
robolectric
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(模型视图演示器)体系结构。活动和片段是视图, 模型是用于调用数据库或远程服务的存储库层, 而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的代码存储库提供了一个简洁的示例, 你可以参考该示例以更好地理解该库。
定制测试双打
你可以编写自己的模型或存储组件, 并使用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);
}
}
}
运行测试
Android Studio
你可以轻松地右键单击测试类, 方法或整个测试包, 然后从IDE中的选项对话框中运行测试。
终奌站
从终端运行Android应用程序测试会在目标模块的” build”文件夹中为已测试类创建报告。甚至, 如果你打算设置自动构建过程, 那么将使用终端方法。使用Gradle, 你可以通过执行以下命令来运行所有调试调试的测试:
gradle testDebug
从Android Studio版本访问源集”测试”
Android Studio的1.1版和Android Gradle插件支持对代码进行单元测试。你可以阅读他们的出色文档来了解更多信息。该功能是实验性的, 但也包含很多内容, 因为你现在可以从IDE轻松地在单元测试和仪器测试源集之间进行切换。它的行为与你在IDE中切换样式的行为相同。
简化流程
编写Android应用程序测试可能不如开发原始应用程序那么有趣。因此, 有关如何简化编写测试过程以及在设置项目时避免常见问题的一些技巧将大有帮助。
AssertJ Android
顾名思义, AssertJ Android是一组在考虑Android的情况下构建的辅助函数。它是流行的AssertJ库的扩展。 Android的AssertJ提供的功能从简单的断言(例如” 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'
}
机器人和游戏服务
如果你使用的是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.srcmini.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.srcmini.fitnesstracker.R.id.login_username_input) failed.", solo.waitForEditTextById(
"com.srcmini.fitnesstracker.R.id.login_username_input", 20000));
solo.enterText(
(EditText) solo
.findViewById("com.srcmini.fitnesstracker.R.id.login_username_input"), "[email protected]");
solo.sendKey(ExtSolo.ENTER);
solo.sleep(500);
assertTrue(
"Wait for edit text (id: com.srcmini.fitnesstracker.R.id.login_password_input) failed.", solo.waitForEditTextById(
"com.srcmini.fitnesstracker.R.id.login_password_input", 20000));
solo.enterText(
(EditText) solo
.findViewById("com.srcmini.fitnesstracker.R.id.login_password_input"), "123456");
solo.sendKey(ExtSolo.ENTER);
solo.sleep(500);
assertTrue(
"Wait for button (id: com.srcmini.fitnesstracker.R.id.parse_login_button) failed.", solo.waitForButtonById(
"com.srcmini.fitnesstracker.R.id.parse_login_button", 20000));
solo.clickOnButton((Button) solo
.findViewById("com.srcmini.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.srcmini.fitnesstracker.R.id.parse_login_button) failed.", solo.waitForButtonById(
"com.srcmini.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”下找到它们:
从理论上讲, 质量检查团队可以记录测试并对其进行优化。通过将精力放在用于优化测试用例的标准化模型中, 就可以做到。通常情况下, 记录测试时, 你始终必须进行一些调整以使其完美运行。
最后, 要从Android Studio运行这些测试, 可以选择它们并像运行单元测试一样运行。从终端开始, 它是单线的:
gradle connectedAndroidTest
测试性能
使用Robolectric进行Android单元测试非常快, 因为它直接在计算机上的JVM中运行。相比之下, 在仿真器和物理设备上的验收测试要慢得多。根据要测试的流的大小, 每个测试用例可能要花费几秒钟到几分钟的时间。验收测试阶段应用作连续集成服务器上自动构建过程的一部分。
通过在多个设备上并行化可以提高速度。从Jake Wharton和Square http://square.github.io/spoon/的家伙那里查看这个出色的工具。它也有一些不错的报告。
本文总结
有各种各样的Android测试工具可用, 随着生态系统的成熟, 设置可测试环境和编写测试的过程将变得更加容易。还有更多的挑战需要解决, 并且有大量的开发人员在处理日常问题, 因此有大量的空间可以进行建设性的讨论和快速反馈。
使用本Android测试教程中介绍的方法来指导你应对摆在面前的挑战。如果并且当你遇到问题时, 请查阅本文或其中链接的参考, 以获取已知问题的解决方案。
在以后的文章中, 我们将讨论并行化, 构建自动化, 持续集成, Github / BitBucket挂钩, 工件版本控制以及用于更深入地管理大型移动应用程序项目的最佳实践。