本节摘要:让单元测试"快稳自动"的抓手,是把外部依赖从被测代码里分离出去。核心手法是依赖注入——把依赖当作参数传进来,而不是在函数里直接 new。本节照一个真实改造案例,给你一套从"测不了"到"测得了"的标准工序。
假设线上有个计费函数,它的实现里直接联网去扣款:
import requests def charge(user_id, amount): resp = requests.post("https://billing.internal/charge", json={"user": user_id, "amount": amount}) if resp.status_code == 200: return "ok" return "fail"
你要给这个函数写单元测试,瞬间撞上三堵墙:第一,真实网络调用让测试变慢且需要外部队列可达;第二,计费系统是生产级外部服务,测试里真扣钱谁也赔不起;第三,返回值直接取决于第三方状态,你无法稳定控制。这三堵墙不是函数的错,是"依赖被写死"的错——new 和直接连外部,等于把测试挡在门外。
解法朴素却极其通用:别让 charge 自己造这个外部客户端,把客户端作为参数传进来。调用方(生产代码或测试)决定塞真货还是假货。这是所谓"依赖注入"的本质——不是框架的魔法,就一句话:依赖通过参数进来,不在内部用 new 生成。
改造后:
def charge(user_id, amount, billing): resp = billing.charge(user_id, amount) # billing 是注入的端口 return "ok" if resp.status_code == 200 else "fail"
现在 charge 不再关心 billing 是真是假,它只管自己那点逻辑。生产环境传一个真客户端,测试传一个能指定返回值的假客户端,同一套逻辑两端皆可验。
下面展开一次完整改造,走"背景 → 操作 → 结果 → 解读 → 变式"五步,你照着做就能迁移到自己的项目。
背景:仓储类 ikea_order 内部直接 new 了一个数据库连接器,读订单写订单都卡在这。
操作:给类增加可选的构造参数,默认仍走真连接,但允许注入替代端口;同时把"读订单"的实现剥成纯接口调用。
结果:生产代码几乎不改(只是多了一个可注入参数),但测试代码可以从真实的数据库里解脱出来,用内存假件顶替。
解读:改动很小,收益却大——顺序、时延、环境全都不再影响测试,快稳自动三条铁律一次达成。
变式:如果依赖是隐藏的全局变量(比如某个单例),就先把它改成函数参数;如果是 static 方法,就抽成实例方法再注入。总之动作都是一个字:把依赖从"内部够到的角落"挪到"从门外递进来"。
不引入任何框架,光用 Python 的直接传参就能验隔离后的逻辑:
class FakeBilling: def charge(self, user_id, amount): return SimpleNamespace(status_code=400) # 可控地返回个失败 def test_charge_fails_when_billing_rejects(): assert charge(7, 9.99, FakeBilling()) == "fail"
断开网络后这测试照跑,返回值 100% 可预期,这就是隔离的报酬。真实项目里,端口常常长成一个接口、一两个方法,再加一层依赖注入容器把它接回生产路径。
不是所有外部依赖都要机械地换成假件。现实的取舍标准:快吗(慢就换)?稳吗(会闪红就换)?贵吗(碰真实费钱或有副作用,如扣款、发信,就换)?三者都"否"的依赖,比如一个只读的本地配置,保留真货也说得过去。所以"关起门"的度是按成本和安全逐项算的,不是一刀切全替身。想要更细的替身分类和选择流程,下一节测试替身实验室接着讲。
⚠️ 常见坑:依赖注入很容易做过头,为了所谓的"纯净"把两层调用之间塞满抽象的端口,让测试像猜谜。记住目标是"必要处隔离",不是"处处建接口"。能用一个简单函数参数解决的问题,别建一个五层继承。
依赖注入在落地时长成三副面孔,选哪副取决于依赖的生命周期。第一副是"构造注入"——依赖经构造函数传入,适合一个类长期持有的依赖(仓库、服务、客户端),也最常用。第二副是"方法注入"——依赖经具体方法参数传入,适合只在某一次调用时需要、不必长期持有的依赖(比如某次请求里临时要的解析器)。第三副是"属性注入"——依赖经可写属性/字段塞进去,灵活但容易让依赖"隐身",可测性反而变差,一般只在测试配置里用。
这里给条选择口诀:依赖要跨多次调用共享,用构造注入;依赖只在一次调用用得上,用方法注入;能不用属性注入就别用——它最容易让人忘了依赖存在,从而把被测逻辑悄悄变成"看不见的硬编码依赖"。
注入做过头和不足一样是坑。判断的护栏有三问:这个依赖是不是真的让测试变慢、变不稳、变贵(快/稳/贵三问)?三问都否,就保留真货;三问中确有,才值得注入端口。第二个护栏问:注入会不会改变被测行为的语义?如果只是把"两种环境下的同一套规则"换端口,语义没变,注入是干净的;如果把规则本身也替换掉了,那你可能已经在测假世界。第三问:有没有更简单的写法?能用函数参数、就别上类级抽象;能用默认参数、就别加容器。这三问能帮你不断把注入打磨到"必要的那个度"。
现实里"测不动"常常同时是隔离问题和设计问题混在一起。分辨方法是看——把依赖注入进去之后,剩下的被测逻辑是否足够短、足够直白。若注入后发现被测函数还是一团五层嵌套的临时变量和副作用,那说明不止缺注入,还缺"拆分":把大的副作用过程和纯计算核分开,让纯核单元测、边界动作交给集成。这一步把第 2.2 节和第 5.3 节的可测试性设计连在了一起,也正是后续第 5 章讲 TDD 时"先拆到可测"的伏笔。