在嵌入式 C 中使用 GoogleTest 和 GoogleMock 框架






4.83/5 (8投票s)
介绍在嵌入式环境中有效使用 Google 单元测试框架的技术。
引言
本文是关于单元测试的。尽管单元测试在各种编程语言中相当流行,但嵌入式 C 编程通常被忽视。这很讽刺,因为嵌入式程序员可能比其他任何人都能从单元测试中受益。如果您使用嵌入式系统,您一定知道调试代码有多难。您添加日志,连接 JTAG 仿真器,您会出汗,您会咒骂……如果这样,这篇文章就是为您而写的。试试吧。
在本文中,我使用 GoogletTest 和 GoogleMock 框架编写单元测试。我假设读者熟悉这些框架。否则,您应该先阅读 GoogleTest 和 GoogleMock 文档。虽然 GoogleTest 可以轻松地调整用于 C 测试,但 GoogleMock 对 C 程序员来说提供的东西很少。GoogleMock 框架是为模拟 C++ 接口而设计的,它依赖于 C 语言所缺乏的虚函数机制。没有模拟接口,单元测试就会变得非常有限。在本文中,我提出了两种不同的 C 函数模拟方法,然后将它们捆绑成一个解决方案,以解决大多数单元测试场景。本文还涵盖了一些设计方面,以帮助您编写单元测试。
我在本文中描述的技术并不新。其中一些是从“嵌入式 C 的测试驱动开发”一书中借鉴的。这本书是一本出色的 C 单元测试资源,但据我看来,它应该更详细地描述如何利用框架进行 C 函数模拟。互联网上有许多文章和框架试图解决 C 模拟问题,但我发现它们不完整。大多数仅限于 GNU/Linux 环境,并且没有提供实际的单元测试编写指南。此外,在本文中,我提出了一种我在任何其他框架中都未找到的 HOOK 模拟技术。
使用 GoogleTest 测试 C 函数
假设我们有一个简单的队列代码,我们想对其进行测试。代码位于 Queue.c 文件中。
#define QUEUE_SIZE 15
static int queue[QUEUE_SIZE];
static int head, tail, count;
void QueueReset()
{
head = tail = count = 0;
}
int QueueCount()
{
return count;
}
int QueuePush(int i)
{
if (count == QUEUE_SIZE)
return -1;
queue[tail] = i;
tail = (tail + 1) % QUEUE_SIZE;
count++;
return 0;
}
...
技巧很简单。 创建 C++ 文件进行测试后,将 C 文件包含进去,C 代码就可以被测试代码访问。下面是 QueueUnitTest.cpp 文件的代码。
#include "Queue.c"
TEST_F(QueueUnitTest, ResetTest)
{
EXPECT_EQ(0, QueuePush(1));
EXPECT_EQ(1, QueueCount());
QueueReset();
EXPECT_EQ(0, QueueCount());
}
TEST_F(QueueUnitTest, CountTest)
{
for (auto i = 0; i < QUEUE_SIZE; i++)
EXPECT_EQ(0, QueuePush(i));
EXPECT_EQ(QUEUE_SIZE, QueueCount());
}
...
为什么不包含 Queue.h 而包含 Queue.c 呢?的确,这将允许您调用 h 文件中声明的 C 函数,但您将无法访问 C 文件中声明的静态函数和静态全局变量。
现在,我们来讨论全局变量。C 语言没有类,但在设计良好的 C 程序中,代码被分成驻留在单独文件中的模块,全局变量通常被声明为 static
。因此,C 模块可以被视为 C++ 类的等价物,模块的全局变量类似于类的 私有 静态 字段。如果是这样,我们应该在运行测试之前初始化它们。考虑添加 ModuleNameInit
方法并将全局变量初始化放入其中。下面是 Queue.c 文件中此类初始化的示例。
void QueueInit()
{
head = tail = count = 0;
}
void QueueReset()
{
QueueInit();
}
...
现在,测试夹具类可以调用QueueInit 函数,为模块的测试做好准备。GoogleTest 框架为每个测试创建一个新的夹具对象,因此, QueueInit 函数将在每次测试执行前运行。
#include "Fixture.h"
#include "Queue.c"
class QueueUnitTest : public TestFixture
{
public:
QueueUnitTest()
{
QueueInit();
}
};
TEST_F(QueueUnitTest, ResetTest)
{
EXPECT_EQ(0, QueuePush(1));
EXPECT_EQ(1, QueueCount());
QueueReset();
EXPECT_EQ(0, QueueCount());
}
...
C 代码中的模拟
在上一节中,我们看到了如何测试 C 函数,但是如果我们测试的函数调用了我们模块之外的另一个函数怎么办?它可能是队列在推送操作时应调用的日志服务,或者 ,当队列已满时 ,需要发出 GPIO 信号。为了处理这种情况,我们需要创建模拟对象,并让代码调用模拟对象而不是生产代码。我发现两种有用的 C 函数模拟技术,我建议根据被模拟函数的性质来选择它们。
模拟服务
第一种技术建议将函数从代码中“剪切”出来,并用假的实现替换它们。这些假的实现又会调用相应的模拟对象。这种方法主要适用于硬件和操作系统服务,例如定时器、GPIO、操作系统日志、IPC 实体等。确实,这些类型的服务与您的应用程序代码无关,不应放在测试中。它们的假实现为您的应用程序提供了一种虚拟运行时环境。
假设我们有一个振荡器和 GPIO 寄存器,并用以下函数将它们封装起来。
int GetTime()
{
// Code that reads oscillator register
...
}
int ReadGpio(int line, int *value)
{
// Code that reads from GPIO line
...
}
int WriteGpio(int line, int value)
{
// Code that writes to GPIO line
...
}
这些函数应该在我们的测试代码中重新实现。这些 实现将调用相应的模拟对象。
int GetTime()
{
return GetMock<OscillatorMock>().GetTime();
}
int WriteGpio(int line, int value)
{
return GetMock<GpioMock>().WriteGpio(line, value);
}
int ReadGpio(int line, int *value)
{
return GetMock<GpioMock>().ReadGpio(line, value);
}
在上面的代码中,模拟对象是从存储所有模拟对象的单例中检索的。您将在接下来的章节中找到有关其工作原理的详细说明。
下面是一个调用服务的代码示例。代码位于 ShortCircuit.c 文件中。
void ShortCircuit(int timeout)
{
int startTime = GetTime();
while(GetTime() - startTime < timeout)
{
int signal;
ReadGpio(0, &signal);
WriteGpio(1, signal);
}
}
ShortCircuit
函数的单元测试如下所示。
#include "Fixture.h"
#include "ShortCircuit.c"
class ShortCircuitUnitTest : public TestFixture
{
public:
...
};
TEST_F(ShortCircuitUnitTest, ShortcutTest)
{
EXPECT_CALL(GetMock<OscillatorMock>(), GetTime()).Times(3)
.WillOnce(Return(10)).WillOnce(Return(100)).WillOnce(Return(111));
EXPECT_CALL(GetMock<GpioMock>(), ReadGpio(0, _)).Times(1)
.WillOnce(DoAll(SetArgPointee<1>(1), Return(0)));
EXPECT_CALL(GetMock<GpioMock>(), WriteGpio(1, 1)).Times(1);
int timeout = 100;
ShortCircuit(timeout);
}
模拟 C 模块
第二种模拟技术更适合模拟您自己的代码。假设有一个 C 模块使用了之前定义的队列代码。假设我们有 在接收到网络数据包时触发的代码,并且数据包的内容被推送到队列中(参见 Isr.c 文件)。
int OnDataReceived(int data)
{
if (QueuePush(data) == -1)
{
return -1;
}
return 0;
}
我们的目标是对队列模块函数设置期望。为此,我们在函数开头添加对相应模拟对象的调用。为此,我们将使用宏技巧。我知道,作为一名 C 程序员,您会欣赏宏技巧,但首先我们需要编写队列模拟类。为了方便访问和操作模拟对象,我们的模拟类必须继承自 ModuleMock
类。 ModuleMock
类将在本文的后面描述。
#include "Queue.h"
#include "ModuleMock.h"
class QueueMock : public ModuleMock
{
public:
MOCK_METHOD0(QueueReset, void());
MOCK_METHOD0(QueueCount, int());
MOCK_METHOD1(QueuePush, int(int));
MOCK_METHOD1(QueuePop, int(int*));
};
我们将放入队列代码中的宏在单元测试运行时调用相应的模拟,而在生产代码中什么也不做。我称这些宏为 HOOKs。
#if defined(__cplusplus)
#define MOCK_HOOK_P0(f) {return GetMock<MOCK_CLASS>().f();}
#define MOCK_HOOK_P1(f, p1) {return GetMock<MOCK_CLASS>().f(p1);}
#define MOCK_HOOK_P2(f, p1, p2) {return GetMock<MOCK_CLASS>().f(p1, p2);}
...
#else
#define MOCK_HOOK_P0(f)
#define MOCK_HOOK_P1(f, p1)
#define MOCK_HOOK_P2(f, p1, p2)
...
#endif
GetMock<MOCK_CLASS>()
返回对 MOCK_CLASS
类型模拟对象的引用。 MOCK_CLASS
是真实模拟类的占位符,需要在 QueueUnitTest.cpp 代码中使用 #define
指令进行定义。我们将在下一节中讨论它具体是如何工作的。
这是带有 HOOK 宏的 Queue.c 文件的代码。
#include "MockHooks.h"
...
void QueueReset()
{
MOCK_HOOK_P0(QueueReset);
QueueInit();
}
int QueueCount()
{
MOCK_HOOK_P0(QueueCount);
return count;
}
int QueuePush(int i)
{
MOCK_HOOK_P1(QueuePush, i);
...
return 0;
}
int QueuePop(int *i)
{
MOCK_HOOK_P1(QueuePop, i);
...
return 0;
}
最后,我们可以在我们的测试中设置期望(请参见 IsrUnitTest.cpp 文件)。
#include "Fixture.h"
#include "Isr.c"
class IsrUnitTest : public TestFixture
{
public:
IsrUnitTest()
{
IsrInit();
}
};
TEST_F(IsrUnitTest, OnDataReceivedPositiveTest)
{
EXPECT_CALL(GetMock<QueueMock>(), QueuePush(1)).Times(1).WillRepeatedly(Return(0));
EXPECT_EQ(0, OnDataReceived(1));
}
TEST_F(IsrUnitTest, OnDataReceivedNegativeTest)
{
EXPECT_CALL(GetMock<QueueMock>(), QueuePush(1)).Times(1).WillRepeatedly(Return(-1));
EXPECT_CALL(GetMock<GpioMock>(), WriteGpio(10, 1)).Times(1);
EXPECT_EQ(-1, OnDataReceived(1));
}
...
好吧,你喜欢它吗?你不喜欢?!我看到你对模拟函数直接嵌入到生产代码而不是 替换 它感到困惑。确实,我们在模拟硬件和操作系统服务时使用了假的实现,并且效果很好。都属实,但 HOOKs 有 几个优点。让我比较一下 HOOK 和 fake 方法。
- 编写和维护 HOOKs 比 fakes 容易得多。HOOKs 驻留在实现代码中,而 fakes 位于单独的文件中。每次更改函数签名时,都可以立即更新其 HOOK。此外,使用 HOOKs,生成的代码更紧凑,可读性更好。
- HOOKs 方法允许您通过将新的模块测试添加到同一个项目中来扩展您的测试。结果是,您的测试和生产代码项目看起来会非常相似。使用 fake 函数,您最终会得到多个测试项目,并且每个项目只测试少数模块。
- 假设您的代码中的每个模块都有自己的模拟类,那么如果某个函数从一个编译单元移动到另一个编译单元,HOOK 宏就会出现编译错误。这样您就不会忘记更新您的模拟类。对于 fake 函数,不会出现错误,测试代码会继续工作,但模拟类将不再反映真实的代码设计。
- 您可以配置给定模拟的 HOOKs,使其在模拟函数被测试命中后继续执行 C 函数。这样,您可以创建更复杂的场景,这些场景有点像集成测试(本文不讨论此功能,但您可以在附带的代码中找到它)。
- HOOKs 方法最大的缺点是您可能会忘记将 HOOK 添加到函数中。然后可能会发生两种情况:(a) 您的模拟期望将不被满足,您会发现 HOOK 丢失了 (b) 您的测试将继续执行生产代码。在测试中执行生产代码不是问题,只要您的测试不受此代码的影响。我相信,在大多数情况下(尤其是在您的项目中保持良好的封装性时),情况确实如此。无论如何,您必须养成一定的纪律,不要错过 HOOKs,但等等……您是一名嵌入式 C 程序员,如果您还没有学会如何有条理,您将无法长久保住工作。
我们需要解决的最后一个问题是模块全局变量的初始化。我之前已经展示了如何在测试夹具类中初始化全局变量。使用 C 模块 模拟引入了一个新挑战:测试还必须初始化被模拟模块的全局变量。同样,假设我们做出了正确的设计决策,为每个模块都有专用的模拟类,我们可以将对模块初始化函数的调用放入模拟构造函数中。
class QueueMock : public ModuleMock
{
public:
QueueMock()
{
Queue_InitGlobals();
}
...
}
这种全局变量初始化方法是 HOOKs 技术的巨大优势。对于 fake 函数,必须在测试代码中重新定义全局变量。确实,如果您的模块访问另一个模块的全局变量(不推荐,但有时不可避免),代码将无法编译,直到您将该变量添加到测试代码中。HOOKs 技术通过将原始模块的变量引入测试项目来解决此问题。
创建测试夹具
到目前为止,我们已经学会了如何在 C 测试中定义和使用 模拟。在本节中,我将展示如何管理这些模拟,以便它们可以轻松地插入到您的测试代码中。我们将从定义模板化夹具类开始,该类将模拟类作为模板参数。此代码位于 ModuleTest.h 文件中。
extern std::unique_ptr<ModuleMockBase> moduleTestMocks;
template<typename T>
struct ModuleTest : public ::testing::Test
{
ModuleTest()
{
moduleTestMocks.reset(new T);
}
virtual ~ModuleTest()
{
moduleTestMocks.reset();
}
};
ModuleTest
类设计为所有测试夹具类的基类。它在其构造函数中实例化模拟对象,并将其放入 moduleTestMocks
单例中。此单例位于 ModuleTest.cpp 文件中,其内容仅在给定测试运行时有效。 ModuleTest
类的析构函数重置 moduleTestMocks
智能指针并销毁模拟对象。 对象的销毁还会强制验证模拟期望(模拟期望在模拟析构函数中由 GooleMock 检查)。
正如您可能注意到的, moduleTestMocks
单例持有指向 ModuleMockBase
的指针,而我们尚未定义它。 ModuleMockBase
定义位于 ModuleMock.h 中,旨在作为我们所有模块模拟的基类。 除了 ModuleMockBase
之外,我们还定义了 ModuleMock
类,这将帮助我们确保在所有模拟类中只有一个 ModuleMockBase
副本。
struct ModuleMockBase
{
virtual ~ModuleMockBase() = default;
};
struct ModuleMock : public virtual ModuleMockBase
{
};
现在我们准备为我们的测试定义模拟类。首先,我们将为每个模块定义一个模拟类,然后将它们捆绑到一个模拟类中,以与 ModuleTest
模板一起使用。
class GpioMock : public ModuleMock
{
...
};
class OscillatorMock : public ModuleMock
{
...
};
class QueueMock : public ModuleMock
{
...
};
... more mock classes
struct Mocks
: public ::testing::NiceMock<GpioMock>
, public ::testing::NiceMock<OscillatorMock>
, public ::testing::NiceMock<QueueMock>
, ... // more mocks
{
};
由于所有模拟类都继承自 ModuleMock
,我们可以将它们存储在 moduleTestMocks
单例中 。为了提高测试的模块化程度,我建议为每个模块测试定义一个 Mocks
类,并仅将该模块使用的模拟放在其中。
最后,我们有了 TestFixture
结构,它只是一个语法糖。我为了写这篇文章而写了它,但您也可以在代码中跳过这个类,直接继承自 ModuleTest<Mocks>
。
struct TestFixture : public ModuleTest<Mocks>
{
};
我们几乎完成了。我们有了模拟,有了单例来存储它们,现在我们需要一种方便的方式来访问它们。这可以通过以下模板方法(位于 ModuleTest.h 文件中)轻松完成。
template<typename T>
static T& GetMock()
{
auto ptr = dynamic_cast<T*>(moduleTestMocks.get());
if (ptr == nullptr)
{
throw std::runtime_error(...);
}
return *ptr;
}
GetMock
方法允许我们检索 所请求类型的模拟对象。 正如您可能在 HOOK 宏定义中回忆到的,HOOK 会调用 GetMock
方法,并将 MOCK_CLASS
宏作为模板参数。 MOCK_CLASS
宏必须在包含 C 文件到您的测试中之前定义。例如,QueueUnitTest.cpp 文件应包含以下代码才能使 HOOKs 工作。
#define MOCK_CLASS QueueMock
#include "Queue.c"
一切完成,我们有了模拟,有了夹具,并且有了一种简单的设置期望的方式。将模拟存储在单例全局变量中是设计中的关键部分。这效果很好,因为在 C 中,只有自由函数被模拟,并且每个被模拟的模块在每个给定的测试中只呈现一个模拟对象实例。
扩展您的测试
正如我之前提到的,HOOKs 方法允许您持续地将新的 C 模块测试添加到测试项目中。在遗留项目中,将所有现有代码放在测试下可能是一项非常繁琐的任务。幸运的是,您不必立即这样做。一旦您选择了一个要测试的模块,跟踪它的依赖项,为依赖的模块创建存根实现,并在其中放置 HOOKs。下面是 stub.c 文件的内容示例。
#include "MockHooks.h"
void Foo()
{
MOCK_HOOK_P0(Foo);
}
然后,创建一个模拟类,并为存根实现添加一个空的单元测试。下面是 StubUnitTest.cpp 文件的内容。
#include "StubMock.h"
#define MOCK_CLASS StubMock
#include "Stub.c"
一旦您决定将实际模块作为被测对象,您将需要将 Stub.c 替换为包含指令中的实际模块文件。您还需要将 HOOKs 放入实际模块代码中。
代码示例
在随附的代码中,您会找到我没有在此文章中描述的一些额外内容。因此,我建议您在将所学技术应用到您自己的项目之前阅读代码。当然,从 README 文件开始阅读。
摘要
我希望本文能帮助您理解如何为嵌入式 C 单元测试利用 GoogleTest 和 GoogleMock 框架。所描述的技术并不难理解,但它们要求您具有严谨的态度和对软件设计的良好理解。尝试将这些技术应用到您代码的某些部分。您会惊讶于发现了多少愚蠢的错误,最重要的是,这将在代码在目标上运行之前发生。