单例让单元测试无法 mock 依赖的最直接痛点是:通过静态 getInstance() 获取实例且内部硬编码实现,导致测试时无法用 mock 替换真实依赖,如 DatabaseConnection 单例被多服务调用时无法注入内存 SQLite 或模拟异常。

单例让单元测试无法 mock 依赖
最直接的痛点是:单例类通常通过静态方法 getInstance() 获取实例,内部硬编码了具体实现,导致在测试时无法用假对象(mock)替换真实依赖。比如一个 DatabaseConnection 单例被多个服务类直接调用,写测试时你没法让它返回一个内存 SQLite 连接或抛出异常来验证错误路径。
常见错误现象:Call to private DatabaseConnection::__construct() —— 因为单例把构造函数设为 private,连手动 new 都被拦住;或者测试中改了单例状态,污染后续测试用例。
- 必须用反射绕过私有构造,代码冗长且脆弱
- 多个测试并行运行时,单例状态互相干扰(尤其在 PHPUnit 的
--process-isolation关闭时) - 无法针对不同场景注入不同配置的实例(如测试环境用
MockPDO,生产环境用真实 PDO)
单例破坏依赖显式声明
当你在某个 service 类里写 Config::getInstance()->get('api.timeout'),这个依赖关系完全藏在方法体里,不看源码根本不知道它依赖什么。IDE 无法跳转、静态分析工具难以追踪、新人接手成本陡增。
对比依赖注入方式:public function __construct(private ConfigInterface $config) —— 一眼可知契约,也方便后期替换实现。
立即学习“PHP免费学习笔记(深入)”;
- 函数签名不再反映真实依赖,
phpstan或psalm检查失效 - 重构时无法安全地提取接口,因为所有调用都绑定到具体类名
- 无法通过构造参数控制生命周期(比如每次请求新建一个带 request-id 的 logger 实例)
单例阻碍扩展与多实例需求
所谓“全局唯一”在现实中极容易被打破。比如最初只用一个数据库连接池,后来要隔离慢查询;或者需要同时连接 MySQL 和 PostgreSQL;又或者不同租户需各自独立缓存实例——这时单例就从便利变成枷锁。
典型错误改造:getInstance() 突然加参数变成 getInstance($type = 'default'),但所有已有调用点都得改,还可能引入类型判断分支,违背开闭原则。
- 一旦加了参数,就不再是严格意义上的单例,语义混乱
- 继承和多态基本失效:你不能让
PostgreSqlPool继承DatabasePool并覆盖getInstance(),否则调用方无法感知 - 容器注册变得别扭:DI 容器本可管理单例/原型/作用域实例,硬写单例反而绕过容器能力
替代方案不是不用“唯一”,而是用对地方
真正需要全局唯一资源(如日志通道、事件总线、配置加载器),应交由 DI 容器统一管理其生命周期,而非在类内部强制单例逻辑。Laravel 的 app('log') 或 Symfony 的 $container->get(LoggerInterface::class) 表面像单例,实则底层由容器控制,可随时切换实现或作用域。
关键区别在于:是否允许外部控制实例创建时机与策略。
- 用容器定义
shared: true(如 PHP-DI 的@Injectable(shared=true))比手写单例更可控 - 配置类可设计为不可变对象,通过构造注入一次,天然“单例语义”但无单例结构
- 连接池类本身不该是单例,而应作为服务注册进容器,由容器保证复用



















