在没有朋友的情况下测试C++中的私有类成员

Testing private class member in C++ without friend

本文关键字:成员 C++ 情况下 朋友 测试      更新时间:2023-10-16

今天我和一位同事讨论了是否测试类中的私有成员或私有状态。他几乎说服了我为什么这是有道理的。这个问题的目的不是重复已经存在的关于测试私人成员的性质和原因的StackOverflow问题,比如:让单元测试成为它正在测试的类的朋友有什么错?

同事们的建议在我看来有点脆弱,将友元声明引入单元测试实现类。在我看来,这是不可行的,因为我们引入了测试代码对测试代码的一些依赖,而测试代码已经依赖于测试代码=>循环依赖。即使是像重命名测试类这样无辜的事情也会破坏单元测试,并强制测试代码中的代码更改。

我想请C++大师来评判另一个建议,它依赖于我们被允许专门化模板函数的事实。想象一下这个类别:

// tested_class.h
struct tested_class 
{
  tested_class(int i) : i_(i) {}
  //some function which do complex things with i
  // and sometimes return a result
private:
  int i_;
};

我不喜欢为I_设置getter只是为了使其可测试。所以我的建议是类中的"test_backdoor"函数模板声明:

// tested_class.h
struct tested_class 
{
  explicit
  tested_class(int i=0) : i_(i) {}
  template<class Ctx>
  static void test_backdoor(Ctx& ctx);
  //some function which do complex things with i
  // and sometimes return a result
private:
  int i_;
};

通过添加这个函数,我们可以使类的私有成员可测试。请注意,既不依赖于单元测试类,也不依赖于模板函数实现。在本例中,单元测试实现使用Boost test框架。

// tested_class_test.cpp
namespace
{
  struct ctor_test_context
  {
    tested_class& tc_;
    int expected_i;
  };
}
// specialize the template member to do the rest of the test
template<>
void tested_class::test_backdoor<ctor_test_context>(ctor_test_context& ctx)
{
  BOOST_REQUIRE_EQUAL(ctx.expected_i, tc_.i_);
}
BOOST_AUTO_TEST_CASE(tested_class_default_ctor)
{
  tested_class tc;
  ctor_test_context ctx = { tc, 0 };
  tested_class::test_backdoor(ctx);
}
BOOST_AUTO_TEST_CASE(tested_class_value_init_ctor)
{
  tested_class tc(-5);
  ctor_test_context ctx = { tc, -5 };
  tested_class::test_backdoor(ctx);
}

通过只引入一个根本不可调用的模板声明,我们为测试实现者提供了将测试逻辑转发到函数中的可能性。由于测试上下文的匿名类型性质,该函数作用于类型安全上下文,并且只能从特定的测试编译单元内部可见。最好的是,我们可以定义尽可能多的匿名测试上下文,并对其进行专门测试,而无需接触测试类。

当然,用户必须知道什么是模板专业化,但这段代码真的很糟糕、奇怪或不可读吗?或者我可以期望C++开发人员了解C++模板专业化是什么以及它是如何工作的?

在详细说明使用friend来声明单元测试类时,我认为这并不健壮。想象一下boost框架(或者可能是其他测试框架)。它为每个测试用例生成一个单独的类型。但我为什么要在乎,只要我能写:

BOOST_AUTO_TEST_CASE(tested_class_value_init_ctor)
{
  ...
}

如果使用朋友,我必须将每个测试用例声明为朋友,然后。。。或者最终在一些常见类型(如fixture)中引入一些测试功能,将其声明为友元,并将所有测试调用转发到该类型。。。这不是很奇怪吗?

我希望看到你们的支持者和反对者实践这种方法。

我认为单元测试就是测试被测类的可观察的行为。因此,没有必要测试私有部件,因为它们本身是不可观察的。测试它的方法是测试对象的行为是否符合预期(这隐含地意味着所有私有内部状态都是有序的)。

不关心私有部分的原因是,通过这种方式,您可以更改实现(例如重构),而不必重写测试。

所以我的答案是不要这样做(即使在技术上可能),因为这违背了单元测试的理念。

优点

  • 您可以访问私人成员以对其进行测试
  • 这是相当少量的hack

