如何在C++单元测试中Mock SDL方法以测试游戏循环?
如何Mock SDL函数并隔离依赖进行轻量化单元测试
这是游戏开发单元测试里非常常见的场景——咱们要把SDL这种外部依赖和核心业务逻辑拆解开,这样测试时完全不用碰真实的SDL库,也不会触发任何窗口渲染相关的代码。下面给你几个实用的方案,按代码设计的优雅程度排序:
1. 依赖注入(最推荐,长期维护友好)
核心思路是把SDL的时间相关操作抽象成一个接口,业务代码依赖这个接口而非具体的SDL实现。测试时传入Mock版本的接口,就能完全控制时间流。
步骤1:定义抽象接口
先创建一个Clock接口,封装你用到的SDL时间操作:
// Clock.h #ifndef CLOCK_H #define CLOCK_H #include <cstdint> namespace Rival { class Clock { public: virtual ~Clock() = default; virtual std::uint32_t getTicks() const = 0; virtual void delay(std::uint32_t ms) const = 0; }; } // namespace Rival #endif // CLOCK_H
步骤2:实现真实SDL版本
在应用项目里写一个基于SDL的实现:
// SdlClock.h #ifndef SDL_CLOCK_H #define SDL_CLOCK_H #include "Clock.h" namespace Rival { class SdlClock : public Clock { public: std::uint32_t getTicks() const override; void delay(std::uint32_t ms) const override; }; } // namespace Rival #endif // SDL_CLOCK_H
// SdlClock.cpp #include "SdlClock.h" #include <SDL.h> namespace Rival { std::uint32_t SdlClock::getTicks() const { return SDL_GetTicks(); } void SdlClock::delay(std::uint32_t ms) const { SDL_Delay(ms); } } // namespace Rival
步骤3:修改Application依赖抽象
把原来直接调用SDL的代码改成依赖Clock接口,通过构造函数注入:
// Application.h #ifndef APPLICATION_H #define APPLICATION_H #include "Clock.h" #include <memory> namespace Rival { class Application { public: // 默认使用SDL时钟,测试时可传入Mock时钟 explicit Application(std::unique_ptr<Clock> clock = std::make_unique<SdlClock>()) : clock(std::move(clock)) {} void start(); // 测试用:添加控制循环退出的接口 void setExiting(bool shouldExit) { exiting = shouldExit; } private: std::unique_ptr<Clock> clock; bool exiting = false; GameState* state = nullptr; // 假设你已有这个成员 }; } // namespace Rival #endif // APPLICATION_H
更新start方法里的调用:
// Application.cpp #include "pch.h" #include "Application.h" #include "TimerUtils.h" // 假设你有这个头文件 namespace Rival { void Application::start() { std::uint32_t nextUpdateDue = clock->getTicks(); while (!exiting) { std::uint32_t frameStartTime = clock->getTicks(); if (nextUpdateDue <= frameStartTime) { while (nextUpdateDue <= frameStartTime) { state->update(); nextUpdateDue += TimerUtils::timeStepMs; } state->render(); } else { std::uint32_t sleepTime = nextUpdateDue - frameStartTime; clock->delay(sleepTime); } } } } // namespace Rival
步骤4:编写Mock Clock进行测试
用Catch2配合Google Mock(或者自己写简单Mock)来创建可控的时钟:
// MockClock.h #ifndef MOCK_CLOCK_H #define MOCK_CLOCK_H #include "Clock.h" #include <gmock/gmock.h> namespace Rival { class MockClock : public Clock { public: MOCK_METHOD(std::uint32_t, getTicks, (), (const, override)); MOCK_METHOD(void, delay, (std::uint32_t ms), (const, override)); }; } // namespace Rival #endif // MOCK_CLOCK_H
然后在测试用例里控制时间:
#include <catch2/catch_test_macros.hpp> #include <memory> #include "Application.h" #include "MockClock.h" TEST_CASE("Game loop updates logic at correct intervals", "[application]") { auto mockClock = std::make_unique<MockClock>(); Rival::Application app(std::move(mockClock)); auto& clock = dynamic_cast<Rival::MockClock&>(*app.clock); // 或者给Application加getClock()方法 // 模拟时间流逝:初始0ms,然后16ms,然后32ms(假设timeStepMs是16) EXPECT_CALL(clock, getTicks()) .WillOnce(testing::Return(0)) .WillOnce(testing::Return(16)) .WillOnce(testing::Return(32)); // 测试单步循环,避免无限阻塞 app.setExiting(true); app.start(); // 验证state->update()被调用了2次(0→16→32,两次时间步) EXPECT_CALL(*app.state, update()).Times(2); }
2. 预处理器宏替换(快速临时方案)
如果不想大改现有代码,可以在测试项目里用宏替换SDL的函数。在测试项目的全局头文件里(要确保在Application.h之前包含)添加:
// test_config.h #define SDL_GetTicks() mock_GetTicks() #define SDL_Delay(ms) mock_Delay(ms) #include <cstdint> namespace Rival { inline std::uint32_t mockTicks = 0; inline std::uint32_t mock_GetTicks() { return mockTicks; } inline void mock_Delay(std::uint32_t ms) { mockTicks += ms; // 测试时直接推进时间,不用真 sleep } } // namespace Rival
然后在测试用例里控制mockTicks的值:
TEST_CASE("Game loop handles sleep correctly", "[application]") { Rival::mockTicks = 0; Rival::Application app; // 模拟第一次调用SDL_GetTicks返回0,第二次返回10(timeStepMs是16,需要sleep6ms) Rival::mockTicks = 0; // 执行部分循环逻辑... Rival::mockTicks = 10; // 验证SDL_Delay被调用了6ms... }
这种方案优点是不用改业务代码,但缺点是依赖预处理器,容易出现宏冲突,且全局mockTicks在多测试用例间需要手动重置,不太优雅。
3. 链接时替换(适合C风格函数)
因为SDL的函数是C风格的,你可以在测试项目里实现同名的SDL_GetTicks和SDL_Delay,链接时测试项目的实现会覆盖SDL库的版本:
// sdl_mock.cpp extern "C" { #include <cstdint> static std::uint32_t mockTicks = 0; std::uint32_t SDL_GetTicks() { return mockTicks; } void SDL_Delay(std::uint32_t ms) { mockTicks += ms; } } // extern "C"
这种方式同样不用改业务代码,但和宏替换一样有全局状态的问题,适合快速测试简单场景,长期维护不如依赖注入。
额外建议
- 把
start方法里的无限循环拆成可调用的单步方法(比如updateLoopStep()),这样测试时可以逐步验证逻辑,不用处理循环阻塞的问题。 - 对于
exiting变量,要么像上面那样加测试接口,要么也通过抽象(比如InputHandler)来控制,让测试能干净地终止循环。
内容的提问来源于stack exchange,提问作者Dan
相关产品推荐
相关产品推荐

