在没有宏和动态内存的C ++中进行测试

许多流行的测试库(例如Google Test,Catch2,Boost.Test)都与宏的使用紧密相关,因此,作为对这些库进行测试的示例,通常会看到如下图:


namespace { // Tests the default c'tor. TEST(MyString, DefaultConstructor) { const MyString s; EXPECT_STREQ(nullptr, s.c_string()); EXPECT_EQ(0u, s.Length()); } const char kHelloString[] = "Hello, world!"; // Tests the c'tor that accepts a C string. TEST(MyString, ConstructorFromCString) { const MyString s(kHelloString); EXPECT_EQ(0, strcmp(s.c_string(), kHelloString)); EXPECT_EQ(sizeof(kHelloString)/sizeof(kHelloString[0]) - 1, s.Length()); } // Tests the copy c'tor. TEST(MyString, CopyConstructor) { const MyString s1(kHelloString); const MyString s2 = s1; EXPECT_EQ(0, strcmp(s2.c_string(), kHelloString)); } } // namespace 

C ++中的宏很警惕,为什么它们在库中如此兴旺以创建测试?


单元测试库应该为用户提供一种编写测试的方法,以便测试运行时可以某种方式查找并执行它们。 当您考虑如何执行此操作时,使用宏似乎是最简单的。 TEST()宏通常会以某种方式定义一个函数(对于Google Test,该宏也会创建一个类),并确保该函数的地址进入某个全局容器。


在其中没有实现单个宏的方法的著名库是tut-framework 。 让我们从教程中查看她的示例:


 #include <tut/tut.hpp> namespace tut { struct basic{}; typedef test_group<basic> factory; typedef factory::object object; } namespace { tut::factory tf("basic test"); } namespace tut { template<> template<> void object::test<1>() { ensure_equals("2+2=?", 2+2, 4); } } 

底层的想法非常有趣并且有效,并不是很难。 简而言之,您有一个基类,该基类实现了一个模板函数,该模板函数涉及使用整数进行参数化:


 template <class Data> class test_object : public Data { /** * Default do-nothing test. */ template <int n> void test() { called_method_was_a_dummy_test_ = true; } } 

现在,当您编写这样的测试时:


 template<> template<> void object::test<1>() { ensure_equals("2+2=?", 2+2, 4); } 

实际上,您为N = 1的特定数字创建了一个测试方法专业化(这正是template<>template<>代表的意思)。 通过调用test<N>()测试运行时可以了解执行测试后查看的是called_method_was_a_dummy_test_值是真实测试还是存根。


接下来,当您声明一个测试组时:


 tut::factory tf("basic test"); 

首先,将所有test<N>枚举到连接到库的某个常数,其次,副作用是,将有关组的信息添加到全局容器中(所有测试函数的组名和地址)。


异常在tut中用作测试条件,因此如果传递给它的两个值不相等,则tut::ensure_equals()函数将仅引发异常,并且测试运行环境将捕获异常并将测试视为失败。 我喜欢这种方法,对于任何可以使用此类断言的C ++开发人员来说,它立即变得清晰。 例如,如果我的测试创建了一个辅助线程,那么将断言放在那里是没有用的,没有人会抓住它们。 另外,对我来说很明显,在发生异常的情况下,我的测试应该能够释放资源,就好像它是普通的异常安全代码一样。


原则上,tut-framework库看起来不错,但是其实现存在一些缺点。 例如,对于我的情况,我希望测试不仅具有数字,而且还具有其他属性,尤其是名称,以及测试的“大小”(例如,它是集成测试还是单元测试)。 这可以在API tut的框架内解决,甚至已经存在,并且如果您向库API添加方法并将其调用到测试主体以设置其任何参数,则可以实现某些目的:


