冰山类和谷歌单元测试

Iceberg Class and Google Unit Testing

本文关键字:单元测试 谷歌 冰山      更新时间:2023-10-16

我正在经历对现有代码进行单元测试的过程,该代码的编写没有考虑单元测试。

有一些类的结构如下:

class Texture
{
public:
friend class Model;
private:
void Load( int a, int b);
void Update(int a, int b);
void Use(int a, int b);    
}
class Material
{
public:
friend class Model;
private:
void Load(int a);
void Update(int a);
void Use(int a);    
}
class Mesh
{
public:
friend class Model;
private:
void Load(int a, int b, int c);
void Update(int a, int b, int c);
void Use(int a, int b, int c);    
}

class Model
{
public:
void Load(); // call all the individual Load()
void Use(); // call all the individual Use()
}

它们之所以保持私有,是因为它是以只有模型类可以调用它们的方式设计的,因此是朋友。

[在实际代码中,有一个律师-客户习惯用法,它限制了模型对这些类的访问,但我将其排除在代码片段之外]

现在我正在尝试为类进行单元测试。在弄清楚如何测试这些私人功能时,我遇到了冰山类的这个术语,我觉得上面的类在某种程度上是有罪的。

大多数涉及这个主题的文章还提到,如果需要测试私有函数,这主要意味着类做得过头了,这些函数最好放在另一个独立的类中,在那里它们保持公共状态。

所以现在,我不确定这是否是一个糟糕的代码设计,我应该重新设计它们以使单元测试更容易,或者我只是按原样进行单元测试。

想听听您的意见

为了使这段代码可测试,我将引入三个纯虚拟接口(ITextureIMeshIMaterial),并添加一个自由方法来创建这样的接口(例如getTexture),这将返回类型ITexture的smart_ptr。然后在 cpp 文件中实现一个get[...]方法,并在生产代码中使用它来创建Model对象。在单元测试中,我会为每个接口类创建一个模拟,并对注入的模拟设置适当的期望(例如,使用gmock或编写自己的模拟)。

Mesh、头文件、IMesh.hpp 的示例:

class IMesh {
public:
virtual ~IMesh() = default;
virtual void Load(int a, int b, int c) = 0;
virtual void Update(int a, int b, int c) = 0;
virtual void Use(int a, int b, int c) = 0; 
};
std::unique_ptr<MeshI> getMesh(/*whatever is needed to create mesh*/);

implementaiton file, MeshImpl.cpp:

#include "IMesh.hpp";
class Mesh : public IMesh {
public:
Mesh(/*some dependency injection here as well if needed*/);
void Load(int a, int b, int c) override;
void Update(int a, int b, int c) override;
void Use(int a, int b, int c) override; 
};
Mesh::Mesh(/*[...]*/) {/*[...]*/}
void Mesh:Load(int a, int b, int c) {/*[...]*/}
void Mesh:Update(int a, int b, int c) {/*[...]*/}
void Mesh:Use(int a, int b, int c) {/*[...]*/}

依赖注入:

Model model{getMesh(), getTexture(), getMaterial()};

通过这种方法,可以实现:

  1. 更好的解耦 - 友谊是一种非常强大的耦合机制,而依赖纯虚拟接口是一种常见的方法)
  2. 更好的可测试性 - 不仅对于Model类 - 因为接口中的所有方法都必须public才能Model类使用它,您现在可以单独测试每个接口
  3. 更好的封装:只能通过getter方法创建所需的类 - 用户无法访问实现,所有私有内容都是隐藏的。
  4. 更好的可扩展性:现在用户可以提供不同的IMesh实现,并在需要时将其注入模型。

有关 DI 技术的更多详细信息,请参阅此问题

我认为在这种情况下使用friend是不幸的。 在我看来,friend的一个很好的用例是,允许在概念上具有紧密耦合的类之间访问私有元素。 当我写它们在概念上具有紧密耦合时,我的意思是紧密耦合不是使用friend的结果,但是这些类之间的紧密耦合是由于它们的依赖性,这是它们定义角色的结果。 在这种情况下,friend是一种正确处理这种紧密耦合的机制。 例如,容器及其相应的迭代器类在概念上是紧密耦合的。

在您的情况下,在我看来,这些类在概念层面上并没有那么紧密耦合。 您将friend用于不同的目的,即强制执行架构规则:只有Model才能使用方法LoadUpdateUse。 不幸的是,这种模式有局限性:如果你有另一个类Foo和第二个架构规则,Foo只能被允许调用Use方法,你不能同时表达这两个架构规则:如果你也Foo其他类的朋友,那么Foo不仅可以访问Use, 但也要LoadUpdate- 您不能以精细的方式授予访问权限。

如果我的理解是正确的,那么我会争辩说LoadUpdateUse在概念上不是private的,也就是说,它们不代表应该为外部隐藏的类的实现细节:它们属于类的"官方"API,只是附加规则只有Model才能使用它们。 通常,private方法是私有的,因为实现者希望保留重命名或删除它们的自由,因为其他代码无法访问它们。 我认为,这不是这里的意图。

考虑到这一切,我认为最好以不同的方式处理这种情况。 公开Load方法,UpdateUse,并添加注释以解释体系结构约束。 而且,尽管我的论点不是关于可测试性的,但这也解决了您的测试问题之一,即允许您的测试也访问LoadUpdateUse

如果你还希望能够模拟你的类TextureMaterialMesh,那么考虑Quarra的建议来引入各自的接口。


尽管对于您的具体示例,我的建议是将方法LoadUpdateUse公开,但我并不反对单元测试实现细节。 同一接口的替代实现具有不同的潜在错误。 而且,发现错误是测试的一个主要目标(参见Myers,Badgett,Sandler:软件测试的艺术,或Beizer:软件测试技术等)。

例如,考虑memcpy函数:假设您必须实现和测试它。 您从一个简单的解决方案开始,逐个字节复制,然后对其进行彻底测试。 然后,您意识到,对于 32 位计算机,如果源地址和目标地址是 32 位对齐的,则可以做得更快:在这种情况下,您可以一次复制四个字节。 当您实现此更改时,新memcpy在内部看起来完全不同:首先检查指针对齐方式是否合适。 如果不适合,则执行原始的逐字节复制,否则执行更快的复制例程(这也必须处理字节数不是四的倍数的情况,因此最后可能会有一些额外的字节要复制)。

memcpy的界面仍然相同。 尽管如此,我认为您肯定需要为新实现扩展测试套件:您应该为两个四字节对齐的指针提供测试用例,对于只有一个指针是四字节对齐的情况等。 您需要指针都是四字节对齐且要复制的字节数是 4 的倍数的情况,以及它们不是 4 的倍数的情况,等等。 也就是说,您的测试套件将大幅扩展 - 只是因为实现细节发生了变化。需要新的测试来查找新实现中的错误 - 尽管所有测试仍然可以使用公共API,即memcpy函数。

因此,假设单元测试与实现细节无关是错误的,并且仅仅因为它们通过公共 API 进行测试而假设测试不是特定于实现的也是错误的。

但是,测试不应不必要地依赖于实现细节,这是正确的。 始终首先尝试创建与实现无关的有用测试,然后再添加特定于实现的测试。 对于后者,测试私有方法(例如来自friend测试类)也可以是一个有效的选择 - 只要您意识到缺点(如果私有方法被重命名、删除等,则需要维护测试代码)并权衡它们与优点。