Android 提供了强大的测试工具。这些工具继承于 JUnit 的同时扩展了额外的特性,提供更加便捷的系统对象 Mock 类,使用 instrumentation 操控被测应用。
整个 Android 测试环境在 Testing Fundamentals 文档里讨论过。
这篇指南为我们展示了一个简单的 Android 应用,并引导我们一步一步为它创建测试应用。通过遍历整个过程,为我们介绍了 Android 测试工具。
这个测试应用展示了以下几个关键点:
同时这个测试应用包含了一些方法,这些方法会执行以下的测试:
Building Your First App
。我们的被测应用来自于 Android SDK 里的面的样例代码 Spinner
, 如果你想对 Spinner
了解更多,你可能需要看下 Spinner
样例代码。Testing Fundamentals guide
。在这篇指南里,我们用的是 Android SDK 里面提供的样例代码 Spinner。你可以在 /samples/android-18/legacy/Spinner 找到被测应用的代码。同时,你可以在 /samples/android-18/legacy/SpinnerTest 里找到测试应用的代码。
我们将会一步一步创建 SpinnerTest,当然你也可以先看一遍代码,然后再回过头来看我们的指南。
在这篇指南里,我们会使用 Android 模拟器来运行应用。 我们需要一个 Android 虚拟机(AVD),这个 AVD API level 需要大于或者等于之前在项目里设置的。如果你不会创建 AVD 的话,先看看 Creating an AVD。
我们先导入 SpinnerActivity 项目:
SpinnerActivity
代码所在位置。 比如: <SDK_ROOT>/samples/android-18/legacy/Spinner
。Package Explorer 会列出代码的目录结构。
接下来,我们为 SpinnerActivity 项目生成测试项目:
(注意,由于 Eclipse 版本不同,可能步骤提示可能不同,随机应变吧。)
目前生成的测试项目,是一个空项目。 Eclipse 和 ADT 只是帮我们生成好了配置文件。我们主要看下 SpinnerActivityTest 底下的 AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.android.example.spinner.test"
android:versionCode="1"
android:versionName="1.0">
<uses-sdk android:minSdkVersion="3" />
<application android:label="@string/app_name">
<uses-library android:name="android.test.runner"/>
</application>
<instrumentation android:name="android.test.InstrumentationTestRunner"
android:targetPackage="com.android.example.spinner"
android:label="Tests for com.android.example.spinner"/>
</manifest>
Eclipse 已经帮我们注入了 Instrumentation。注意看 instrumentation 这个节点。通过 targetPackage 这个属性,Android 知道哪个是被测应用,并且如何启动被测程序。name 属性告诉 Android,当运行这个测试应用时,需要用 激活了 Instrumentation 的 TestRunner。
接下来,我们需要创建测试类。在这篇指南里,我们将创建一个测试类,包含:
Android 测试其实就是一个包含了一个或者多个测试类的特殊的应用。每一个测试类都有很多测试方法。我们先创建一个测试类,如何试着添加一些测试方法。
首先我们要选择一种 Android test case class 来继承。根据不同的 component,选择不同的 Android test case class 。这里我们测试 Activity,所以选择和 Activity 有关的。我们在真实环境中用的最多的是 ActivityInstrumentationTestCase2。 这个类提供了很多便捷的方法可以直接和 UI 交互。
java
public class SpinnerActivityTest extends ActivityInstrumentationTestCase2<SpinnerActivity> {
...
}
为了确保测试应用正确实例化了,你必须配置一个构造函数,test runner 在实例化测试类的时候,会调用这个构造函数。这个构造函数必须是无参的,它的主要作用就是把信息传递给父类的默认构造函数。看代码:
/*
* Constructor for the test class. Required by Android test classes. The constructor
* must call the super constructor, providing the Android package name of the app under test
* and the Java class name of the activity in that application that handles the MAIN intent.
*/
public SpinnerActivityTest() {
super("com.android.example.spinner", SpinnerActivity.class);
}
不过 super("com.android.example.spinner", SpinnerActivity.class);
这个已经 deprecated 了。 看代码:
/**
* Creates an {@link ActivityInstrumentationTestCase2}.
*
* @param pkg ignored - no longer in use.
* @param activityClass The activity to test. This must be a class in the instrumentation
* targetPackage specified in the AndroidManifest.xml
*
* @deprecated use {@link #ActivityInstrumentationTestCase2(Class)} instead
*/
@Deprecated
public ActivityInstrumentationTestCase2(String pkg, Class<T> activityClass) {
this(activityClass);
}
/**
* Creates an {@link ActivityInstrumentationTestCase2}.
*
* @param activityClass The activity to test. This must be a class in the instrumentation
* targetPackage specified in the AndroidManifest.xml
*/
public ActivityInstrumentationTestCase2(Class<T> activityClass) {
mActivityClass = activityClass;
}
setUp() 方法其实就是 JUnit 的 setUp() 方法。直接看代码,
/* 里面涉及的实例变量,假设已经存在了。 */
@Override
protected void setUp() throws Exception {
/*
* Call the super constructor (required by JUnit)
*/
super.setUp();
/*
* prepare to send key events to the app under test by turning off touch mode.
* Must be done before the first call to getActivity()
*/
setActivityInitialTouchMode(false);
/*
* Start the app under test by starting its main activity. The test runner already knows
* which activity this is from the call to the super constructor, as mentioned
* previously. The tests can now use instrumentation to directly access the main
* activity through mActivity.
*/
mActivity = getActivity();
/*
* Get references to objects in the application under test. These are
* tested to ensure that the app under test has initialized correctly.
*/
mSpinner = (Spinner)mActivity.findViewById(com.android.example.spinner.R.id.Spinner01);
mPlanetData = mSpinner.getAdapter();
}
我们在 setUp 方法里去拿一些数据,以便每次测试运行需要。值得关注的是 setActivityInitialTouchMode(false);
,如果要发送按键事件给被测应用的话,必须在开始任何 Activity 前关闭 touch 模式,否则发送会被忽略。看它的代码实现:
/**
* Call this method before the first call to {@link #getActivity} to set the initial touch
* mode for the Activity under test.
*
* <p>If you do not call this, the touch mode will be false. If you call this after
* your Activity has been started, it will have no effect.
*
* <p><b>NOTE:</b> Activities under test may not be started from within the UI thread.
* If your test method is annotated with {@link android.test.UiThreadTest}, then you must call
* {@link #setActivityInitialTouchMode(boolean)} from {@link #setUp()}.
*
* @param initialTouchMode true if the Activity should be placed into "touch mode" when started
*/
public void setActivityInitialTouchMode(boolean initialTouchMode) {
mInitialTouchMode = initialTouchMode;
}
事实上在 ActivityInstrumentationTestCase2 的 setUp 方法里,已经将 touchMode 设置为 false 了。
初始条件测试要验证:
看代码:
/*
* Tests the initial values of key objects in the app under test, to ensure the initial
* conditions make sense. If one of these is not initialized correctly, then subsequent
* tests are suspect and should be ignored.
*/
public void testPreconditions() {
/*
* An example of an initialization test. Assert that the item select listener in
* the main Activity is not null (has been set to a valid callback)
*/
assertTrue(mSpinner.getOnItemSelectedListener() != null);
/*
* Test that the spinner's backing mLocalAdapter was initialized correctly.
*/
assertTrue(mPlanetData != null);
/*
* Also ensure that the backing mLocalAdapter has the correct number of entries.
*/
assertEquals(mPlanetData.getCount(), ADAPTER_COUNT);
}
现在创建一个测试: 从 Spinner 插件里选择一个项目。这个测试会发送按键事件给 UI。我们要确保选择的项目是我们期盼的。
这个测试显示了 instrumentation 的威力。只有基于 instrumentation 的测试类才能发送按键事件 (触摸事件) 给被测应用。使用 instrumentation, 就可以不用通过截图,录制或者人工来测试 UI。
为了使用 Spinner,首先我们用 requestFocus()
和 setSelection()
取得焦点并默认选中一个。两个方法都是和 View 直接交互,所以我们需要用特殊的形式调用他们。
测试应用里面,直接和被测应用的 View 交互的代码,必须放在被测应用的线程(也叫 UI 线程)里。我们用 Activity.runOnUiThread()
方法。这个方法需要一个匿名的 Runnable
类作为参数。我们可以重写 Runnable
类的 run()
方法.
我们用 sendKeys()
方法发送按键事件给 UI。这个方法不需要在 UI 线程内运行,因为 Android 通过 instrumentation 把按键事件传递给被测应用。
我们来看代码:
/*
* Tests the UI of the main activity. Sends key events (keystrokes) to the UI, then checks
* if the resulting spinner state is consistent with the attempted selection.
*/
public void testSpinnerUI() {
/*
* Request focus for the spinner widget in the application under test,
* and set its initial position. This code interacts with the app's View
* so it has to run on the app's thread not the test's thread.
*
* To do this, pass the necessary code to the application with
* runOnUiThread(). The parameter is an anonymous Runnable object that
* contains the Java statements put in it by its run() method.
*/
/* 这里需要注意, 主要是因为 mSpinner 是一个 View。*/
mActivity.runOnUiThread(
new Runnable() {
public void run() {
mSpinner.requestFocus();
mSpinner.setSelection(INITIAL_POSITION);
}
}
);
// Activate the spinner by clicking the center keypad key
this.sendKeys(KeyEvent.KEYCODE_DPAD_CENTER);
// send 5 down arrow keys to the spinner
for (int i = 1; i <= TEST_POSITION; i++) {
this.sendKeys(KeyEvent.KEYCODE_DPAD_DOWN);
}
// select the item at the current spinner position
this.sendKeys(KeyEvent.KEYCODE_DPAD_CENTER);
// get the position of the selected item
mPos = mSpinner.getSelectedItemPosition();
/*
* from the spinner's data mLocalAdapter, get the object at the selected position
* (this is a String value)
*/
mSelection = (String)mSpinner.getItemAtPosition(mPos);
/*
* Get the TextView widget that displays the result of selecting an item from the spinner
*/
TextView resultView =
(TextView) mActivity.findViewById(com.android.example.spinner.R.id.SpinnerResult);
// Get the String value in the EditText object
String resultText = (String) resultView.getText();
/*
* Confirm that the EditText contains the same value as the data in the mLocalAdapter
*/
assertEquals(resultText,mSelection);
}
需要解释的一点是, sendKeys()
方法是来自 ActivityInstrumentationTestCase2 的父类的父类 InstrumentationTestCase
。 大致的实现是得到 instrumentation
,然后调用 instrumentation
的 sendKeyDownUpSync
方法,有兴趣的人可以去看下实现。
至此,一个简单的 UI 测试就写好了。大家可以试着运行下。
我们现在要写两个测试来验证 SpinnerActivity 暂停或者终止的时候是否会保持状态。在我们的例子里,状态就是 spinner 的当前选项。当用户选了一个项目,然后暂停或者关闭应用,然后在恢复或者重新打开应用,之前的那个项目应该还是选中的状态。
保持状态是应用的一个非常重要的特性。通常我们会遇到:
每种情况,最好的用户体验是当我们再回到这个 UI 时候,系统还为我们保持着离开时候的状态。
我们的被测应用 SpinnerActivity 是这样保持状态的:(具体实现可以看代码)
对于 Activity 而言, 隐藏就是 paused
, 重现就是 resume
。这是 Activity 生命周期中重要的知识点, Activity 类提供了两个回调方法:
SpinnerActivity 就用了这两个方法来保存和恢复状态。
#### 测试用例 1:整个应用关闭后重启,Spinner 的选项会被保持。
看代码:
/*
* Tests that the activity under test maintains the spinner state when the activity halts
* and then restarts (for example, if the device reboots). Sets the spinner to a
* certain state, calls finish() on the activity, restarts the activity, and then
* checks that the spinner has the same state.
*
*/
public void testStateDestroy() {
/*
* Set the position and value of the spinner in the Activity. The test runner's
* instrumentation enables this by running the test app and the main app in the same
* process.
*/
mActivity.setSpinnerPosition(TEST_STATE_DESTROY_POSITION);
mActivity.setSpinnerSelection(TEST_STATE_DESTROY_SELECTION);
// Halt the Activity by calling Activity.finish() on it
mActivity.finish();
// Restart the activity by calling ActivityInstrumentationTestCase2.getActivity()
mActivity = this.getActivity();
/*
* Get the current position and selection from the activity.
*/
int currentPosition = mActivity.getSpinnerPosition();
String currentSelection = mActivity.getSpinnerSelection();
// test that they are the same.
assertEquals(TEST_STATE_DESTROY_POSITION, currentPosition);
assertEquals(TEST_STATE_DESTROY_SELECTION, currentSelection);
}
#### 测试用例 2:Activity 暂停后恢复,Spinner 的选项会被保持。
看代码:
/*
* Tests that the activity under test maintains the spinner's state when the activity is
* paused and then resumed.
*
* Calls the activity's onResume() method. Changes the spinner's state by
* altering the activity's View. This means the test must run
* on the UI Thread. All the statements in the test method may be run on
* that thread, so instead of using the runOnUiThread() method, the
* @UiThreadTest is used.
*/
@UiThreadTest
public void testStatePause() {
/*
* Get the instrumentation object for this application. This object
* does all the instrumentation work for the test runner
*/
Instrumentation instr = this.getInstrumentation();
/*
* Set the activity's fields for the position and value of the spinner
*/
mActivity.setSpinnerPosition(TEST_STATE_PAUSE_POSITION);
mActivity.setSpinnerSelection(TEST_STATE_PAUSE_SELECTION);
/*
* Use the instrumentation to onPause() on the currently running Activity.
* This analogous to calling finish() in the testStateDestroy() method.
* This way demonstrates using the test class' instrumentation.
*/
instr.callActivityOnPause(mActivity);
/*
* Set the spinner to a test position
*/
mActivity.setSpinnerPosition(0);
mActivity.setSpinnerSelection("");
/*
* Call the activity's onResume() method. This forces the activity
* to restore its state.
*/
instr.callActivityOnResume(mActivity);
/*
* Get the current state of the spinner
*/
int currentPosition = mActivity.getSpinnerPosition();
String currentSelection = mActivity.getSpinnerSelection();
assertEquals(TEST_STATE_PAUSE_POSITION,currentPosition);
assertEquals(TEST_STATE_PAUSE_SELECTION,currentSelection);
}
需要注意的是,第二个测试使用了 @UiThreadTest 注释。这是因为 instr.callActivityOnResume(mActivity)
实际上调用了 SpinnerActivity 的 onResume 方法。而在这个 onResume 方法里,代码直接操纵了 View。
Spinner restoreSpinner = (Spinner)findViewById(R.id.Spinner01);
restoreSpinner.setSelection(getSpinnerPosition());
所有和 View 直接交互的代码,必须放在 UI 线程中执行。
至此,我们所有的测试代码都完成了。接下来就是运行和调试,就不细说了。想看详细的可以移步 Activity Testing。