集成测试:接缝才是真正出事的地方
你把每个类都单元测试到了绿,信心满满地上线,然后它炸了。哪儿错了?翻半天发现:一句 SQL 的列名拼错了、后端 JSON 里的字段叫 user_id 你写成了 userId、时间戳没带时区。这些 bug,你的单元测试一个都照不到 —— 不是它们不努力,是它们为了「隔离」,恰好把出事的那个连接给假掉了。集成测试专治这个:它把你的代码和真实的那一侧(真的数据库、真的 HTTP 响应)接起来,去撞方块之间的接缝。
bug 从方块内部,搬到了方块之间
上一章结尾那句话值得再说一遍:单元测试全绿、线上照炸,不是悖论。它只是说明,bug 换了个地方藏。
单元测试测「方块内部的逻辑对不对」。但真实系统的很大一部分风险,不在方块里,在方块之间的握手上 —— 你的代码和数据库怎么对话、和后端怎么对话、和文件系统怎么对话。
这些握手有个共同点:两边各有一套「格式约定」,而约定很容易对不上。列名、字段名、类型、可空性、编码、时区、字节序 —— 每一个都是一道缝,每一道缝都能漏。集成测试就是站在缝上,拿真实的一侧来验。
常见的接缝就那么几类,记住它们,你就知道集成测试该测什么:
- 持久层:你的代码 ↔ 数据库。SQL 语法、列名、迁移、约束、事务。
- 网络层:你的代码 ↔ 后端 API。JSON 字段名、可空、错误码、超时、分页。
- 序列化:对象 ↔ 字节。编码、日期格式、精度丢失。
- 并发:多个部件在不同线程上握手(第 12 章会专门测这个)。
持久层:用内存版 Room 测 DAO
Android 上最典型的集成测试,是测 Room 的 DAO。为什么必须是集成测试?因为 DAO 里的查询是字符串 SQL —— 编译器基本管不到它对不对,只有真的丢给一个 SQLite 引擎去执行,才知道列名有没有拼错、WHERE 条件对不对。
诀窍是用内存数据库:它是真的 SQLite,但建在内存里,测试跑完即毁,快而干净。
@RunWith(AndroidJUnit4::class) // 需要 Robolectric 或真机(第 20 章)
class UserDaoTest {
private lateinit var db: AppDatabase
private lateinit var dao: UserDao
@Before fun setup() {
db = Room.inMemoryDatabaseBuilder( // ← 关键:内存库,不落盘
ApplicationProvider.getApplicationContext(),
AppDatabase::class.java
).allowMainThreadQueries().build()
dao = db.userDao()
}
@After fun teardown() = db.close()
@Test fun `按城市查,只返回该城市的用户`() = runTest {
dao.insert(User(1, "Ann", city = "上海"))
dao.insert(User(2, "Bob", city = "北京"))
val result = dao.findByCity("上海") // ← 真的执行这条 SQL
assertEquals(listOf("Ann"), result.map { it.name })
}
}
如果 findByCity 里的 SQL 把 WHERE city = :city 写成了 WHERE name = :city,单元测试永远发现不了(它照不到 SQL),但这个集成测试当场红。这就是接缝测试的价值。
数据库还有一道特别容易翻车的缝:schema 迁移。用户手机上装着 v3 的库,你发了 v4 改了表结构,迁移写错 = 用户数据全丢或直接崩。Room 提供 MigrationTestHelper,专门测「从旧版本迁到新版本,数据还在、结构对了」。这是那种「不测,就等着线上炸」的地方。
网络层:用 MockWebServer 测 Retrofit
另一条大缝是网络。你不想在测试里打真的后端(慢、会变、可能没网),但你想验证:后端返回这坨 JSON 时,我的代码能不能正确解析、能不能处理错误码。
办法是起一个假的 HTTP 服务器(OkHttp 的 MockWebServer),让它返回你事先准备好的响应,再把 Retrofit 指向它:
class UserApiTest {
private val server = MockWebServer()
private val api = Retrofit.Builder()
.baseUrl(server.url("/"))
.addConverterFactory(MoshiConverterFactory.create())
.build().create(UserApi::class.java)
@Test fun `正确解析后端返回的用户`() = runTest {
server.enqueue(MockResponse().setBody("""
{ "user_id": 1, "full_name": "Ann" }
""".trimIndent()))
val user = api.getUser(1)
assertEquals(1, user.id)
assertEquals("Ann", user.name) // ← 验证 user_id→id、full_name→name 的映射
}
@Test fun `后端 500 时抛 HttpException`() = runTest {
server.enqueue(MockResponse().setResponseCode(500))
assertFailsWith<HttpException> { api.getUser(1) }
}
}
第一个测试盯的正是最阴险的那类 bug:字段名映射。后端叫 full_name,你的数据类字段叫 name,靠一个 @Json(name = "full_name") 注解桥接 —— 这个注解写错一个字母,解析出来就是 null,而编译器毫无察觉。第二个测试盯的是失败路径:后端挂了(500),你的代码是优雅抛异常,还是把一坨错误当正常数据用了?
接缝 bug 是 AI 的重灾区,原因很直接:AI 看不到接缝的另一侧。它不知道你后端 JSON 的真实字段名叫什么、不知道那列在数据库里到底叫 created_at 还是 createdAt、不知道那个字段后端会不会返回 null。
于是它猜一个最像的,然后自信地写下去。userId vs user_id、date 是字符串还是时间戳、金额是分还是元 —— 它全靠猜,而且猜得理直气壮。这些「看起来完全合理」的映射错误,只有集成测试(拿真实的一侧来撞)能抓到。
集成测试测什么、不测什么
集成测试比单元测试贵(百毫秒起步,要建库、起服务器),所以要克制。一个常见错误是拿集成测试重复测逻辑:在 DAO 测试里塞一堆业务规则的断言。别 —— 逻辑归单元层。
一个集成测试应该聚焦在连接本身:SQL 能跑通吗、JSON 能映射对吗、错误码处理对吗。至于「满 100 打八折」这种业务逻辑,在单元层已经穷尽测过了,别在这儿重来一遍。
数量上,集成测试应该明显少于单元测试(回到第 4 章金字塔)—— 每条缝测几个代表性场景(正常、边界、失败)就够,不必穷举。
一条铁律:测试必须是密封的(hermetic)
集成测试最容易踩的坑,是连了真的外部依赖 —— 真的测试数据库、真的下游服务。一旦这样,你的测试就继承了外部世界的所有毛病:它慢、它会因为别人改了共享数据而红、它没网就跑不了、它可能污染真实数据。
一个连了共享测试库的集成测试,今天绿明天红,而你的代码一行没动 —— 因为别的团队往那个库里塞了脏数据。这种测试会训练全组忽略红灯(第 17 章 flaky)。
解药是密封性:测试自带它需要的一切,跑完不留痕迹。内存版 Room、MockWebServer 都是为此而生 —— 它们是「真实的一侧」,但是你完全掌控、每次都一样的那一侧。这既保住了「测真接缝」的价值,又守住了 FIRST 的「可重复」。
1. 翻你项目里一个 DAO,找一条带 WHERE / JOIN / ORDER BY 的查询,给它写一个内存库集成测试。SQL 越复杂,这个测试越值钱。
2. 找一个数据类的 @Json / @SerializedName 映射,用 MockWebServer 验证它真能从后端那个字段名解析出来。顺手测一下这个字段返回 null 时会怎样。
3. 检查有没有集成测试连了真外部依赖。如果有,它就是你 CI 里下一次莫名其妙变红的源头。
范围再往外扩一圈,就到了顶层:端到端。下一章,我们让机器像用户一样,从 UI 点进去、穿过整个系统 —— Espresso、Compose Test、Playwright,以及所有 UI 测试都绕不开的那个头疼问题:测试跑得比界面快。