 template<> template<> void object::test<1>() { set_name("2+2"); // Set test name to be shown in test report ensure_equals("2+2=?", 2+2, 4); } 

另一个问题是tut测试运行环境对诸如测试开始之类的事件一无所知。 环境执行object::test<N>()并且它不预先知道是否针对给定的N实现了测试,还是只是存根。 她仅通过分析called_method_was_a_dummy_test_的值来called_method_was_a_dummy_test_测试何时called_method_was_a_dummy_test_ 。 此功能在CI系统中无法很好地显示出来,因为CI系统能够将程序在测试开始和结束之间进行的输出分组。


但是,我认为可以改进的主要问题(“致命缺陷”)是编写测试需要额外的辅助代码。 tut-framework教程中有很多内容:建议首先创建一个特定的类struct basic{} ,并将测试描述为与此相关的对象的方法。 在此类中,您可以定义要在测试组中使用的方法和数据,并且构造函数和析构函数构成执行测试的框架,从而从jUnit创建固定装置。 在我对tut的实践中,该对象几乎总是空的,但是它沿着一定数量的代码行拖动。


因此,我们去了自行车车间,并尝试以一个小型图书馆的形式来安排这个想法。


这是最小的测试文件在被测试的库中的样子:


 // Test group for std::vector (illustrative purposes) #include "tested.h" #include <vector> template<> void tested::Case<CASE_COUNTER>(tested::IRuntime* runtime) { runtime->StartCase("emptiness"); std::vector<int> vec; tested::Is(vec.empty(), "Vector must be empty by default"); } template<> void tested::Case<CASE_COUNTER>(tested::IRuntime* runtime) { runtime->StartCase("AddElement"); std::vector<int> vec; vec.push_back(1); tested::Is(vec.size() == 1); tested::Is(vec[0] == 1); tested::FailIf(vec.empty()); } void LinkVectorTests() { static tested::Group<CASE_COUNTER> x("std.vector", __FILE__); } 

除了缺少宏之外,好处还在于库内部缺少动态内存。


测试用例的定义


为了进行测试注册,入门级基本魔法的使用原理与tut相同。 在test.h的某个地方有这种样板函数:


 template <int N> static void Case(IRuntime* runtime) { throw TheCaseIsAStub(); } 

库用户编写的测试用例只是该方法的专业化。 该函数被声明为静态,即 在每个翻译单元中,我们创建的专业化名称在链接期间不会彼此相交。


有这样一条规则,首先需要调用StartCase() ,您可以向其传递诸如测试名称之类的信息,以及可能仍在开发中的其他事物。


当测试调用runtime->StartTest() ,可能会发生有趣的事情。 首先,如果测试现在处于运行模式,则可以告诉某个地方该测试已开始执行。 其次,如果存在一种收集有关可用测试信息的模式,则StartTest()引发一种特殊类型的异常,这将意味着测试是真实的,而不是存根。


报名


在某个时候,您需要收集所有测试用例的地址并将它们放在某个地方。 在测试中,这是使用组完成的。 被测试的:: Group类的构造函数这样做是有副作用的:


 static tested::Group<CASE_COUNTER> x("std.vector", __FILE__); 

构造函数使用指定的名称创建一个组,并将在当前翻译单元中找到的所有Case<N> case添加到其中。 事实证明,在一个翻译单元中,您不能有两组。 这也意味着您不能将一组划分为几个翻译单元。


模板的参数是要为创建的组在当前翻译单元中查找多少个测试用例。


友情链接


在上面的示例中,创建测试的:: Group()对象发生在我们必须从应用程序调用以注册测试的函数内部:


 void LinkStdVectorTests() { static tested::Group<CASE_COUNTER> x("std.vector", __FILE__); } 

一个功能并不总是必需的,有时您可以在文件内简单声明一个tested::Group类的对象。 但是,我的经验是,如果链接器在库中汇编,则链接器有时会“优化”整个文件,并且主应用程序均未使用此cpp文件中的任何字符:


 calc.lib <- calc_test.lib(calc_test.cpp) ^ ^ | | app.exe run_test.exe 

当未从run_test.exe源链接calc_test.cpp时,尽管链接器具有我们需要的副作用,但链接器只是将其从考虑中完全删除,并创建了一个静态对象。


如果是从run_test.exe生成哪个链,则静态对象将出现在可执行文件中。 并没有什么关系,如示例所示:


 void LinkStdVectorTests() { static tested::Group<CASE_COUNTER> x("std.vector", __FILE__); } 

左右:


 static tested::Group<CASE_COUNTER> x("std.vector", __FILE__); void LinkStdVectorTests() { } 

我认为第一种选择更好,因为构造函数是在main()开始之后调用的,并且应用程序对此过程具有一定的控制权。


我认为,使用全局变量和构造函数的副作用来创建测试数据库的任何单元测试库都必须使用拐杖设置。 但是,可以通过将测试库与--whole-archive键链接起来来避免这种情况(MSVC中的类似物仅在Visual Studio 2015.3中出现)。


巨集


我保证不会有宏,但它是CASE_COUNTER 。 工作选项是由__COUNTER__使用, __COUNTER__是一个宏,每次在翻译单元中使用编译器时,编译器将其递增一。
由GCC,CLANG,MSVC支持,但不受标准支持。 如果这令人沮丧,请使用以下替代方法:


  • 使用数字0、1、2
  • 使用标准__LINE__
  • 使用80级的constexpr魔术。 您可以搜索“ constexpr计数器”,然后尝试查找将在其上运行的编译器。

__LINE__的问题在于,在模板选项中使用大量数字会创建较大的可执行文件。 这就是为什么我将带符号的字符模式的类型限制为128个作为组中最大测试数的原因。


动态内存故障


原来,在注册测试时,不能使用我使用的动态内存。 您的环境可能没有动态内存,或者您在测试用例中使用搜索内存泄漏,因此不需要测试执行环境。 Google测试正为此而苦苦挣扎,以下是其中的摘录:


 // Use the RAII idiom to flag mem allocs that are intentionally never // deallocated. The motivation is to silence the false positive mem leaks // that are reported by the debug version of MS's CRT which can only detect // if an alloc is missing a matching deallocation. // Example: // MemoryIsNotDeallocated memory_is_not_deallocated; // critical_section_ = new CRITICAL_SECTION; class MemoryIsNotDeallocated 

我们根本无法制造困难。


那我们如何获得测试清单? 这些是技术性更强的内部组件,在源代码中更容易看到,但无论如何我都会告诉您。


创建组时,其类将收到指向tested::CaseCollector<CASE_COUNTER>::collect函数的指针,该函数会将所有翻译单元测试收集到一个列表中。 运作方式如下:


 // Make the anonymouse namespace to have instances be hidden to specific translation unit namespace { template <Ordinal_t N> struct CaseCollector { // Test runtime that collects the test case struct CollectorRuntime final : IRuntime { void StartCase(const char* caseName, const char* description = nullptr) final { // the trick is exit from test case function into the collector via throw throw CaseIsReal(); } }; // Finds the Case<N> function in current translation unit and adds into the static list. It uses the // reverse order, so the case executed in order of appearance in C++ file. static CaseListEntry* collect(CaseListEntry* tail) { CaseListEntry* current = nullptr; CollectorRuntime collector; try { Case<N>(&collector); } catch (CaseIsStub) { current = tail; } catch (CaseIsReal) { s_caseListEntry.CaseProc = Case<N>; s_caseListEntry.Next = tail; s_caseListEntry.Ordinal = N; current = &s_caseListEntry; } return CaseCollector<N - 1>::collect(current); } private: static CaseListEntry s_caseListEntry; }; // This static storage will be instantiated in any cpp file template <Ordinal_t N> CaseListEntry CaseCollector<N>::s_caseListEntry; } 

事实证明,在每个转换单元中,创建了CaseListEntry CaseCollector \ :: s_caseListEntry类型的许多静态变量,它们是测试列表的元素,并且collect()方法将这些元素收集在单个连接的列表中。 列表以几乎相同的方式形成测试组,但没有模式和递归。


结构形式


测试需要不同的绑定,例如以红色字母Failed输出到控制台,以CI或GUI可以理解的格式创建测试报告,您可以在其中查看测试列表并运行选定的测试-通常,很多事情。 我对如何做到这一点抱有幻想,这与我先前在测试库中看到的有所不同。 该声明主要针对的是自称“仅标头”的库,同时包含大量代码,这些代码实际上不适用于标头文件。


我假设的方法是将库分成前端库(test.h和后端库本身)。 要编写测试,您只需要test.h,它现在是C ++ 17(由于std :: std :: string_view),但是假设会有C ++ 98。 Tested.h实际上执行注册和搜索测试,一个最不方便的启动选项以及导出测试(组,测试用例函数的地址)的能力。 在输出结果和使用导出功能启动方面,尚不存在的后端库可以执行所需的任何操作。 同样,您可以使启动适应项目的需要。


总结


经过测试的库( github代码 )仍然需要一些稳定。 在不久的将来,添加运行异步测试(WebAssembly中的集成测试所需)的功能,并指明测试的大小。 我认为图书馆还没有准备好用于生产,但是我突然花了很多时间,舞台已经停下来,屏住呼吸,并要求社区提供反馈。 您对使用这种库感兴趣吗? 也许在C ++库中还有其他想法,因为有可能创建没有宏的库? 这样的问题陈述是否很有趣?

Source: https://habr.com/ru/post/zh-CN434906/


All Articles