使用c++分层命名空间解析为单元测试提供模拟组件是一种好做法吗?< / h1 >

Is it good practice to use C++ hierarchical namespace resolution to provide mock components for unit tests?

本文关键字:一种 gt lt h1 命名空间 c++ 分层 单元测试 组件 模拟 使用      更新时间:2023-10-16

一个典型的用例将是在其实现中使用boost::filesystem的组件(例如下面示例中的testable::MyFolder)。单元测试该组件将需要模拟boost::filesystem的部分。模拟boost::filesystem的一种方法是在也包含MyFolder的名称空间中实现模拟组件(例如在示例中的testable名称空间中),并依靠分层名称空间解析在编译时将boost::filesystem组件替换为其模拟对应项。

例如:

in fileMyFolder.hh:

#include <boost/filesystem.hpp>
#include <boost/exception/all.hpp>
namespace testable
{
struct SomeError: public std::exception {};
struct MyFolder
{
MyFolder(const boost::filesystem::path &p)
{
if (!exists(p)) // must be resolved by ADL for unit-tests
{
BOOST_THROW_EXCEPTION(SomeError());
}
}
};
} // namespace testable

in fileMockFilesystem.hh:

#include <string>
namespace testable
{
namespace boost
{
namespace filesystem
{
struct path
{
path(const std::wstring &) {}
};
bool exists(const path&)
{
return false;
}
} // namespace filesystem
} // namespace boost
} // namespace testable

in filetestMyFolder.cpp:

#include "MockFilesystem.hh" // provides boost::filesystem mocks for MyFolder
#include "MyFolder.hh"
#include <cppunit/ui/text/TestRunner.h>
#include <cppunit/extensions/HelperMacros.h>
class TestMyFolder : public CppUnit::TestFixture
{
CPPUNIT_TEST_SUITE( TestMyFolder );
CPPUNIT_TEST( testConstructor );
CPPUNIT_TEST_SUITE_END();
private:
public:
void setUp() {}
void tearDown() {}
void testConstructor();
};
const std::wstring UNUSED_PATH = L"";
void TestMyFolder::testConstructor()
{
CPPUNIT_ASSERT_THROW(testable::MyFolder(testable::boost::filesystem::path(UNUSED_PATH)), testable::SomeError);
}
int main()
{
CppUnit::TextUi::TestRunner runner;
runner.addTest( TestMyFolder::suite() );
runner.run();
}

关于这种方法的具体问题是:

  • 有什么好的理由不这样做吗?
  • 这种方法最常见的缺陷是什么?
  • 有哪些替代方案?
  • 在什么情况下,这个解决方案比其他方案更好或更差?
  • 如何改进MyFolder类,使其更容易进行单元测试?

单元测试只不过是用低级组件(模拟组件)的替代实现来执行高级组件(被测试的单元)。从这个角度来看,任何分离高层和低层组件的SOLID方法都是可以接受的。然而,需要注意的是,在单元测试中,模拟组件的选择是在编译时完成的,而不是像插件、服务定位器、依赖注入等运行时模式。

有许多不同的接口机制来降低高层和低层组件之间的耦合。除了与语言无关的方法(hack、编译器命令行选项、库路径等)之外,c++还提供了一些选项,包括虚拟方法、模板、命名空间解析和参数依赖查找(ADL)。在这种情况下,虚拟方法可以看作是运行时多态性,而模板、名称空间解析和ADL可以看作是编译时的多态性。以上所有这些都可以用于单元测试,从ed脚本到模板。

当低级组件的选择在编译时完成时,我个人更喜欢使用名称空间和ADL而不是带有虚拟方法的接口类,以节省(有人认为是最小的)定义虚拟接口和将低级组件连接到该接口的开销。事实上,如果没有令人信服的理由,我会质疑通过自制的虚拟接口访问任何STL或引导组件的合理性。我之所以提出这个例子,是因为当低级STL或boost组件满足特定条件(内存分配失败、索引超出边界、io条件等)时,很大一部分单元测试应该测试高级组件的行为。假设您在单元测试中是系统的、严格的和严格的,并且假设您总是使用抽象虚拟类作为替换mock的机制,那么您将需要用自制的IVector替换std::vector的每个单独实例,在代码中的任何地方。

现在,尽管严格和严格的单元测试很重要,但系统化可能会被认为是适得其反的:在大多数情况下,std::vector将用于实现高端组件,而没有任何理由担心内存分配失败。但是,如果您决定在内存分配成为问题的上下文中开始使用高端组件,会发生什么情况呢?您是否愿意修改高端组件的代码,将std::vector替换为自制的IVector,只是为了添加相关的单元测试?或者您更愿意透明地添加缺失的单元测试—使用名称空间解析和ADL—而不更改高级组件中的任何代码?

另一个重要的问题是您愿意在项目中支持单元测试的不同方法的数量。1似乎是一个不错的数字,特别是如果您决定自动发现、编译和执行单元测试。

如果前面的问题使您考虑使用名称空间和ADL,那么在做出最终决定之前,是时候研究一下可能存在的限制、困难和陷阱了。让我们用一个例子:

文件MyFolder.hh

#ifndef ENCLOSING_MY_FOLDER_HH
#define ENCLOSING_MY_FOLDER_HH
#include <boost/filesystem.hpp>
#include <boost/exception/all.hpp>
namespace enclosing {
struct SomeError: public std::exception {};
struct MyFolder {
MyFolder(const boost::filesystem::path &p);
};
} // namespace enclosing
#endif // #ifndef ENCLOSING_MY_FOLDER_HH

在文件MyFolder.cpp:

