返回 Android
Android
10 分钟阅读

从 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 放在 ViewModelUseCase 这一层,把 UI 行为抽象成状态流(StateFlow / LiveData),对状态变化做断言,而不是去测每一个 Composable 或 View 长什么样。

重点不是测 UI 像素级对不对,而是业务逻辑在状态流转里有没有跑偏

我怎么看 TDD 的真正用处

TDD 不是「追求 100% 覆盖率」的代名词,也不是团队 KPI 里的测试数量。

它更像一个验证理解的过程:写实现之前,先逼自己说清楚——这个功能在各种输入下,应该变成什么状态、产出什么结果。

在协作里,这件事有时候比测试本身更有用。产品得把状态和结果说清楚,开发得先验证逻辑闭环,测试同学可以集中精力盯边界和异常。大家如果在同一张「状态走向图」上说话,扯皮的次数通常会少一点。

当然,这也只是理想情况。真实项目里排期紧、需求变、遗留代码多,不可能事事 TDD。我自己的态度是:值得 TDD 的层(纯逻辑、状态机、数据转换)就认真做;不值得硬上的(纯 UI 布局、一次性 Demo)就别形式主义。

结语

Leetcode 给我们的是确定性问题,输入输出写在题面上,对就是对、错就是错。

Android 业务给我们的是复杂性问题,「对」有时候要到联调、灰度、甚至上线之后才能看得更清楚。

TDD 解决不了所有混乱,但它提供了一种习惯——先从混乱里抽出几条你能说清楚的状态规则,再动手实现。先定义什么算对,再去追那个对。

这句话写起来简单,在业务里做到位很难。我也是在写这篇文章的时候,重新梳理了一遍自己的理解,未必全对,但如果你也在 Android 业务里纠结要不要上 TDD,或许可以拿 Leetcode 那套「输入输出先钉死」的思路,先从 ViewModel 一层试起。