工厂方法模式如何提升可测试性?又会带来哪些测试问题?
简化版
工厂方法通过让调用方依赖抽象工厂和抽象产品,可以在测试中替换成 fake 工厂或 mock 产品,避免业务代码直接 new 复杂依赖。但如果工厂被写成静态全局入口、内部隐藏大分支或缓存全局状态,反而会降低测试隔离。
详细版
可测试性的关键是依赖能否替换。业务类如果直接 new SmsSender(),测试时很难阻止它发送真实短信;如果业务类依赖 SenderFactory,测试可以注入 FakeSenderFactory,返回一个只记录调用参数的 fake sender。
工厂方法提升测试性的方式包括:
- 把具体产品创建从业务流程中移走;
- 让测试替换工厂或产品;
- 避免连接外部服务、文件系统、网络等真实资源;
- 让异常路径更容易模拟。
但工厂也可能带来测试问题:静态工厂难替换,注册表工厂状态可能跨测试残留,缓存产品可能导致用例互相影响。工程中应优先通过构造器注入工厂抽象,并为注册表提供清理或独立实例。
完整版教学
一、可测试性的核心是替换创建结果
单元测试通常不希望真的访问短信平台、支付网关、文件系统或数据库。工厂方法的价值在于,业务代码不直接创建这些具体依赖,而是通过抽象工厂拿到抽象产品。
public class NoticeService {
private final SenderFactory factory;
public NoticeService(SenderFactory factory) {
this.factory = factory;
}
public void notice(String message) {
factory.createSender().send(message);
}
}
测试时可以传入 fake 工厂,返回一个只把消息保存到内存列表的 fake sender。这样测试的是业务流程,不是外部短信服务。
二、直接 new 为什么会让测试变重
如果业务方法内部直接 new RealPaymentClient(),测试就被迫满足真实客户端的构造条件:配置、证书、网络地址、鉴权参数。一个原本 5 ms 的单元测试可能变成 500 ms 的集成测试。
直接 new 真实客户端 -> 读配置 -> 建连接 -> 调外部服务 -> 测试慢且不稳定
依赖抽象工厂 -> fake 产品 -> 内存断言 -> 测试快且稳定
假设一个模块有 200 个测试,每个测试多花 300 ms 初始化外部客户端,总耗时会增加:
200 * 300 ms = 60000 ms = 60 s
这就是创建逻辑和业务逻辑耦合后的测试成本。
三、工厂可以让异常路径更容易覆盖
很多异常路径在真实依赖中很难稳定复现,比如支付超时、文件权限失败、第三方返回限流。用测试工厂可以直接返回会抛异常的产品,从而覆盖业务补偿逻辑。
class FailingSenderFactory implements SenderFactory {
public MessageSender createSender() {
return message -> { throw new RuntimeException("send failed"); };
}
}
这样测试可以精准验证:发送失败时是否记录日志、是否重试、是否写入失败表。没有工厂抽象时,测试往往只能依赖真实服务制造异常,既慢又不稳定。
四、静态工厂会削弱这种好处
如果代码写成 SenderFactoryProvider.get().createSender(),工厂虽然存在,但仍被全局静态入口隐藏起来。测试要替换它,就需要修改全局状态或使用特殊 mock 工具。
对比:
| 写法 | 替换难度 | 测试隔离 |
|---|---|---|
构造器注入 SenderFactory | 低 | 好 |
| 方法参数传入工厂 | 低 | 好 |
| 静态全局工厂 | 高 | 较差 |
| 工厂内部直接读全局配置 | 中高 | 取决于清理 |
所以不是“用了工厂方法就自动好测”。可测试性来自依赖显式化,而不是来自类名里有 Factory。
记忆钩子:工厂能不能提升测试性,关键看测试能不能换掉它。
五、注册表和缓存会带来测试污染
注册表工厂常用 Map 缓存产品。如果这个 Map 是静态的,测试 A 注册了 "sms" -> FakeA,测试 B 可能仍然读到 FakeA。并行测试时问题更明显。
示例时序:
TestA: register("pay", FakePayClientA)
TestB: register("pay", FakePayClientB)
TestA: create("pay") 期望 A,实际可能拿到 B
解决方式包括为每个测试创建独立工厂实例、避免静态注册表、提供明确清理方法,或者使用容器测试上下文隔离 Bean。不要把测试污染归咎于工厂方法模式本身,问题通常来自全局可变状态。
六、工厂本身也需要测试
业务测试可以替换工厂,但工厂实现也要有自己的测试。尤其是参数化工厂和注册表工厂,要验证 key 到产品的映射、未知 key 的异常、缓存语义和并发创建。
可以设计这些用例:
"sms"返回SmsSender;"email"返回EmailSender;- 未知 key 抛出明确异常;
- 同一 key 连续创建 2 次是否符合缓存预期;
- 32 个线程并发创建同一 key 时构造次数是否符合设计;
- 创建失败是否不会缓存半成品。
这些测试属于工厂层测试,不应混进每个业务服务测试里。分层测试能让问题定位更快。
七、什么时候不要为了测试强行加工厂
如果对象只是简单值对象,没有外部依赖、没有复杂创建成本,也没有替换需求,强行引入工厂会增加样板代码。比如创建 Money、Point、DateRange 这类对象,构造器或静态工厂方法通常足够。
工厂方法适合“创建具体依赖会影响测试边界”的地方,例如网关客户端、消息发送器、文件存储适配器、策略实现选择。模式应该服务于变化点和测试边界,而不是为了显得设计复杂。
八、常见误区与追问
- 误区:只要使用工厂方法,代码就一定更好测。 如果工厂通过静态全局入口访问,替换仍然困难。
- 误区:测试可以不关心工厂实现。 业务测试可以替换工厂,但工厂映射和缓存语义要单独测试。
- 误区:注册表工厂天然适合测试。 静态注册表会造成跨用例污染,必须隔离或清理。
- 误区:为了 mock 所有 new 都要改成工厂。 简单值对象不需要工厂,复杂外部依赖才值得抽象。
- 追问:如何测试工厂创建异常? 注入会失败的配置或 fake 构造器,断言异常类型、日志和补偿流程。
- 追问:构造器注入工厂和直接注入产品哪个好? 如果业务只需要一个固定产品,直接注入产品更简单;需要按运行时条件创建产品时注入工厂。
- 追问:工厂缓存如何避免测试污染? 使用实例级缓存、测试后清理、独立容器或避免全局静态状态。
九、加强记忆
工厂方法提升可测试性的路径是“显式依赖抽象工厂,再替换具体产品”。它能隔离外部服务、模拟异常路径、降低测试耗时;但静态入口、全局注册表和缓存状态会反过来破坏隔离。答题时把收益和代价都讲出来,才像工程答案。