使用C ++分层命名空间解析为单元测试提供模拟组件是一种好习惯吗?

时间:2013-09-03 12:28:35

标签: c++ unit-testing mocking boost-filesystem

典型的用例是在其实现中使用boost::filesystem的组件(例如,下例中的testable::MyFolder)。单元测试该组件需要模拟boost::filesystem的部分。模拟boost::filesystem的一种方法是在包含MyFolder的命名空间内实现模拟组件(例如,在示例中的testable命名空间内)并依赖于分层命名空间解析来替换编译时使用模拟副本的boost::filesystem组件。

例如:

文件MyFolder.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
文件MockFilesystem.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
文件testMyFolder.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以便更容易进行单元测试?

2 个答案:

答案 0 :(得分:1)

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

有许多不同的接口机制可以降低高级和低级组件之间的耦合。除了语言不可知的方法(黑客,编译器命令行选项,库路径等),C ++还提供了几个选项,包括虚拟方法,模板,命名空间解析和argument-dependent lookup(ADL)。在这种情况下,虚拟方法可以看作是运行时多态,而模板,命名空间解析和ADL可以看作是多态的编译时风格。以上所有内容都适用于单元测试,从ed脚本到模板。

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

现在,即使对单元测试进行严格和严格的处理也很重要,但系统化可能会产生适得其反的效果:在大多数情况下,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

如果我想针对2个明显的用例测试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_pathexisting_path)实现单元测试。同样的方法也可以深入测试内部和隐藏的实现方面(例如私有类成员或方法的内部实现细节),但有很大的局限性 - 这可能是一件好事。

当坚持单元测试的严格定义,其中被测单元是单个编译单元时,只要设计合理地为SOLID,事情就会保持相当简单:编译单元中实现的单个高级组件将包含少量标题,每个标题都是对低级组件的依赖。当这些依赖项在其他编译单元中实现时,它们是模拟实现的良好候选者,并且标题保护在其中发挥关键作用。

使用适当的命名约定,只需几个makefile配方即可实现自动化。

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

  • 提供某些形式的编译时多态,非常适合单元测试
  • 不要在界面或高级组件的实现中添加任何内容
  • 为boost和STL等低级组件实现模拟非常简单方便
  • 可用于任何用户实现的较低级别依赖

可能被视为坏(或好)事物的某些方面:

  • 需要仔细封装模拟以避免命名空间污染
  • 需要一致且系统的标题保护

我认为不使用此方法进行单元测试的重要原因将是遗留和个人偏好。

答案 1 :(得分:-2)

  1. 不是检查MyFolder的功能,而是检查2个接口的组成:MyFolder的公共接口和boost :: MyFolder的公共接口 结果,你得到了更复杂和更脆弱的测试
  2. 脆弱的测试(至少从我的经验来看) 您的测试取决于缺少“/ tmp”文件夹,可以随时创建
  3. 使用简单类型或接口。这两个类别都可以轻松地模拟测试需求。
  4. 我认为使用您的方法没有任何好处
  5. 见#3
  6. 作为一个例子

    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);