第28章:端到端测试与集成测试:E2E测试工具、完整用户流程与多组件交互
说实话,很多开发兄弟对单元测试和UI测试都挺熟的,但一提到端到端测试(E2E),就觉得那是QA的事。我以前也这么想,直到有一次,我自信满满地提交了一个版本——单元测试全绿,UI测试也过了——结果上线后用户反馈说「注册完点下一步,直接闪退」。我查了半天才发现,是ActivityA跳到ActivityB时,有个Intent参数没传对。
嗯,从那以后我就明白了:单测测的是零件,E2E测的是整台机器能不能跑起来。这一章,我就带你走一遍Android端到端测试的完整套路。
28.1 什么是端到端测试?跟集成测试有啥区别?
先别急着上代码,咱们把概念理清楚。
| 维度 | 单元测试 | 集成测试 | 端到端测试 |
|---|---|---|---|
| 范围 | 单个类/方法 | 多个模块/组件协作 | 完整用户流程 |
| 依赖 | Mock全部外部依赖 | 部分真实依赖 | 全部真实依赖(或模拟服务) |
| 速度 | 毫秒级 | 秒级 | 分钟级 |
| 典型场景 | ViewModel逻辑 | Fragment + ViewModel + Repository | 登录 → 首页 → 下单 → 支付 |
说白了,E2E测试就是模拟真实用户的操作路径。你想想看,用户不会关心你Repository层用了Room还是Retrofit,他只关心「点这个按钮能不能看到下一页」。所以E2E测试的核心价值,就是验证整个系统在真实环境下的行为是否符合预期。
28.2 E2E测试工具:Barista vs Kakao
Android上做E2E测试,最底层是Espresso。但Espresso的API说实话有点啰嗦——写一个onView(withId(R.id.btn_login)).perform(click()),手都酸了。所以社区里出现了两个好用的封装库:Barista 和 Kakao。
28.2.1 Barista:极简主义者的最爱
Barista是Adevinta Spain团队开源的,它的设计哲学就是「一行代码搞定一个操作」。我个人特别喜欢它的链式调用风格。
// 传统Espresso写法
onView(withId(R.id.username)).perform(typeText("admin"))
onView(withId(R.id.password)).perform(typeText("123456"))
onView(withId(R.id.login_button)).perform(click())
onView(withText("欢迎回来")).check(matches(isDisplayed()))
// Barista写法
click(R.id.login_button)
writeTo(R.id.username, "admin")
writeTo(R.id.password, "123456")
assertDisplayed("欢迎回来")
你看,代码量直接砍半。而且Barista内置了很多常用断言,比如assertDisplayed()、assertNotDisplayed()、assertEnabled(),基本覆盖了日常需求。
clickBack()、scrollTo()、sleep()等实用方法。不过sleep()我建议少用——它会让测试变慢且不稳定。更好的做法是用waitForCondition()或者Espresso的IdlingResource。
28.2.2 Kakao:Kotlin DSL的优雅典范
Kakao是Kakao Corp(对,就是做KakaoTalk那个公司)开源的。它最大的特点是用Kotlin DSL定义页面对象,让测试代码读起来像自然语言。
// 定义页面对象
class LoginScreen : Screen<LoginScreen>() {
val username = KEditText { withId(R.id.username) }
val password = KEditText { withId(R.id.password) }
val loginBtn = KButton { withId(R.id.login_button) }
val errorMsg = KTextView { withId(R.id.error_message) }
}
// 测试用例
@Test
fun testLoginSuccess() {
LoginScreen {
username { typeText("admin") }
password { typeText("123456") }
loginBtn { click() }
// 验证跳转到主页
HomeScreen {
title { assertDisplayed() }
}
}
}
我个人习惯用Kakao做大型项目的E2E测试,因为页面对象模式让代码复用性极高。你想想看,如果登录页面在10个测试用例里都被用到,用Kakao只需要定义一次LoginScreen,后面直接引用就行。
28.3 测试完整用户流程
光会点按钮还不够,E2E测试的核心是模拟真实用户的操作路径。下面我以一个电商App为例,展示一个完整的用户流程测试。
@RunWith(AndroidJUnit4::class)
class PurchaseFlowTest {
@get:Rule
val activityRule = ActivityScenarioRule(MainActivity::class.java)
@Test
fun testCompletePurchaseFlow() {
// 1. 登录
LoginScreen {
username { typeText("test_user") }
password { typeText("pass123") }
loginBtn { click() }
}
// 2. 浏览商品列表
ProductListScreen {
firstProduct { click() }
}
// 3. 查看商品详情并加入购物车
ProductDetailScreen {
addToCartBtn { click() }
cartIcon { click() }
}
// 4. 在购物车中结算
CartScreen {
checkoutBtn { click() }
}
// 5. 填写收货地址
AddressScreen {
name { typeText("张三") }
phone { typeText("13800138000") }
address { typeText("北京市朝阳区xxx路100号") }
submitBtn { click() }
}
// 6. 选择支付方式并支付
PaymentScreen {
selectAlipay { click() }
payBtn { click() }
}
// 7. 验证支付成功
assertDisplayed("支付成功")
assertDisplayed("订单号: ")
}
}
这段代码看起来很长,但逻辑非常清晰——就是按照用户的操作顺序一步步走。我在项目中遇到过一个问题:测试跑到第5步时经常失败,原因是网络请求还没返回。后来我加了IdlingResource来等待网络完成,问题就解决了。
28.4 测试多Activity/多Fragment交互
很多App的页面跳转涉及多个Activity或Fragment。比如从LoginActivity跳到MainActivity,再在MainActivity里切换多个Fragment。这种场景下,E2E测试要特别注意Activity的生命周期和Fragment的恢复。
28.4.1 跨Activity测试
@Test
fun testCrossActivityNavigation() {
// 从LoginActivity启动
val loginScenario = ActivityScenario.launch(LoginActivity::class.java)
loginScenario.onActivity { activity ->
activity.findViewById<Button>(R.id.login_btn).performClick()
}
// 等待MainActivity出现
Intents.intended(hasComponent(MainActivity::class.java.name))
// 切换到MainActivity的Scenario
val mainScenario = ActivityScenario.launch(MainActivity::class.java)
mainScenario.onActivity { activity ->
// 验证MainActivity的Fragment是否加载
val fragment = activity.supportFragmentManager
.findFragmentById(R.id.fragment_container)
assertNotNull(fragment)
assertTrue(fragment is HomeFragment)
}
}
这里有个坑:ActivityScenario.launch()每次都会创建一个新的Activity实例。如果你要测试「从A跳到B再返回A」的场景,需要用ActivityScenario.moveToState()来模拟生命周期变化。
28.4.2 多Fragment交互测试
Fragment之间的交互更复杂,因为FragmentManager管理着回退栈。我建议用FragmentScenario来测试单个Fragment,用ActivityScenario来测试Fragment之间的跳转。
@Test
fun testFragmentNavigation() {
// 启动包含Fragment的Activity
val scenario = ActivityScenario.launch(MainActivity::class.java)
scenario.onActivity { activity ->
// 模拟点击跳转到详情Fragment
activity.findViewById<Button>(R.id.to_detail_btn).performClick()
}
// 验证详情Fragment是否显示
onView(withId(R.id.detail_container)).check(matches(isDisplayed()))
// 模拟返回操作
Espresso.pressBack()
// 验证回到了列表Fragment
onView(withId(R.id.list_container)).check(matches(isDisplayed()))
}
pressBack()之后Fragment没有恢复。原因是Fragment没有正确处理onSaveInstanceState()。所以写E2E测试时,一定要覆盖「旋转屏幕」「按Home键再回来」这些场景,它们能暴露很多生命周期相关的问题。
28.5 测试后台服务
后台服务(Service)的测试比较特殊,因为它没有UI。我通常用两种方式测试:
- 直接测试Service的逻辑:用
ServiceTestCase或Robolectric,验证Service的onStartCommand()、onBind()等方法的行为。 - 通过E2E测试间接验证:启动Service后,检查它是否正确地更新了数据库或发送了广播。
@Test
fun testBackgroundService() {
// 启动Service
val intent = Intent(ApplicationProvider.getApplicationContext(), SyncService::class.java)
ApplicationProvider.getApplicationContext().startService(intent)
// 等待Service完成同步
Thread.sleep(2000) // 实际项目中用IdlingResource
// 验证数据库是否更新
val db = Room.databaseBuilder(
ApplicationProvider.getApplicationContext(),
AppDatabase::class.java, "app.db"
).build()
val count = db.userDao().getUserCount()
assertTrue(count > 0)
}
Thread.sleep()是下下策。更好的做法是让Service在完成工作时发送一个广播,然后在测试中注册BroadcastReceiver来等待。或者用CountDownLatch实现同步等待。
28.6 测试广播接收器
广播接收器(BroadcastReceiver)的测试相对简单,核心就是发送广播 → 验证结果。
@Test
fun testBroadcastReceiver() {
// 注册一个临时的Receiver来捕获结果
val latch = CountDownLatch(1)
var receivedAction = ""
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
receivedAction = intent.action ?: ""
latch.countDown()
}
}
val filter = IntentFilter("com.example.MY_ACTION")
ApplicationProvider.getApplicationContext().registerReceiver(receiver, filter)
// 发送广播
val intent = Intent("com.example.MY_ACTION").apply {
putExtra("key", "value")
}
ApplicationProvider.getApplicationContext().sendBroadcast(intent)
// 等待Receiver处理
latch.await(2, TimeUnit.SECONDS)
// 验证
assertEquals("com.example.MY_ACTION", receivedAction)
// 清理
ApplicationProvider.getApplicationContext().unregisterReceiver(receiver)
}
这里有个细节:有序广播和粘性广播的测试方式不同。有序广播需要设置Receiver的优先级,粘性广播则需要先发送再注册Receiver。我在项目中遇到过一个问题:测试粘性广播时,Receiver总是收不到,后来发现是因为粘性广播在Android 7.0之后被废弃了。嗯,这个坑踩得值。
28.7 知识体系总览
最后,我用一张SVG图来总结本章的核心内容。这张图展示了E2E测试的完整知识体系,从工具选择到测试场景,再到最佳实践。
这张图把E2E测试分成了三个维度:工具、场景和最佳实践。你可以把它当作一个检查清单——每次写E2E测试时,看看自己覆盖了哪些场景,用了哪些工具,有没有遵循最佳实践。
好了,这一章的内容就到这里。E2E测试说白了就是「站在用户的角度去测试你的App」。虽然写起来比单元测试慢,但它能发现很多单元测试发现不了的问题——比如Activity之间的数据传递、Fragment的生命周期恢复、后台服务的异步处理等等。我个人建议,每个项目至少要有5-10个核心的E2E测试用例,它们会成为你上线前最坚实的防线。