缺点

  • 封装破损
  • 破损的封装更复杂,与friend一样脆
  • 通过将test_backdoor放在生产端,将测试与生产代码混合
  • 维护问题(就像对测试代码加好友一样,您已经创建了一个与测试代码极其紧密耦合的

抛开所有的优点/缺点不谈,我认为你最好做一些架构更改,以便更好地测试正在发生的任何复杂事情。

可能的解决方案

  • 使用Pimpl习惯用法,将complex代码与私有成员一起放入Pimpl中,并为Pimpl编写一个测试。Pimpl可以前向声明为公共成员,从而允许在单元测试中进行外部实例化。Pimpl只能由公共成员组成,从而更容易进行测试
    • 缺点:代码太多
    • 缺点:不透明类型,调试时更难看到内部
  • 只需测试类的公共/受保护接口即可。测试您的接口所制定的契约。
    • 缺点:单元测试很难/不可能单独编写
  • 与Pimpl解决方案类似,但创建一个包含complex代码的免费函数。将声明放在私有标头(而不是库公共接口的一部分)中,并对其进行测试
  • 通过朋友的测试方法/夹具打破封装
    • 可能的变体是:声明friend struct test_context;,将测试代码放在struct test_context实现中的方法中。这样一来,您就不必对每个测试用例、方法或固定装置都友好。这应该可以减少有人破坏朋友关系的可能性
  • 通过模板专业化打破封装

从技术上讲问题,因为它仍将使用"朋友"功能但它不需要修改被测试实体本身我认为这增加了打破封装的担忧在其他一些答复中提到;但它确实需要写一些样板代码。

它背后的想法不是我的,实施是完全基于litb在他的博客(加上这篇萨特的文章)更多的上下文,至少对我来说)-简而言之,CRTP、朋友、ADL和指向成员的指针(我必须承认,令我沮丧的是,ADL部分我仍然没有完全理解,但我正在努力100%弄清楚)。

我用gcc 4.6、clang 3.1和VS2010编译器对它进行了测试工作非常完美。

/* test_tag.h */
#ifndef TEST_TAG_H_INCLUDED_
#define TEST_TAG_H_INCLUDED_
template <typename Tag, typename Tag::type M>
struct Rob
{
    friend typename Tag::type get(Tag)
    {
        return M;
    }
};
template <typename Tag, typename Member> 
struct TagBase
{
    typedef Member type;
    friend type get(Tag);
};

#endif /* TEST_TAG_H_INCLUDED_ */
/* tested_class.h */
#ifndef TESTED_CLASS_H_INCLUDED_
#define TESTED_CLASS_H_INCLUDED_
#include <string>
struct tested_class
{
    tested_class(int i, const char* descr) : i_(i), descr_(descr) { }
private:
    int i_;
    std::string descr_;
};
/* with or without the macros or even in a different file */
#   ifdef TESTING_ENABLED
#   include "test_tag.h"
    struct tested_class_i : TagBase<tested_class_i, int tested_class::*> { };
    struct tested_class_descr : TagBase<tested_class_descr, const std::string tested_class::*> { };
    template struct Rob<tested_class_i, &tested_class::i_>;
    template struct Rob<tested_class_descr, &tested_class::descr_>;
#   endif
#endif /* TESTED_CLASS_H_INCLUDED_ */
/* test_access.cpp */
#include "tested_class.h"
#include <cstdlib>
#include <iostream>
#include <sstream>
#define STRINGIZE0(text) #text
#define STRINGIZE(text) STRINGIZE0(text)
int assert_handler(const char* expr, const char* theFile, int theLine)
{
    std::stringstream message;
    message << "Assertion " << expr << " failed in " << theFile << " at line " << theLine;
    message << "." << std::endl;
    std::cerr << message.str();
    return 1;
}
#define ASSERT_HALT() exit(__LINE__)
#define ASSERT_EQUALS(lhs, rhs) ((void)(!((lhs) == (rhs)) && assert_handler(STRINGIZE((lhs == rhs)), __FILE__, __LINE__) && (ASSERT_HALT(), 1)))
int main()
{
    tested_class foo(35, "Some foo!");
    // the bind pointer to member by object reference could
    // be further wrapped in some "nice" macros
    std::cout << " Class guts: " << foo.*get(tested_class_i()) << " - " << foo.*get(tested_class_descr()) << std::endl;
    ASSERT_EQUALS(35, foo.*get(tested_class_i()));
    ASSERT_EQUALS("Some foo!", foo.*get(tested_class_descr()));
    ASSERT_EQUALS(80, foo.*get(tested_class_i()));
    return 0; 
}

