一提起安卓自动化测试,大多数人第一反应都是Appium,提到Robotium反而像是在翻老黄历。但实际做过原生应用UI自动化的人都知道,Robotium在纯Android项目里从来没真正过气——它基于Instrumentation运行,能直接拿到应用里的View对象,没有WebDriver那层通信开销,跑起来又快又稳。这篇是《Robotium 安卓自动化测试》系列的第一篇,我会从零开始把一个可运行、能出报告的自动化测试工程完整搭起来,把环境配置、签名问题、Solo核心用法和第一个真实用例全部走一遍。适合刚接触UI自动化、被Appium的配置折腾到头秃的新手,也适合老项目需要快速补自动化回归体系、但不想引入重型框架的团队。
1. 为什么这个“过气”框架还值得系统学一遍
1.1 Robotium到底是什么
Robotium是Jayway公司开源的一套安卓UI自动化测试框架,底层基于Android官方测试框架里的Instrumentation机制。简单说,它和被测应用运行在同一个进程里,通过Instrumentation注入事件、查找控件,直接操作真实的Activity、View层级。
这话听起来有点抽象。我用一个类比解释:Appium做自动化像“隔着窗户指挥别人操作电脑”,客户端通过HTTP协议发指令,中间还有一层代理转发,能跨平台、支持多语言,但通信和等待的代价是速度慢、定位元素绕。Robotium则是“直接坐到电脑前自己去动手”,它跟被测App在同一个进程内跑,拿到的是内存里真实的控件对象,操作粒度细、响应快。
Robotium暴露出来的核心类叫Solo,测试里绝大多数操作都通过solo来发起。比如solo.clickOnText("登录")、solo.enterText(0, "admin"),这些API看着简单,背后却内置了一套复杂的控件查找机制,它会遍历当前Window上的View树,按文本、ID、Description等条件去做匹配。
1.2 什么时候选它,什么时候果断走开
我在实际项目里接触过不少团队,一上来就纠结用哪个框架。这里先明确一个结论:没有最好的框架,只有最合适的使用场景。选Robotium还是Appium,看我总结的这张对比表:
| 维度 | Robotium | Appium |
|---|---|---|
| 支持平台 | 仅Android | Android + iOS |
| 底层机制 | Instrumentation进程内 | WebDriver协议跨进程 |
| 定位方式 | 文本、ID、Index、View对象 | ID、XPath、AccessibilityId |
| 执行速度 | 快,无网络通信 | 相对慢,受协议和等待策略影响 |
| 脚本语言 | Java为主 | 多语言(Java/Python/JS等) |
| WebView支持 | 组件自带WebElement支持 | 通过context切换,较成熟 |
| 学习成本 | 低,API直观 | 中高,前期环境配置复杂 |
| 适合场景 | 纯Android原生/轻Hybrid | 跨平台、大型服务化框架 |
如果你的被测项目是纯Android原生应用,甚至有源码可以直接编译出测试包,Robotium是性价比极高的选择。它在小步快跑的场景里特别实用:不用起服务、不用配Desired Capabilities,直接把androidTest工程配好,一条Gradle命令就能跑完整条回归链路。
如果要求跨平台(Android、iOS都要覆盖),或者测试团队主要写Python而不是Java,那就不要硬上Robotium,直接考虑Appium这类方案。工具只是手段,别为了框架去迁就业务,这是我一直坚持的原则。
2. 项目搭建:一个干净RoboTest模块的诞生过程
2.1 项目结构与依赖引入
Robotium官方版本虽然很多年没更新了,但在AndroidX时代依然能用。推荐直接在项目的app模块下创建androidTest目录来存放测试代码,这样既能复用主工程的依赖,又能避免单独维护一个测试App的繁琐配置。
标准目录结构是这样:
MyProject/ ├── app/ │ ├── src/ │ │ ├── main/ // 被测应用源码 │ │ │ └── AndroidManifest.xml │ │ └── androidTest/ // 自动化测试代码目录 │ │ ├── java/com/example/test/ │ │ │ ├── LoginTest.java │ │ │ └── CommonSetup.java │ │ └── AndroidManifest.xml // 测试专用Manifest在app/build.gradle里添加依赖:
android { defaultConfig { // 指定使用Instrumentation测试运行器 testInstrumentationRunner "android.test.InstrumentationTestRunner" } } dependencies { androidTestImplementation 'com.jayway.android.robotium:robotium-solo:5.6.3' }2.2 AndroidManifest中必须写对的三处配置
测试工程的AndroidManifest.xml里,有三个地方极其关键,少写一个都会让测试直接跑不起来。
第一是instrumentation节点,它告诉系统本次测试注入的目标应用是哪个。第二是uses-library,声明需要用到的测试库。第三是targetPackage的包名,必须与主工程manifest中的package完全一致,不清楚就直接去主工程文件里复制。
<?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example.app.test"> <instrumentation android:name="android.test.InstrumentationTestRunner" android:targetPackage="com.example.app" android:functionalTest="false" android:handleProfiling="false" /> <application> <uses-library android:name="android.test.runner" /> </application> </manifest>但这里有个细节容易被忽略:Android Studio在构建androidTest时会自动合并测试工程的Manifest到最终的测试APK里,如果你在Gradle里已经指定了testInstrumentationRunner,instrumentation节点其实可以省略。不过在无Gradle纯命令行构建的老项目里,这个文件还是必不可少的。我建议这两处都写上,双保险。
2.3 签名问题:绕不开的第一道坎
Instrumentation机制要求测试APK与目标APK使用相同的签名。这是无数新手在起步阶段卡住的最主要原因。
如果用Android Studio直接通过connectedAndroidTest运行,IDE会使用默认的debug签名同时构建主APK和测试APK,所以通常不会出问题。一旦切到Release包测试,或者拿到的是别人打好的正式包,必须用debug签名给被测APK重新签名,或者用正式签名去签测试包。我提供几种常见的做法:
签同一个keystore,最简单的是在build.gradle里配置:
android { signingConfigs { autoTest { storeFile file("debug.keystore") storePassword "android" keyAlias "androiddebugkey" keyPassword "android" } } buildTypes { debug { signingConfig signingConfigs.autoTest } } }如果没有被测APK的源码和签名信息,可以先用apksigner工具或者Android Studio自带的Build菜单里的Generate Signed APK,把目标APK重新签成与测试APK相同的证书,再安装运行。
这个坑我在第三章会展开讲一个具体报错的完整排查过程。现在先把注意力放回工程本身,因为我们马上要写第一行测试代码了。
3. Solo类的使用心法:定位、点击与等待
3.1 如何正确拿到Solo对象
在Robotium里没有@Before这种注解,测试类需要继承ActivityInstrumentationTestCase2,然后在setUp()生命周期中创建Solo实例。写成这样:
public class LoginTest extends ActivityInstrumentationTestCase2<MainActivity> { private Solo solo; public LoginTest() { super(MainActivity.class); } @Override protected void setUp() throws Exception { super.setUp(); solo = new Solo(getInstrumentation(), getActivity()); } @Override protected void tearDown() throws Exception { // 清理当前打开的Activity,避免影响下一个用例 solo.finishOpenedActivities(); super.tearDown(); } }这里要解释一个关键理由:ActivityInstrumentationTestCase2的构造函数必须指定一个入口Activity类型。setUp()里调用getActivity()会按照Android标准的Intent启动流程去创建这个Activity。Solo在构造时,会把当前Activity的WindowManager和View树的根节点全部索引到内部缓存中,后续所有控件查找都基于这份初始快照持续维护。
有个细节必须注意:setUp()里如果先super.setUp()再getActivity(),此时Activity已经启动了。不要在getActivity()之前去初始化Solo,否则它会因为找不到当前窗口而抛异常。
3.2 点击与输入API的适用范围
Solo提供了几十个以click和enter开头的方法,整体分为几大类。我最常用的是这些:
| 方法 | 作用 | 适用场景 |
|---|---|---|
clickOnText(String) | 按文本点击 | 点击按钮、菜单项、链接 |
clickOnButton(String/Index) | 按按钮属性点击 | Button类控件 |
clickOnView(View) | 直接点击View对象 | 配合getView()精确定位 |
clickInList(int) | 点击列表项 | ListView/RecyclerView低版本兼容 |
enterText(int index, String) | 按索引输入 | 多输入框页面的快速输入 |
enterTextInWebElement | 输入到WebView元素 | 混合应用页面 |
实际执行时,这些方法内部都会先发动一遍“搜索”,默认从当前Activity上的所有View里遍历匹配条件,找到候选后再取第一个可操作的对象执行点击。所以它定位控件更像是在当前可见UI里“找”,而不是在层级树里“查”,这跟Web自动化的XPath是完全不同的思路。
举个例子:
// 点击一个文本为“登录”的按钮 solo.clickOnText("登录"); // 在某一张表单里,按View索引输入用户名(索引从0开始) solo.enterText(0, "test_user"); solo.enterText(1, "123456"); // 点击文本为“立即注册”的TextView solo.clickOnText("立即注册");3.3 等待类API背后的轮询逻辑
写自动化最怕的就是异步问题,页面加载、网络请求、动画切换,都会让控件在某一时刻还没出现在屏幕上。新手很容易直接写Thread.sleep(2000)来等,这在演示脚本里凑合,落到真实回归里就是埋雷。
Robotium专门提供waitFor系列方法,底层是轮询而不是简单sleep。比如waitForText(String, int, long),会在指定超时时间内,每隔一段时间去检查View树里能否找到目标文本:
assertTrue(solo.waitForText("登录失败", 1, 3000));waitForText返回布尔值,查到就返回true并立即结束,查不到会一直轮询到超时。我习惯把所有跟网络加载、页面跳转相关的断言都包上waitFor,既比固定sleep稳定,又能提前暴露异常。
waitForActivity也是硬需求,它用于等待某个完全新的Activity出现在前台,经常跟clickOnText之后配合使用:
solo.clickOnText("注册"); assertTrue(solo.waitForActivity("RegisterActivity", 5000));还有waitForDialogToClose、waitForView等方法,各自的语义都是“在这个时间段内不断尝试”。掌握了这套等待机制,你对整个框架的执行节奏就有了掌控感。
4. 第一个完整用例:登录流程的自动化脚本
4.1 测试目标与用例拆分
现在动手做一个真正能落地的用例。我们的被测应用是一个标准登录页,有用户名输入框、密码输入框、登录按钮。登录成功后跳转到HomeActivity,页面上会出现文本“欢迎回来”。
针对这个需求,我拆成三个测试方法:
testLoginSuccess:正确用户名密码登录,验证进入首页。testLoginWithEmptyPassword:密码为空时,验证提示“密码不能为空”。testWrongPasswordThenRetry:先输错密码再正确登录,模拟用户正常纠错流程。
每一条用例在tearDown()里都会执行临时的Activity清理,所以用例之间不会互相干扰。
4.2 完整测试类的逐步实现
需要说明一个实战细节:MainActivity是启动入口,但如果测试工程构建时无法直接引用主模块的Activity类,可以把MainActivity替换成被测应用的入口类完整类名,甚至是某个非入口Activity,只要它能通过Intent正常启动即可。
package com.example.test; import android.test.ActivityInstrumentationTestCase2; import com.example.app.MainActivity; import com.robotium.solo.Solo; public class LoginTest extends ActivityInstrumentationTestCase2<MainActivity> { private Solo solo; public LoginTest() { super(MainActivity.class); } @Override protected void setUp() throws Exception { super.setUp(); solo = new Solo(getInstrumentation(), getActivity()); } @Override protected void tearDown() throws Exception { solo.finishOpenedActivities(); super.tearDown(); } // 用例1:正确登录 public void testLoginSuccess() { solo.waitForActivity("MainActivity", 3000); solo.enterText(0, "admin"); solo.enterText(1, "123456"); solo.clickOnText("登录"); assertTrue("登录后未进入首页", solo.waitForActivity("HomeActivity", 5000)); assertTrue("首页未显示欢迎文案", solo.waitForText("欢迎回来", 1, 3000)); } // 用例2:空密码校验 public void testLoginWithEmptyPassword() { solo.waitForActivity("MainActivity", 3000); solo.enterText(0, "admin"); solo.clickOnText("登录"); assertTrue("空密码未提示错误", solo.waitForText("密码不能为空", 1, 3000)); } // 用例3:输错密码后重新输对 public void testWrongPasswordThenRetry() { solo.waitForActivity("MainActivity", 3000); solo.enterText(0, "admin"); solo.enterText(1, "wrongpass"); solo.clickOnText("登录"); assertTrue("首次错误登录未提示失败", solo.waitForText("用户名或密码错误", 1, 3000)); // 清空两个输入框再输入正确内容 solo.clearEditText(0); solo.clearEditText(1); solo.enterText(0, "admin"); solo.enterText(1, "123456"); solo.clickOnText("登录"); assertTrue("重试后未进入首页", solo.waitForActivity("HomeActivity", 5000)); } }这中间有几个值得解释的处理:
enterText(0, ...)里的0和1对应当前Activity里可见输入框的查找顺序。Robotium是按View树中的Index顺序来查找输入控件的,不是按页面的上下位置。这种写法简短,但风险在于页面结构一旦调整,顺序就变了。更稳妥的做法是用getView("input_username")拿到控件再操作:
EditText usernameEdit = (EditText) solo.getView("input_username"); solo.enterText(usernameEdit, "admin");但按ID定位有个前提:被测应用的控件ID没有被混淆。所以我在实际写脚本时,一般对正式包用文本定位兜底,对源码可控的测试包用ID定位优先。
clearEditText这个方法本身也值得留意,它会把EditText里的内容一清到底,随后光标位置自动归零,不需要额外调用clickOnView去聚焦。
4.3 用Gradle和adb两种方式跑起来
在Android Studio里,直接右键测试类,选择Run 'LoginTest',IDE会自动构建并部署应用和测试包,输出日志到Run窗口。命令行方式则适合CI流水线:
# 全量跑指定模块的所有androidTest用例 ./gradlew connectedDebugAndroidTest # 只跑某个类 ./gradlew connectedDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.example.test.LoginTest如果是纯adb环境,手动安装和运行也可以:
adb install -r app-debug.apk adb install -r app-debug-androidTest.apk adb shell am instrument -w com.example.app.test/android.test.InstrumentationTestRunner-r这个参数表示覆盖安装保留数据。跑完之后控制台会输出OK (3 tests)之类的统计,每条用例的耗时、执行状态都会打出来。
5. 踩坑实录:三个高频问题的完整排查链路
5.1 签名不一致导致测试无法注入
有一次我给客户做回归测试,拿到的是对方单独签名的release APK。直接在设备上安装测试包后运行,日志里出现:
E AndroidRuntime: java.lang.SecurityException: Permission Denial: starting instrumentation ComponentInfo{com.example.app.test/...} from pid=1234, uid=11234 not allowed because package com.example.app.test does not belong to uid 11234这个报错的根因就是签名不一致,系统不允许一个属于不同证书的测试进程往你的应用进程里注入。
我当时没有对方的keystore,解决方法是先用apksigner把release包重签为debug证书。过程不复杂:
# 给release.apk去掉旧签名并重新签名 apksigner sign --ks ~/.android/debug.keystore \ --ks-key-alias androiddebugkey \ --ks-pass pass:android \ --key-pass pass:android \ --out app-resigned.apk app-release-unsigned.apk adb install -r app-resigned.apk重装后测试包直接就能跑通了。后来在项目里我干脆做了个约定:凡是需要自动化回归的包,一律统一使用同一个测试专用keystore签名,从源头杜绝这个问题。如果你身边也常有这种对接场景,建议把签名校验这套流程写到CI的构建脚本里,别让手工背锅。
5.2 waitForText永远超时却没找到原因
另一个经常遇到的坑是:明明界面上能看到那行字,solo.waitForText("密码不能为空", 1, 3000)却返回false,导致用例报错。我排查这种问题时,第一个动作是抓一张屏幕快照,而不是改代码。
排查思路顺着三条链走:
- 当前页面是不是被测应用内的页面?有可能是系统弹窗或输入法弹层遮住了控件,Robotium默认情况只在应用上下文里找View。
- 提示文本是不是真的渲染成了
TextView?如果是Toast弹窗,出现到消失的时间窗口非常短,而且它不常驻在View树里,waitForText从设计上就不太适合去捕获Toast。 - 文本里有没有不可见字符?比如全角空格、前后空格、占位符,肉眼看着一样,程序对比时就是匹配不上。
第三条尤其迷惑人。我后来学乖了,统一在封装层处理:写一个waitForVisibleText方法,先拿到页面上的所有文本,做trim和大小写归一化之后再匹配,从根上减少对文本细节的苛求。
5.3 WebView布局下点击无效
现在的App很少完全原生,登录页、活动页可能是一块WebView。Robotium针对WebView有clickOnWebElement等方法,但使用前提是:WebView必须启用JavaScript,并且打开了调试开关。
如果页面里是一张“同意协议”的链接,直接用solo.clickOnText("《用户协议》")是点不到的,因为它不是原生View。
我的一般处理方式是先判断当前有没有聚焦到WebView,然后用:
solo.clickOnWebElement(By.text("《用户协议》"));不过这个方法在部分定制ROM上存在点击坐标偏移的问题。如果遇到这类情况,更稳妥的替代方案是绕过UI点击,直接通过JavaScript接口触发回调,或者用webView的loadUrl("javascript:...")去执行页面逻辑。这一块我会在系列后续的混合应用专题里单独展开,本篇先记住一个结论:原生页面用View定位,WebView页面用WebElement定位,不要混用,否则测试结果会像薛定谔的猫一样不可预测。
6. 从单脚本到测试套件:组织方式的进化方向
6.1 不要让每条用例都从零启动App
等测试用例多了以后,你会发现全量跑的时间有很大一部分耗在反复的App安装和Activity启动上。Robotium本身没提供“用例间保持上下文不中断”的官方能力,但我们可以用setUp()里的参数来控制启动模式。
比如希望在同一进程内快速执行多个方法,可以继承InstrumentationTestCase而不是ActivityInstrumentationTestCase2,然后在每个方法内手动getActivity()。但要注意,绕开了ActivityInstrumentationTestCase2之后,Solo的初始化需要自己控制好Instrumentation与Activity之间的生命周期关系。只有在方法间依赖同一个页面状态、又不关心Activity被重建时,才建议这么干。
6.2 数据与用例分离的改造思路
我在真实项目里把测试脚本抽成了两层:操作层和用例层。操作层封装所有solo调用,比如一个通用的登录方法:
public class ActionHelper { private Solo solo; public void login(String username, String password) { solo.waitForActivity("MainActivity", 3000); solo.enterText(0, username); solo.enterText(1, password); solo.clickOnText("登录"); } public void assertHomeVisible() { assertTrue(solo.waitForActivity("HomeActivity", 5000)); assertTrue(solo.waitForText("欢迎回来", 1, 3000)); } }用例层只需要关心测试逻辑,不关心具体控件ID和查找策略。这样一来,页面重构导致的脚本维护量就被压缩在操作层一个文件里了。
6.3 测试报告与失败截图
Robotium自带takeScreenshot(),可以在失败时把现场保存下来。推荐的写法是放到tearDown()里,结合一个失败标记变量来判断当前用例是否执行失败:
@Override protected void tearDown() throws Exception { if (getTestResult().failedCount() - beforeFailureCount > 0) { solo.takeScreenshot("/sdcard/robotium_fail_" + System.currentTimeMillis() + ".png"); } solo.finishOpenedActivities(); super.tearDown(); }某些设备上外部存储路径需要手动确认写权限,更省心的方式是直接用solo.getCurrentActivity().getExternalFilesDir(null)作为保存目录,这样既不用额外申请存储权限,路径也便于后续从设备导出。
说说这个系列的第一篇为什么只搭到这里
很多人初学Robotium,恨不得一天把所有API都试一遍,结果遇到一个签名报错就卡了两天,把兴趣全部消磨掉了。我特意把第一篇的边界控制在“搭完骨架、跑通第一个真实用例”,就是为了让你在动手阶段先把环境、签名、Solo生命周期这些最基础也最容易出问题的环节吃透。这些内容看似琐碎,却是后续所有高级用法的地基。下一篇会重新回到Solo的API图谱,把文本定位、索引定位、ID定位的优先级关系以及WebView混合场景的处理一次讲透。你把这篇文章里的登录用例在你自己项目里跑通之后,再来看下一篇,会顺畅得多。