【问题描述】:

今天我和一位同事讨论了是否在课堂上测试私有成员或私有状态。他几乎说服了我为什么这样做是有道理的。这个问题的目的不是重复已经存在的关于测试私有成员的性质和原因的 StackOverflow 问题,例如:What is wrong with making a unit test a friend of the class it is testing?

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

我想请 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_ 设置吸气剂只是为了使其可测试的想法。所以我的建议是类中的'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++ 模板专业化以及它是如何工作的?

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

BOOST_AUTO_TEST_CASE(tested_class_value_init_ctor)
{
  ...
}

如果使用朋友,我必须将每个测试用例声明为朋友......或者最终以某种常见类型(如夹具)引入一些测试功能,将其声明为朋友,并将所有测试调用转发给该类型...这不是很奇怪吗?

我想看看你练习这种方法的利弊。

【问题讨论】:

  • 我仍在思考您的问题并提出解决方案。使用朋友和专业化之间的争论似乎是语义争论。在这两种情况下,你都在课堂上打开了一个漏洞来绕过隐私。我的第一印象是您正在转移复杂性而不是消除它(在添加新测试时您仍然需要进行相同数量的工作)。恕我直言,专业化是这两种解决方案中最奇怪的一种。您也可以使用转发的朋友 function(价格相同)。朋友更容易理解和维护。
  • Friend 函数有其他程序员使用它们的风险。考虑到您发布的解决方案的所有事情都可能是解决问题的方法:您的解决方案最大限度地减少了实现它的工作,并在不影响类的内存布局的情况下隐藏了应该隐藏的内容。更好的是,由于命名空间技巧,其他人在测试代码之外编写代码甚至会触及您的 test_backdoor() 钩子的可能性极小。但是如果他们这样做了,您可以编写一个脚本来预处理所有代码,以使用 grep 检测这些代码的非法使用。

标签: c++ unit-testing private protected members


【解答1】:

我认为单元测试是关于测试被测类的可观察行为。因此没有必要测试私处,因为它们本身是不可观察的。您测试它的方式是测试对象是否按照您期望的方式运行(这隐含地暗示所有私有内部状态都是有序的)。

不关心私有部分的原因是,这样您可以更改实现(例如重构),而无需重写测试。

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

【问题讨论】:

  • 单元测试的部分收获是将系统分解为足够小的单元,以便它们具有易于表达的行为。如果您有很多复杂的私有代码,您认为应该将其作为多个单元进行测试,或许将其重构为由较小的类而不是一个大类组成的协奏曲就是答案。
  • Pete:不,这不是一个答案,因为我明确写过我对这种方法不感兴趣。我觉得你和阿提拉的回答是题外话。如果在理想的世界里,你们俩都是对的,我也知道。这对我来说并不新鲜。您是否曾经使用过需要清理/重构的非常复杂的遗留代码?这将是一个非常持久的部分。在这里,你不能把所有东西都分解成更小的部分。这是一个不理想的世界,有时您确实需要一种可移植的方式来访问私有数据以进行测试。
  • @ovanes - 我想我现在更好地理解了你的问题来自哪里。为了解决能够测试遗留代码的迫切需求,模板函数声明将是最简单和最可移植的方式。这就是说,您仍然应该努力最终删除这种方法并用纯单元测试代替它(即只测试公共/受保护的部分)。原因是如果你将测试与私有部分结合起来,每次对测试代码进行更改时,你都必须加倍努力:测试也需要更改——即使是很小的重组
  • -1:考虑到我们作为工程师处理的大量遗留代码,这是一种理想主义的回应,并且在实践中几乎总是不适用。
【解答2】:

优点

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

缺点

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

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

可能的解决方案

  • 使用 Pimpl 习语,将 complex 代码与私有成员一起放入 pimpl,并为 Pimpl 编写测试。 Pimpl 可以前向声明为公共成员,允许在单元测试中进行外部实例化。 Pimpl 只能由公共成员组成,使其更易于测试
    • 缺点:代码多
    • 缺点:不透明的类型在调试时更难看到内部
  • 只需测试类的公共/受保护接口。测试您的界面布局的合同。
    • 缺点:单元测试很难/不可能以孤立的方式编写。
  • 类似于 Pimpl 解决方案,但创建一个包含 complex 代码的自由函数。将声明放在私有标头中(不是库公共接口的一部分),然后对其进行测试。
  • 通过朋友打破封装测试方法/夹具
    • 对此的可能变化:声明 friend struct test_context;,将您的测试代码放在 struct test_context 实现中的方法中。这样,您就不必为每个测试用例、方法或夹具添加好友。这应该会降低有人破坏好友关系的可能性。
  • 通过模板特化打破封装

【问题讨论】:

  • 请编辑更多优点/缺点!
  • Zac:关于封装:是的,这会破坏封装,但我想再次从 gotw.ca/gotw/076.htm 回到 Herb Sutter 的解释:“[...] 这实际上不是问题。这里的问题是防止墨菲与防止马基雅维利……也就是说,防止意外滥用(该语言做得很好)与防止故意滥用(这实际上是不可能的)。最后,如果程序员非常想颠覆系统,他会找到一种方法,正如所证明的那样......”在 GotW #76 中。他还表示不这样做。
  • Zac:关于“将测试与生产代码混合”。事实上,我没有。这只是一个未实现的函数声明。是的,这是公共静态类接口的一部分,但如果不提供显式实现,就不可能调用该函数。
  • Zac:关于维护问题:这不是真的。不依赖于测试代码。 =friend= 假设存在具有这样名称(或签名)的类或函数。这个模板没有任何假设。重命名测试用例或测试函数不会自动涉及编译错误。单元测试的目的是快速识别类或类成员中的错误。如果我更改实现,我希望特定的单元测试尽快失败,而不是 5 层以上。第二个已经是功能测试了。
  • 恐怕这是正确的答案。 pimpl 可以 100% 公开,定义在一个私有头文件中,该文件永远不会出现在您的类实现之外(与其他程序员共享的公共头文件)。实际上,99% 的情况下,pimpl 是一个结构,因为所有成员都是公共的(尽管我个人并没有以这种方式实现它们,但无论如何......)这意味着所有测试所要做的就是访问正确的数据那里。调整主类(或接口)不是一个好主意。您可能还需要一个强调主界面的测试,但没有私有成员访问。
