You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何在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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.05.09 10:03:03