我很抱歉建议这样做,但当这些答案中的大多数方法在没有强大重构的情况下都无法实现时,它对我有所帮助:在文件的头之前添加您希望访问其私有成员的类,

#define private public

这是邪恶的,但

  • 不会干扰生产代码

  • 不会像朋友/更改访问级别那样破坏封装

  • 使用PIMPL习语避免了繁重的重构

所以你可以选择…

测试私有成员并不总是通过检查状态是否等于一些期望值来验证状态。为了适应其他更复杂的测试场景,我有时会使用以下方法(在这里简化以传达主要思想):

// Public header
struct IFoo
{
public:
    virtual ~IFoo() { }
    virtual void DoSomething() = 0;
};
std::shared_ptr<IFoo> CreateFoo();
// Private test header
struct IFooInternal : public IFoo
{
public:
    virtual ~IFooInternal() { }
    virtual void DoSomethingPrivate() = 0;
};
// Implementation header
class Foo : public IFooInternal
{
public:
    virtual DoSomething();
    virtual void DoSomethingPrivate();
};
// Test code
std::shared_ptr<IFooInternal> p =
    std::dynamic_pointer_cast<IFooInternal>(CreateFoo());
p->DoSomethingPrivate();

这种方法有一个明显的优点,即促进良好的设计,而不会与朋友声明混淆。当然,大多数时候你不必经历麻烦,因为能够测试私人成员从一开始就是一个非常不标准的要求。

我通常觉得没有必要对私有成员和函数进行单元测试。我可能更愿意引入一个公共函数来验证正确的内部状态。

但是,如果我真的决定四处窥探细节,我会在单元测试程序中使用讨厌的快速破解

#include <system-header>
#include <system-header>
// Include ALL system headers that test-class-header might include.
// Since this is an invasive unit test that is fiddling with internal detail
// that it probably should not, this is not a hardship.
#define private public
#include "test-class-header.hpp"
...

至少在Linux上,这是有效的,因为C++名称篡改不包括私有/公共状态。有人告诉我,在其他系统上,这可能不是真的,也不会联系起来。

我使用了一个函数来测试私有类成员,它刚刚被称为TestInvariant()。

它是类的私有成员,在调试模式下,在每个函数的开头和结尾都会调用它(除了ctor的开头和dctor的结尾)。

它是虚拟的,任何基类在拥有它之前都称为父版本。

这使我能够一直验证类的内部状态,而不会将类的意图暴露给任何人。我进行了非常简单的测试,但没有理由不进行复杂的测试,甚至可以用标志等设置它。

此外,您还可以拥有公共测试函数,这些函数可以由调用TestInvariant()函数的其他类调用。因此,当您需要更改内部类工作时,您不需要更改任何用户代码。

这有帮助吗?

我想首先要问的是:为什么朋友被认为是必须谨慎使用的东西?

因为它破坏了封装。它为另一个类或函数提供了对对象内部的访问权限,从而扩展了私有成员的可见范围。如果你有很多朋友,就很难对你的对象的状态进行推理。

在我看来,模板解决方案在这方面甚至比朋友更糟糕。模板的主要好处是,您不再需要明确地与类中的测试交朋友。我认为,恰恰相反,这是一种损害。这有两个原因。

  1. 该测试耦合到类的内部。任何更改类的人都应该知道,通过更改对象的私有项,他们可能会破坏测试。朋友告诉他们哪些对象可能耦合到类的内部状态,但模板解决方案没有。

  2. 朋友限制了你私生活的范围。如果你是一个类的朋友,你知道只有那个类可以访问你的内部。因此,如果您是测试的好友,您就会知道只有测试才能读取或写入私有成员变量。然而,您的模板后门可以在任何地方使用。

模板解决方案是无效的,因为它隐藏了问题,而不是修复它。循环依赖的根本问题仍然存在:更改类的人必须知道后门的每一次使用,更改测试的人必须了解类。基本上,只有以迂回的方式将所有私有数据转换为公共数据,才能从类中删除对测试的引用。

如果你必须从测试中访问私人成员,只需成为测试夹具的好友并完成它。这很简单,也可以理解。

有一种理论认为,如果它是私有的,就不应该单独测试,如果需要,就应该重新设计。

对我来说,这就是什叶派

在一些项目中,人们为私有方法创建了一个宏,就像:

class Something{
   PRIVATE:
       int m_attr;
};

为测试进行编译时,PRIVATE被定义为public,否则它被定义为PRIVATE。就这么简单。