GenTestAsm:在 nUnit 中运行您的 C++ 测试






4.46/5 (9投票s)
如何在 C++ 中编写单元测试并在 nUnit 中运行它们。

背景
自动化单元测试在 Java 世界变得非常流行,然后由于一个出色的工具 nUnit,它胜利地进入了 .NET 领域。
然而,nUnit 有一个严重的限制:它只适用于托管代码。老旧的 C++ 并没有消失,我们 C++ 程序员也希望享受美妙而简单的自动化单元测试。GenTestAsm
就是实现这一目标的工具。它允许您用(非托管)C++ 编写单元测试,然后在 nUnit 中运行它们。
单元测试 C++ 代码
当涉及到单元测试 C++ 代码时,基本上有三种选择
- 根本不进行单元测试。
- 使用专门为 C++ 设计的单元测试包,例如 TUT C++ 单元测试框架。
- 找到一种在 nUnit 中运行 C++ 测试的方法。
根本不进行单元测试是一种非常危险的方法。代码变得脆弱,修改的风险太高。TUT 是一个不错的工具,但它不提供像 nUnit 那样的 GUI 测试运行器。此外,必须在两个不同的工具之间切换来测试托管代码和非托管代码似乎很麻烦。
因此,我专注于最后一种方法——找到一种在 nUnit 中运行 C++ 测试的方法。
作战计划
总体的作战计划如下
- 通过 DLL 导出使非托管测试可从外部调用。
- 编写一个工具,它接收一个非托管 DLL,枚举其导出,并生成一个可由 nUnit 加载的托管程序集。我把这个工具命名为
GenTestAsm
。 - 对于每个导出的非托管函数,自动创建一个用
[Test]
属性标记的托管方法。 - 托管方法通过 P/Invoke 调用非托管函数。
枚举 DLL 导出
遗憾的是,Win32 没有提供开箱即用的 API 来枚举 DLL 导出。幸运的是,DLL 文件的格式可以从 Microsoft 公开获取。我通过打开可执行文件并分析字节来提取导出列表。这有点繁琐,但并不是一个非常复杂的任务。最大的烦恼是 PE 文件格式使用相对虚拟内存地址 (RVA) 而不是文件偏移量。这在文件加载到内存中时很棒,但在处理磁盘上的文件时需要不断重新计算。
生成测试程序集
为了生成测试程序集,我首先创建 C# 源代码,然后使用 CSharpCodeProvider
类对其进行编译。这被证明比通过 CodeDOM 构建代码更简单、更直接。这也更容易测试。如果生成的程序集出现问题,总可以查看生成的源代码并扫描异常。我为 GenTestAsm
添加了一个选项,该选项输出生成的源代码而不是编译后的二进制文件。
测试导出与其它导出
一个包含非托管测试的 DLL 确实可能导出不是测试的函数。当 GenTestAsm
创建托管包装器时,它需要知道哪些导出是测试,哪些不是。nUnit 使用属性将测试与非测试分开,但在非托管世界中没有属性。我决定改用一个简单的命名约定。GenTestAsm
仅为名称以特定前缀(默认为 UnitTest
)开头的导出生成托管测试包装器。其他导出将被忽略。
测试结果
下一个问题是如何处理测试失败。在 nUnit 世界中,如果一个测试成功运行完成,通常被认为是成功的;如果它抛出异常,则被认为是失败的。由于我的测试是用非托管 C++ 编写的,它们的异常将是非托管 C++ 异常。我不能让这些异常泄漏到托管包装器中。因此,我需要一些其他机制来报告测试失败。我决定使用测试的返回值。非托管测试必须具有以下签名
BSTR Test();
返回值为 NULL
表示成功,其他任何值都表示失败,返回的 string
是错误消息。我选择 BSTR
而不是普通的 char*
,因为 BSTR
具有明确定义的内存管理规则,并且 .NET 运行时知道如何释放它。
编写一个简单的测试
从 C++ 测试返回 BSTR
很好,但这使得编写测试有点困难。测试的作者必须确保未处理的 C++ 异常不会逃逸测试。他还必须格式化错误消息并将其转换为 BSTR
。如果这在每个测试中都手动完成,代码会变得过于冗长,不切实际。让我们看一个 C# 中的简单测试
// C#
public void CalcTest()
{
Assert.AreEqual( 4, Calculator.Multiply(2,2) );
}
看看 C++ 中等效的测试会是什么样子
// C++
__declspec(dllexport)
BSTR CalcTest()
{
try
{
int const expected = 4;
int actual = Calculator::Multiply(2,2);
if (expected != actual)
{
std::wostringstream msg;
msg << "Error in " << __FILE__ << " (" << __LINE__ << "): "
<< "expected " << expected << ", but got " << actual;
return SysAllocString( msg.str().c_str() );
}
}
catch (...)
{
return SysAllocString("Unknown exception");
}
return NULL;
}
这有太多的样板代码。我们需要一个支持库。
支持库
借助一个微小的 #include
文件,我们可以将我们的 C++ 测试缩减回 3 行代码
// C++
#include "TestFramework.h"
TEST(CalcTest)
{
ASSERT_EQUAL( 4, Calculator::Multiply(2,2) );
}
TestFramework.h 定义了 TEST
宏,它封装了异常处理和 BSTR
转换的细节。它还定义了几个 ASSERT
宏,例如 ASSERT_EQUAL
。
大锁定
然而,有一个问题。正如你所记得的,我使用 P/Invoke 来调用我的非托管测试。在内部,P/Invoke 会加载非托管 DLL 并保持其加载状态,直到托管进程退出。换句话说,如果我盲目地使用 P/Invoke,一旦你执行了测试,你的托管 DLL 就会被锁定。你将无法重新编译它,直到你关闭 nUnit GUI。这是一个令人不快的速度障碍。
一种解决方案
当然,GenTestAsm
可以不直接调用非托管 DLL,而是调用 LoadLibrary()
,然后调用 GetProcAddress()
。然后它可以执行 Marshal.GetDelegateForFunctionPointer()
并调用生成的委托。问题是,此 API 仅在 .NET 2.0 中可用。我希望 GenTestAsm
与 .NET 1.1 兼容,所以我不得不寻找不同的解决方案。
另一种解决方案
如果某些东西必须永久加载,那就让它不是测试 DLL,而是其他永不更改的辅助 DLL。当前版本的 GenTestAsm
P/Invokes 到非托管辅助(thunk),然后调用 LoadLibrary()
、GetProcAddress()
和 FreeLibrary()
。这样,被锁定的是 thunk,而实际的测试 DLL 保持自由。
// C++
typedef BSTR (*TestFunc)();
extern "C"
__declspec(dllexport)
BSTR __cdecl RunTest( LPCSTR dll, LPCSTR name )
{
HMODULE hLib = LoadLibrary(dll);
if (hLib == NULL) return SysAllocString(L"Failed to load test DLL");
TestFunc func = (TestFunc)GetProcAddress(hLib, name);
if (func == NULL) return SysAllocString(L"Entry point not found");
BSTR result = func();
FreeLibrary(hLib);
return result;
}
我将 thunk DLL 作为资源放入 GenTestAsm.exe,并且它总是与生成的托管程序集一起写入。有两个额外的 DLL 文件存在有点烦人,但这总比无法重新编译代码要好。
指定 nUnit 版本
GenTestAsm
创建托管测试程序集的 C# 源代码,然后使用 .NET Framework C# 编译器编译它。测试程序集引用 nunit.framework.dll。此 DLL 的位置在 gentestasm.exe.config 文件中指定,如下所示
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
<appSettings>
<add key="nUnit.Reference"
value="C:\Program Files\NUnit 2.2\bin\nunit.framework.dll" />
</appSettings>
</configuration>
使用 .NET Framework 2.0 的 nUnit
如果您使用适用于 .NET 2.0 的 nUnit,GenTestAsm
可能会遇到问题。在创建托管程序集时,您可能会收到以下错误
fatal error CS0009: Metadata file
'c:\Program Files\NUnit-Net-2.0 2.2.8\bin\nunit.framework.dll'
could not be opened -- 'Version 2.0 is not a compatible version.'
出现此错误是因为 GenTestAsm
是一个 .NET 1.1 应用程序,并且默认情况下使用 .NET 1.1 C# 编译器(如果可用)。此编译器无法引用为较新版本的 Framework 创建的程序集。为了解决此问题,我们必须强制 GenTestAsm
使用 .NET 2.0 库,包括 .NET 2.0 C# 编译器。这可以通过在配置文件中添加 supportedRuntime
元素来实现
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
<appSettings>
<add key="nUnit.Reference"
value="C:\Program Files\NUnit-Net-2.0 2.2.8\bin\nunit.framework.dll" />
</appSettings>
<startup>
<supportedRuntime version="v2.0.50727"/>
</startup>
</configuration>
结论
总而言之,GenTestAsm
是一个工具,允许在流行的 nUnit 环境中运行非托管(通常是 C++)测试。一个小的支持库为非托管测试的作者提供了基本的断言功能,类似于 nUnit 的断言功能。有了 GenTestAsm
,团队可以采用更统一的方法来对托管代码和非托管代码进行单元测试。使用相同的工具来运行测试,并且测试语法相似。