单元测试只不过是使用低级组件(模拟组件)的替代实现来执行高级组件(被测单元)。从这个角度来看,任何将高级和低级组件解耦的 SOLID 方法都是可以接受的。然而,重要的是要注意,通过单元测试,模拟组件的选择是在编译时完成的,而不是像插件、服务定位器、依赖注入等运行时模式。
有许多不同的接口机制来降低高级和低级组件之间的耦合。除了与语言无关的方法(hack、编译器命令行选项、库路径等)之外,C++ 还提供了多种选项,包括虚拟方法、模板、命名空间解析和参数相关查找(ADL)。在这种情况下,虚拟方法可以被视为运行时多态性,而模板、命名空间解析和 ADL 可以被视为多态性的编译时风格。ed
从脚本到模板,以上所有内容都可以用于单元测试。
在编译时选择低级组件时,我个人更喜欢使用命名空间和 ADL 而不是具有虚拟方法的接口类,以节省定义虚拟接口和连接低级组件的(有些人认为是最小的)开销进入那个界面。事实上,如果没有令人信服的理由,我会质疑通过自制虚拟界面访问任何 STL 或引导组件的合理性。我提出这个例子是因为当低级 STL 或 boost 组件满足特定条件(内存分配失败、索引超出范围、io 条件等)时,很大一部分单元测试应该测试高级组件的行为。假设你的单元测试是系统的、严格的、严谨的,std::vector
由自制的IVector
,在您的代码中随处可见。
现在,尽管对单元测试进行严格和严格很重要,但系统化可能会被视为适得其反:在大多数情况下,astd::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
最后,需要一个包装器来使用模拟 WrapperForMyFolder.cpp 编译 MyFolder 实现:
#include "MocksForMyFolder.hh"
#include "MyFolder.cpp"
boost::filesystem::path
主要的缺陷是不同编译单元中的单元测试可能会在封闭的命名空间(例如)内实现相同低级组件(例如)的模拟enclosing::boost::filesystem::path
。当将所有单元测试与测试运行器链接到单个测试套件中时,根据情况,链接器要么抱怨冲突,要么更糟糕的是,默默地任意选择一个实现。解决方法是将模拟组件的实现包含在一个内部未命名的命名空间中——或一个唯一命名的命名空间(例如namespace MocksForMyFolder
)中,然后用适当的using
子句(例如using MocksForMyFolder::path
)公开它们。
missing_path
这个例子展示了使用可配置的模拟(和)实现单元测试的选项existing_path
。相同的方法还可以对内部和隐藏的实现方面(例如私有类成员或方法的内部实现细节)进行深入测试,但有很大的局限性——这可能是一件好事。
当坚持单元测试的严格定义时,被测单元是单个编译单元,只要设计合理可靠,事情往往会保持相当简单:在编译单元中实现的单个高级组件将包括一个小的标头的数量,每个标头都依赖于低级组件。当这些依赖项在其他编译单元中实现时,它们是模拟实现的良好候选者,这就是标头守卫发挥关键作用的地方。
使用适当的命名约定,只需几个 makefile 配方就可以实现自动化。
所以,我个人的总结是命名空间解析和ADL:
- 提供一些非常适合单元测试的编译时多态性形式
- 不要向高级组件的接口或实现添加任何内容
- 是一种非常容易和方便地为低级组件(如 boost 和 STL)实现模拟
- 可用于任何用户实现的低级依赖
一些可能被认为是坏(或好)的方面:
- 需要仔细封装模拟以避免命名空间污染
- 需要一致和系统的头部防护
我相信不使用这种方法进行单元测试的重要原因是遗留问题和个人偏好。