如何替换单例设计模式?TimerHandler单例引发单元测试难题求解
这种单例硬编码依赖导致的测试难题确实很常见,尤其是当你要测试依赖Timer的其他类时,单例的全局状态很容易让测试用例互相干扰。我分享几个不用大规模修改现有代码就能解决问题的思路:
1. 给单例TimerHandler添加测试替换接口
既然TimerHandler是单例,我们可以给它加一个仅测试环境使用的接口,允许替换全局实例为Mock版本。这样Timer构造时依然会调用RegisterTimer,但实际注册到的是我们控制的Mock实例。
示例代码:
class TimerHandler { private: static TimerHandler* instance_; public: static TimerHandler& GetInstance() { if (!instance_) { instance_ = new TimerHandler(); } return *instance_; } // 测试专用:替换全局实例 static void SetTestInstance(TimerHandler* test_instance) { // 清理原有实例(如果需要) delete instance_; instance_ = test_instance; } // 测试专用:恢复默认实例 static void ResetToDefault() { delete instance_; instance_ = nullptr; // 下次GetInstance会重建默认实例 } // 原有的RegisterTimer方法 void RegisterTimer(Timer& timer) { /* ... */ } };
测试时的用法:
TEST(SomeClassTest, UsesTimerCorrectly) { // 创建Mock TimerHandler auto mock_handler = new MockTimerHandler(); TimerHandler::SetTestInstance(mock_handler); // 执行测试逻辑,比如创建依赖Timer的SomeClass实例 SomeClass obj; // ... 验证Mock的交互 ... // 测试结束后恢复默认实例,避免影响其他用例 TimerHandler::ResetToDefault(); }
这个方案对原有代码的改动极小,只需要给TimerHandler加两个静态方法,完全不影响生产环境的逻辑。
2. 扩展Timer的构造函数,支持可选的Handler注入
给Timer加一个带默认参数的构造函数,默认使用单例TimerHandler,但测试时可以传入自定义的Handler。这样原有代码不需要任何修改,测试时灵活注入Mock。
示例代码:
class Timer { public: // 默认参数保持原有行为,兼容所有旧代码 Timer(TimerHandler& handler = handlers::TimerHandler::GetInstance()) : tick_counter_(0), target_tick_(0), increment_(false), started_(false), elapsed_(false) { handler.RegisterTimer(*this); } // 原有的其他成员... private: int tick_counter_; int target_tick_; bool increment_; bool started_; bool elapsed_; };
测试时直接传入MockHandler:
TEST(SomeClassTest, HandlesTimerElapsed) { MockTimerHandler mock_handler; Timer timer(mock_handler); // 注册到Mock SomeClass obj(timer); // 假设SomeClass依赖Timer // 模拟Timer触发,验证obj的行为... }
这个方案的优点是完全不污染生产代码,只是对Timer做了小扩展,测试时按需注入,非常灵活。
3. 用工厂类封装Timer的创建与注册
把Timer的创建和注册逻辑抽离到一个工厂类中,生产环境使用默认工厂(绑定单例TimerHandler),测试环境使用Mock工厂(绑定MockHandler)。这样依赖Timer的类只需要依赖工厂,而不是直接创建Timer。
示例代码:
// 抽象工厂接口 class TimerFactory { public: virtual ~TimerFactory() = default; virtual std::unique_ptr<Timer> CreateTimer() = 0; }; // 生产环境用的默认工厂 class DefaultTimerFactory : public TimerFactory { public: std::unique_ptr<Timer> CreateTimer() override { auto timer = std::make_unique<Timer>(); // 注意:这里需要修改Timer,去掉构造时的自动注册,由工厂负责注册 handlers::TimerHandler::GetInstance().RegisterTimer(*timer); return timer; } }; // 测试用的Mock工厂 class MockTimerFactory : public TimerFactory { private: MockTimerHandler& mock_handler_; public: MockTimerFactory(MockTimerHandler& handler) : mock_handler_(handler) {} std::unique_ptr<Timer> CreateTimer() override { auto timer = std::make_unique<Timer>(); mock_handler_.RegisterTimer(*timer); return timer; } };
然后修改依赖Timer的类,让它依赖TimerFactory而不是直接new Timer:
class SomeClass { public: // 构造函数注入工厂,默认用全局的默认工厂 SomeClass(std::unique_ptr<TimerFactory> factory = std::make_unique<DefaultTimerFactory>()) : timer_(factory->CreateTimer()) {} private: std::unique_ptr<Timer> timer_; };
测试时注入Mock工厂:
TEST(SomeClassTest, TimerTriggersAction) { MockTimerHandler mock_handler; auto mock_factory = std::make_unique<MockTimerFactory>(mock_handler); SomeClass obj(std::move(mock_factory)); // 模拟Timer事件,验证obj的响应... }
这个方案稍微改动大一点,但好处是把依赖关系解耦得更彻底,不仅解决了测试问题,也让代码的扩展性更好。
4. 编译期条件编译(不推荐但应急可用)
如果不想改太多代码,可以用编译宏区分测试和生产环境,让Timer在测试时注册到测试用的Handler。
示例代码:
Timer::Timer() : tick_counter_(0), target_tick_(0), increment_(false), started_(false), elapsed_(false) { #ifdef UNIT_TEST // 测试环境下注册到测试Handler TestTimerHandler::GetInstance().RegisterTimer(*this); #else // 生产环境正常注册 handlers::TimerHandler::RegisterTimer(*this); #endif }
这个方案的缺点是代码里混入了测试相关的条件编译,长期来看不太优雅,但如果只是临时应急,改动最小。
个人最推荐方案2或方案1,它们对现有代码的侵入最小,同时能完美解决测试时的依赖问题。
内容的提问来源于stack exchange,提问作者Jepsson

