单元测试写不下去,通常是设计有问题

作者:键盘上的猫 发布时间: 2025-11-01 阅读量:50 评论数:0

我先把结论放这儿:当你觉得一个函数「没法写单元测试」的时候,问题几乎从来不在测试上,而在这个函数本身。

这个观点我以前是不信的。我信的是「这段代码逻辑太复杂了,测试成本太高,先上线再说」。直到有一次,我为了给一个函数写测试,写了 140 行的 mock,测试代码是被测代码的三倍,而且每次改一点实现,测试就全挂。那次之后我才开始怀疑:也许难写的不是测试。

那个函数长什么样

简化之后大概是这个结构:

def sync_user_points(user_id):
    conf = load_config()                    # 读全局配置文件
    client = HttpClient(conf.endpoint)      # 内部 new 了一个客户端
    resp = client.get(f'/points/{user_id}') # 网络调用
    now = datetime.now()                    # 直接取当前时间
    if now.hour < 6:
        bonus = 0
    else:
        bonus = compute_bonus(resp, now)    # 业务逻辑藏在这里
    db.execute('UPDATE ...')                 # 写数据库
    write_audit_log(user_id, bonus)         # 写文件
    return bonus

要测这个函数,我得:mock 配置读取、mock HTTP 客户端、mock 系统时间、mock 数据库、mock 文件写入。五个 mock,才能测那一行 compute_bonus

而我真正想验证的,其实只有那个 bonus 算得对不对。

问题的本质

这个函数把三类东西糅在了一起:

  • 依赖获取(读配置、建客户端)
  • 副作用(网络、数据库、文件、时间)
  • 业务决策(怎么算 bonus)

难测的原因是:我想验证第三类,却被迫处理前两类。这不是测试工具的问题,这是职责没有分离

换个角度想:一个函数需要五个 mock 才能运行,说明它在真实环境里有五个失败的可能。这本身就是设计信号。

怎么改的

三步。

第一步,把时间变成参数。 datetime.now() 是最常见的隐藏依赖。它让函数变成不确定的——同一个输入,早上跑和晚上跑结果不同。改成 def sync_user_points(user_id, now),一秒钟解决问题。

我知道有人喜欢用 mock 库 patch 时间。能用,但每次都要写那几行,不同框架写法还不一样。传参零成本、跨语言通用,而且让依赖显式化了。

第二步,把依赖注入进来。 客户端和配置从外面传,不在函数内部 new。这样测试时传一个假实现就行,不需要 patch 任何全局状态。

第三步,也是收益最大的一步:把纯计算抽出来。

def calc_points(raw_data, now, rule):
    # 没有任何 IO,纯输入输出
    ...

这个函数不碰网络、不碰数据库、不看时钟。测它只需要构造输入、断言输出。我给它写了 23 个用例,覆盖各种边界,全部跑完不到 40 毫秒,不需要一个 mock。

外层的 sync_user_points 变成了一个只做编排的壳:取数据、调计算、存结果。这个壳我用集成测试覆盖,跑得慢一点无所谓,因为它没有分支逻辑,不需要跑很多组合。

这套思路的一般形式

后来我发现这其实是个很通用的模式,有人叫它「函数式核心,命令式外壳」。核心思想是:

把决策逻辑做成纯函数,把副作用推到调用链的最外层。

好处不只是好测:可以放心并发调用;出问题时只要有入参就能本地精确复现;重构时不用担心破坏隐藏状态。

我们组现在有个不成文的检查项:如果一个 PR 里的测试需要三个以上的 mock,评审时会先讨论要不要调整设计。 不是硬性规定,但触发讨论的次数不少,而且多数时候确实找到了更好的结构。

几个反对意见,以及我的看法

「拆得太碎了,一个功能要跳好几个文件。」 我的经验是,拆成 2 到 3 层通常刚好,拆到 6 层确实过度了。判断标准不是层数,而是每一层是不是有清晰的、能一句话说明白的职责

「有些代码天生难测,比如框架的生命周期钩子。」 同意。正确做法是让它薄到没有逻辑——钩子里只调一个函数。薄到没有 bug 的地方,就不需要测。

「时间紧,先上线。」 这个我不反驳,工程本来就是妥协。但我想指出一点:难测的代码,改起来也难,出问题也难查。你今天省下的两小时,通常会在三个月后以两天的形式还回来。

最后一点体会

写测试这件事,我现在觉得它最大的价值不是「防止 bug」,而是它是对设计的一次即时反馈。写得顺,说明结构大概没问题;写得别扭,说明有什么地方缠在一起了。

就像盖房子的时候,如果你发现某个房间没法开窗,那多半不是窗户的问题,是户型的问题。

评论