【解答3】:

从技术上讲,接下来的内容并不是对您的直接回答 问题,因为它仍然会使用“朋友”功能 但它不需要修改被测实体本身 我认为它增加了打破封装的担忧 在其他一些答案中提到;它确实需要 编写一些样板代码。

它背后的想法不是我的,实现是 entirely based on a trick presented and explained by litb on his blog(加上这个Sutter's gotw只是一点点 更多上下文,至少对我而言)-简而言之,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; 
}

【问题讨论】:

    【解答4】:

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

    #define private public
    

    这是邪恶的,但是

    • 不干扰生产代码

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

    • 避免使用 PIMPL 惯用语进行大量重构

    所以你可以去...

    【问题讨论】:

    • 这是可用的最糟糕的技巧之一 :) 首先这是不符合标准的 => 您不能更改语言关键字。其次,它会破坏 ODR。有关更多信息,请阅读 Herb Sutters GOTW。
    • @ovanes 它可能不会破坏 ODR。例如,如果该类仅是标题,并且唯一包含来自单元测试。或者,如果#define 是作为编译器设置完成的,那么每个人都可以得到它,包括关联的 cpp 和单元测试 cpp。
    • 是的,我绝对知道并理解这一点。但这是脆弱的代码!今天课程只是标题,明天新开发人员来到您的团队重构课程,其他人花费数小时查找错误。你的说法是对的:它可能不会中断....
    【解答5】:

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

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

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

    【问题讨论】:

    • mmmm..... 如果我进行泛型编程并且根本没有虚函数怎么办???我认为这种方法违反了 LSP,不知何故我什至没有明白它的意图是什么。这里测试什么? Foo 还是 CreateFoo 工厂?
    • 显然此代码不适用于您有意避免虚拟行为的情况。为了解决所谓的 LSP 违规,可以进行多重继承;为了简单起见,我只是在这里避免了它。显然,Foo 在这里进行了测试,它恰好是由一个迷你工厂函数产生的(可能只是在执行 return std::shared_ptr(new Foo);) .
    • 一个小技巧 ;) 考虑使用 std::make_shared 而不是 new Foo...en.cppreference.com/w/cpp/memory/shared_ptr/make_shared 原因如下:herbsutter.com/gotw/_103
    • 我喜欢您的解决方案,但我觉得这种方法比 OP 的解决方案更难理解和维护。 IMO 拥有一个朋友功能会更容易,因为它不会对非测试人员隐藏代码。可能不好的是继承会改变所涉及类的内存布局,特别是如果必须进行多重继承并且这可能会破坏交易。
    【解答6】:

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

    但如果我决定深入了解细节,我会在单元测试程序中使用令人讨厌的快速破解

    #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++ 名称修饰不包括私有/公共状态。有人告诉我,在其他系统上这可能不是真的,它不会链接。

    【问题讨论】:

    • C++ 标准禁止重新定义语言关键字。这可能会违反 ODR...
    • @ovanes:不一定:stackoverflow.com/a/2726221/195488
    • 我希望你指出这一点:gotw.ca/gotw/076.htm,所以根据 Herb Sutter 的说法,你是一个“罪犯:扒手”。 ;)
    • @ovanes:你将成为“语言律师”。 :-)
    • 抛开语言问题不谈,我担心这样会导致编译问题(例如,出现范围模糊等),否则永远不会发生。 IMO 单元测试代码甚至不应该潜在地破坏工作代码和将私有风险重新定义为公共风险 - 特别是如果代码具有丰富的类型和范围使用。
    【解答7】:

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

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

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

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

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

    这会有帮助吗?

    【问题讨论】:

    • 请注意,您将内部结构暴露给其他人 - 如果 TestInvariant() 方法用于通过子类中的代码检查内部状态,则必须保护状态.如果状态受到保护,那么您已将其暴露给任何子类。继承是最紧密的耦合形式之一。
    • 但是如果你保护了testinvariant函数,数据不能保持私有吗?
    【解答8】:

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

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

    在我看来,模板解决方案在这方面甚至比朋友更糟糕。您声明的模板的主要好处是您不再需要明确地与班级的测试建立朋友关系。我认为,相反,这是有害的。有两个原因。

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

    2. 朋友限制了你的私人范围扩展。如果您将某个班级加为好友,您就会知道只有该班级可以访问您的内部信息。因此,如果您对测试加为好友,您就会知道只有测试可以读取或写入私有成员变量。但是,您的模板后门可以在任何地方使用。

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

    如果您必须从测试中访问私有成员,只需将测试夹具加为好友即可。简单易懂。

    【问题讨论】:

      【解答9】:

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

      对我来说这就是什叶派。

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

      class Something{
         PRIVATE:
             int m_attr;
      };
      

      当为测试编译时,PRIVATE 被定义为公共,否则它被定义为私有。就这么简单。

      【问题讨论】: