第28章:端到端测试与集成测试:E2E测试工具、完整用户流程与多组件交互

说实话,很多开发兄弟对单元测试和UI测试都挺熟的,但一提到端到端测试(E2E),就觉得那是QA的事。我以前也这么想,直到有一次,我自信满满地提交了一个版本——单元测试全绿,UI测试也过了——结果上线后用户反馈说「注册完点下一步,直接闪退」。我查了半天才发现,是ActivityA跳到ActivityB时,有个Intent参数没传对。

嗯,从那以后我就明白了:单测测的是零件,E2E测的是整台机器能不能跑起来。这一章,我就带你走一遍Android端到端测试的完整套路。

28.1 什么是端到端测试?跟集成测试有啥区别?

先别急着上代码,咱们把概念理清楚。

维度单元测试集成测试端到端测试
范围单个类/方法多个模块/组件协作完整用户流程
依赖Mock全部外部依赖部分真实依赖全部真实依赖(或模拟服务)
速度毫秒级秒级分钟级
典型场景ViewModel逻辑Fragment + ViewModel + Repository登录 → 首页 → 下单 → 支付

说白了,E2E测试就是模拟真实用户的操作路径。你想想看,用户不会关心你Repository层用了Room还是Retrofit,他只关心「点这个按钮能不能看到下一页」。所以E2E测试的核心价值,就是验证整个系统在真实环境下的行为是否符合预期。

我的经验:在项目中,我通常把E2E测试分成两类——「冒烟测试」和「核心流程测试」。冒烟测试只覆盖最关键的3-5个路径(比如登录、注册、支付),每次CI都跑;核心流程测试覆盖20-30个路径,每天跑一次。这样既保证了质量,又不会让CI等太久。

28.2 E2E测试工具:Barista vs Kakao

Android上做E2E测试,最底层是Espresso。但Espresso的API说实话有点啰嗦——写一个onView(withId(R.id.btn_login)).perform(click()),手都酸了。所以社区里出现了两个好用的封装库:BaristaKakao

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(),基本覆盖了日常需求。

小技巧:Barista还支持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,后面直接引用就行。

注意:Kakao和Barista不能混用!它们底层都依赖Espresso,但API风格完全不同。混用会导致测试代码难以维护。我的建议是:小项目用Barista,大项目用Kakao。

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来等待网络完成,问题就解决了。

核心原则:E2E测试不要mock网络请求!要用真实的测试服务器或模拟器。mock了网络,那就不是端到端测试了,顶多算个「集成测试plus版」。

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()))
}
避坑指南:我曾经在测试Fragment回退时,发现pressBack()之后Fragment没有恢复。原因是Fragment没有正确处理onSaveInstanceState()。所以写E2E测试时,一定要覆盖「旋转屏幕」「按Home键再回来」这些场景,它们能暴露很多生命周期相关的问题。

28.5 测试后台服务

后台服务(Service)的测试比较特殊,因为它没有UI。我通常用两种方式测试:

  1. 直接测试Service的逻辑:用ServiceTestCaseRobolectric,验证Service的onStartCommand()onBind()等方法的行为。
  2. 通过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测试 测试工具 Barista Kakao 测试场景 用户流程 多Activity 多Fragment 后台服务 广播接收器 最佳实践 IdlingResource 页面对象模式 真实网络请求 生命周期覆盖 核心思想:模拟真实用户操作,验证完整系统行为 不要Mock网络请求!不要Mock网络请求!不要Mock网络请求!

这张图把E2E测试分成了三个维度:工具、场景和最佳实践。你可以把它当作一个检查清单——每次写E2E测试时,看看自己覆盖了哪些场景,用了哪些工具,有没有遵循最佳实践。

好了,这一章的内容就到这里。E2E测试说白了就是「站在用户的角度去测试你的App」。虽然写起来比单元测试慢,但它能发现很多单元测试发现不了的问题——比如Activity之间的数据传递、Fragment的生命周期恢复、后台服务的异步处理等等。我个人建议,每个项目至少要有5-10个核心的E2E测试用例,它们会成为你上线前最坚实的防线。