从 Leetcode 到 Android:测试驱动开发(TDD)在业务中的落地思考
写测试不是为了凑覆盖率,而是先想清楚「对的行为」到底是什么。
为什么突然想聊 TDD
在以前公司做 Android 业务的时候,说实话,测试往往不是开发的第一步。
需求来了,先把页面搭出来,联调一遍,QA 点一轮,能上线就上线了。手动点点看,这在业务早期其实挺管用的——逻辑还没那么绕,团队也没那么大,出了问题改一改也就过去了。
但随着业务堆起来、模块拆开来、人也开始多起来,光靠「我点过了没问题」这件事,心里会越来越没底。某个边界条件、某次状态切换、某个异步回调晚了一拍,线上就给你来一个 ANR 或者莫名其妙的白屏。
测试驱动开发(TDD)说的是另一套顺序:先写测试,再写实现。动手写功能之前,得先把预期的输入、输出、状态变化想清楚。
这话听着很正,放到 Android 里落地却没那么顺——UI 行为复杂、状态多、用户输入也不固定,和业务后端那种「给个 JSON 进来、给个 JSON 出去」完全不是一回事。
所以这篇文章我想换个角度聊:不把它讲成一套标准方法论,而是从我自己的理解出发,拿 Leetcode 当镜子,照一照 TDD 到底在干什么。
用一道 Leetcode 题理解 TDD 在干什么
算法题有个好处:输入和输出的关系通常是确定的,不太需要你和产品经理反复对齐「什么叫对」。
比如 Leetcode 105,从前序与中序遍历序列构造二叉树:
/**
* 给定两个整数数组 preorder 和 inorder,
* 其中 preorder 是二叉树的先序遍历,inorder 是同一棵树的中序遍历,
* 请构造二叉树并返回其根节点。
*
* - 输入: preorder = [3,9,20,15,7], inorder = [9,3,15,20,7]
* - 输出: [3,9,20,null,null,15,7]
*/这里 preorder + inorder 是输入,树结构是输出,关系写死在题面里。每一组输入输出对,本质上就是一个测试用例。所有用例都过了,你大概可以相信实现逻辑是对的——至少在这道题定义的「对」的范围内。
这和 TDD 想做的事是一回事:不是先闷头写代码,而是先把「什么算对」钉死,再让代码去追这个标准。
测试样本从哪来:我一般会偷懒
像算法题这种,边界条件其实不少——空树、单节点、只有左子树、只有右子树……自己一个个想也能想出来,但挺费时间。
我现在的习惯是丢给 ChatGPT(或者 Cursor 里直接让 AI 补),让它一次性吐一批样本出来,比如:
// 1. 空树
preorder = []
inorder = []
// 输出: []
// 2. 单节点
preorder = [1]
inorder = [1]
// 输出: [1]
// 3. 左子树
preorder = [3,2,1]
inorder = [1,2,3]
// 输出: [3,2,null,1]
// 4. 右子树
preorder = [1,2,3]
inorder = [1,2,3]
// 输出: [1,null,2,null,3]几十组边界和复杂情况,AI 生成起来很快。开发者可以把精力放在实现逻辑上,而不是穷举测试数据——这件事在真实业务里同样值钱,只是业务里的「样本」没那么好生成,后面再说。
落到代码上:一个最小的 TDD 循环
假设在 Algorithm 层要实现 buildTree(),一开始可以先留个空壳:
class SolutionRepo {
fun buildTree(preorder: IntArray, inorder: IntArray): TreeNode? {
return null
}
}这时候返回 null,所有测试理所当然会挂——红,这是正常的。
接着把测试先写好:
class SolutionRepoTest {
private val solution = SolutionRepo()
@Test
fun testBuildTree() {
(1..20).forEach { index ->
assertEquals(
expectedTree(index),
solution.buildTree(preorder(index), inorder(index))
)
}
}
}测试写好了,哪怕现在全红也没关系。接下来就是反复跑 testBuildTree(),一点点把实现补全,直到全绿。绿了之后如果代码写得别扭,再重构一轮。
很多人把这叫 红 → 绿 → 重构。我个人觉得它真正值钱的不是「多写了几个 @Test」,而是逼你在实现之前,先承认自己对「正确行为」的理解到底够不够清楚。
换到 Android 业务里:事情就没那么干净了
算法题的世界相对干净,Android 业务的世界要乱得多。我自己在实践中踩过、也见过同事踩过的坑,大致就这几类。
测试用例很难自己 cover 全
业务逻辑往往不是简单的对或错。用户路径绕、数据状态变、外部依赖(网络、推送、支付回调)还不一定按你预期的来。TDD 没法帮你穷尽所有可能——这点得老实承认。
所以测试用例不能只靠开发一个人拍脑袋,得和产品、测试一起把关键状态场景对齐。不然很容易掉进另一个坑:为了把测试弄绿而写代码,测的是你自己臆想出来的行为,不是用户真正要的行为。
UI 层直接做 TDD,性价比通常不高
后端接口的输入输出可以用数据描述清楚;Android 还叠了界面状态、手势交互、生命周期、协程/线程切换这些层。
大多数团队(包括我待过的)更实际的做法是:TDD 放在 ViewModel 或 UseCase 这一层,把 UI 行为抽象成状态流(StateFlow / LiveData),对状态变化做断言,而不是去测每一个 Composable 或 View 长什么样。
重点不是测 UI 像素级对不对,而是业务逻辑在状态流转里有没有跑偏。
我怎么看 TDD 的真正用处
TDD 不是「追求 100% 覆盖率」的代名词,也不是团队 KPI 里的测试数量。
它更像一个验证理解的过程:写实现之前,先逼自己说清楚——这个功能在各种输入下,应该变成什么状态、产出什么结果。
在协作里,这件事有时候比测试本身更有用。产品得把状态和结果说清楚,开发得先验证逻辑闭环,测试同学可以集中精力盯边界和异常。大家如果在同一张「状态走向图」上说话,扯皮的次数通常会少一点。
当然,这也只是理想情况。真实项目里排期紧、需求变、遗留代码多,不可能事事 TDD。我自己的态度是:值得 TDD 的层(纯逻辑、状态机、数据转换)就认真做;不值得硬上的(纯 UI 布局、一次性 Demo)就别形式主义。
结语
Leetcode 给我们的是确定性问题,输入输出写在题面上,对就是对、错就是错。
Android 业务给我们的是复杂性问题,「对」有时候要到联调、灰度、甚至上线之后才能看得更清楚。
TDD 解决不了所有混乱,但它提供了一种习惯——先从混乱里抽出几条你能说清楚的状态规则,再动手实现。先定义什么算对,再去追那个对。
这句话写起来简单,在业务里做到位很难。我也是在写这篇文章的时候,重新梳理了一遍自己的理解,未必全对,但如果你也在 Android 业务里纠结要不要上 TDD,或许可以拿 Leetcode 那套「输入输出先钉死」的思路,先从 ViewModel 一层试起。