#include "MyFolder.hh"
namespace enclosing {
MyFolder::MyFolder(const boost::filesystem::path &p) {
if (!exists(p)) // must be resolved by ADL for unit-tests {
BOOST_THROW_EXCEPTION(SomeError());
}
}
} // namespace enclosing

如果我想为两个明显的用例测试MyFolder构造函数,我的单元测试将看起来像这样:

testMyFolder.cpp

#include "MocksForMyFolder.hh" // Has to be before include "MyFolder.hh"
#include "MyFolder.hh"
#include <cppunit/ui/text/TestRunner.h>
#include <cppunit/extensions/HelperMacros.h>
namespace enclosing {
class TestMyFolder : public CppUnit::TestFixture {
CPPUNIT_TEST_SUITE( TestMyFolder );
CPPUNIT_TEST( testConstructorForMissingPath );
CPPUNIT_TEST( testConstructorForExistingPath );
CPPUNIT_TEST_SUITE_END();
public:
void setUp() {}
void tearDown() {}
void testConstructorForMissingPath();
void testConstructorForExistingPath();
};
const std::wstring UNUSED_PATH = L"";
void TestMyFolder::testConstructorForMissingPath() {
CPPUNIT_ASSERT_THROW(MyFolder(boost::filesystem::missing_path(UNUSED_PATH)), SomeError);
}
void TestMyFolder::testConstructorForExistingPath() {
CPPUNIT_ASSERT_NO_THROW(MyFolder(boost::filesystem::existing_path(UNUSED_PATH)));
}
} // namespace enclosing
int main() {
CppUnit::TextUi::TestRunner runner;
runner.addTest( enclosing::TestMyFolder::suite() );
runner.run();
}

使用MocksForMyFolder.hh:

中实现的模拟路径
#include <string>
namespace enclosing {
namespace boost {
namespace filesystem {
namespace MocksForMyFolder { // prevent name collision between compilation units
struct path {
path(const std::wstring &) {}
virtual bool exists() const = 0;
};
struct existing_path: public path {
existing_path(const std::wstring &p) : path{p} {}
bool exists() const {return true;}
};
struct missing_path: public path {
missing_path(const std::wstring &p) : path{p} {}
bool exists() const {return false;}
};
inline bool exists(const path& p) {
return p.exists();
}
} // namespace MocksForMyFolder
using MocksForMyFolder::path;
using MocksForMyFolder::missing_path;
using MocksForMyFolder::existing_path;
using MocksForMyFolder::exists;
} // namespace filesystem
} // namespace boost
} // namespace enclosing

最后,需要一个包装器来编译带有mock的MyFolder实现,WrapperForMyFolder.cpp:

#include "MocksForMyFolder.hh"
#include "MyFolder.cpp"

主要的缺陷是不同编译单元中的单元测试可能在封闭的命名空间(例如enclosing::boost::filesystem::path)内实现相同的低级组件(例如boost::filesystem::path)的模拟。当将所有单元测试与测试运行器链接到单个测试套件中时,根据不同的情况,链接器要么会抱怨冲突,要么会无声地、任意地选择其中一个实现。解决方法是将模拟组件的实现封装在一个内部未命名的名称空间中——或者封装在一个唯一命名的名称空间中(例如namespace MocksForMyFolder),然后使用适当的using子句(例如using MocksForMyFolder::path)公开它们。

这个例子显示了使用可配置mock (missing_pathexisting_path)来实现单元测试的选项。同样的方法也可以对内部和隐藏的实现方面进行深度测试(例如,私有类成员或方法的内部实现细节),但有明显的限制——这可能是一件好事。

当坚持单元测试的严格定义时,被测试的单元是一个单独的编译单元,只要设计合理可靠,事情往往会保持相当简单:在编译单元中实现的单个高级组件将包括少量的头文件,每个头文件都是对低级组件的依赖。当这些依赖项在其他编译单元中实现时,它们是模拟实现的良好候选者,这就是头保护起关键作用的地方。

使用适当的命名约定,只需使用几个makefile食谱就可以轻松实现自动化。

所以,我个人的总结是名称空间解析和ADL:

  • 提供了一些非常适合于单元测试的编译时多态性形式
  • 不要在高级组件的接口或实现中添加任何内容
  • 是一个非常简单和方便的实现底层组件的模拟,如boost和STL
  • 可用于任何用户实现的低级依赖

一些可能被认为是坏(或好的)事情的方面:

  • 要求仔细封装模拟以避免命名空间污染
  • 要求一致和系统的头部保护

我相信不使用这种方法进行单元测试的重要原因是遗留问题和个人偏好。

  1. 不是检查MyFolder的功能,而是检查2个接口的组成:MyFolder的公共接口和boost::MyFolder的公共接口结果你得到了更复杂和脆弱的测试
  2. 脆性测试(至少从我的经验来看)您的测试依赖于"/tmp"文件夹的缺失,该文件夹可以在任何时候创建
  3. 使用简单类型或接口。这两个类别都可以很容易地模拟测试需求。
  4. 我看不出使用你的方法有什么好处
  5. 见# 3

作为一个例子

class IPath
{
virtual bool exists() const = 0;
}
struct MyFolder
{
MyFolder(const IPath &p)
{
if (!p.exists()) // must be resolved by ADL for unit-tests
{
throw exception;
}
}
};
//TEST CODE
class CMockPath: public IPath
{
CMockPath(string s) {};
virtual bool exists() const { return false};
};
const wstring UNUSED_PATH = L"";
void TestMyFolder::testConstructor()
{
CPPUNIT_ASSERT_THROW(CMockPath(UNUSED_PATH), testable::SomeError);
}
//PDN CODE
class CPath: public IPath
{
...
boost::filesystem::path _p;
bool exists() const { return _p.exists(); };
...
};
CPath path(L".....");
MyFolder folder(path);
相关文章: