用单元测试重新构建C++应用程序

use*_*525 6 c++ architecture unit-testing

我开始考虑重新构建一个大型C++应用程序,并考虑单元测试.我所做的大部分阅读都引导我去模拟框架(即谷歌模拟).但是,我的设计目标之一是使软件尽可能简单,以便于维护.

我的问题是,您似乎需要为应用程序添加相当大的复杂性,以便构建使用模拟类所需的依赖注入.

例如,您需要为可能需要模拟的所有类添加抽象基类,以便您可以在生产代码中实例化"生产"对象,并在单元测试代码中"模拟"对象.由于额外类的数量和所有类的增加抽象级别,这有点不合需要.此外,您是否添加了一个定义公共接口的抽象基类到每个类?如果你不这样做,你怎么能确定这个课程永远不需要被嘲笑?

或者,您需要对所有类进行模板化,以便可以在单元测试代码中"注入"模拟对象.我绝对不希望每个类都是模板类的应用程序.

每个人的经历对此有何影响?您如何在您的架构中构建可测试性以及结果如何?

小智 3

例如,您需要为所有可能需要模拟的类添加抽象基类,以便您可以在生产代码中实例化“生产”对象,并在单元测试代码中实例化“模拟”对象。由于额外类的数量以及所有类的抽象级别的增加,这在某种程度上是不可取的。

我认为首先您必须认识到这一点的必要性,然后基于模拟的测试框架才有意义。

例如,就我而言,我曾开发过很多规模较大的代码库。它们并不庞大,最小的大约有 50 万行代码,最大的大约有 2000 万行代码。然而,即使是最小的软件也能从软件设计核心的抽象接口中获益匪浅。

抽象中央接口

所有这些代码库的共同点之一是作为其基础核心的软件开发工具包。第三方会使用我们的 SDK 为我们的产品编写插件,有时甚至出售插件,他们使用我们用于构建主要产品的相同中央 API 来构建这些插件。

为了能够编写在运行时添加的插件,要求插件依赖于抽象接口,而具体实现位于其他地方(例如:在主应用程序二进制文件或另一个插件中)。

因此,在我们的案例中,强烈需要软件的核心由抽象接口组成,无论是否考虑单元测试*。系统中的每个主要组件都是通过抽象接口使用的,无论是图像、网格、粒子系统、渲染器,甚至像小部件和布局这样的 UI 概念都是抽象使用的。甚至我们的图像加载器/保存器也是抽象的,因此只需在运行时添加一个插件(甚至是第三方编写的插件),该软件就能够加载和保存以前无法识别的图像格式。

* 在我们的例子中,我们的抽象接口类似于 C,使用函数指针表来实现最广泛的兼容性,但在最常用的接口之上使用静态链接的 C++ 包装器,使它们更安全、更易于使用。

模拟测试应该自然契合

在这种情况下,模拟测试框架自然而然地适合。在这些情况下,您不必费尽心思去设计依赖注入的东西,它是自然而然的。由于抽象接口构成了无法访问具体细节的软件的基础,因此别无选择,只能依赖传入的其他抽象。

另外,您是否添加一个抽象基类来定义每个类的公共接口?如果不这样做,你怎么能确定这个类永远不需要被嘲笑呢?

根据上述内容,您不必仅仅为了依赖注入和模拟而使类表面上依赖于抽象接口。否则你可能会发现自己质疑每一个像这样的小设计决策,而这可能会变成一种气味。应该还有其他需求迫使您将这些广泛使用的核心接口抽象化,独立于模拟测试。应该有一些特征促使您在软件的核心寻求抽象,只要它符合可伸缩性/可扩展性要求,使模拟测试成为一种有用的策略。

并非每个项目都能从大多数抽象接口中受益

对于一些较小或定义非常严格的项目,寻求使所有中央接口抽象将完全是矫枉过正,最终会适得其反。在这种情况下,并不那么强烈需要严格定义的单元测试过程。在这种情况下,单元测试和集成测试之间的测试可能会变得模糊,而在这种严格定义的、不可扩展的范围中,这是完全可以接受的。这些类型的抽象案例中的单元测试在团队环境中最有用,在团队环境中,您希望独立于 Joe 的工作来测试您的工作,这可能不正确或将来可能变得不正确。如果您是该工作的唯一作者和维护者并控制着一切,那么通常最大的未知来源之一就会被堵塞,世界不再在您脚下变化,集成测试通常会开始变得越来越有用,而单元测试的有用性,尤其是在涉及模拟的情况下,似乎会减弱。

集成测试

即使在一切都依赖于抽象的代码库中,集成测试也非常有用。有时会出现一些不幸的边缘情况,只有当两个或多个具体实现组合在一起时才会出现,其中两个单独测试时都通过了测试,但组合时却失败了。

通常,当存在某种中间代码,并且两者都使用某种模糊形式的时间耦合时,就会出现这些情况,就像这两个实体都可能使用某些图形库,但图形库根据代码下的操作顺序做了一些奇怪的事情您的控制请求组合在一起时。

然而,集成测试在大型项目中通常是一种痛苦,因为它们通常需要根据现实世界的输入构建复杂的结构。在这些情况下,我在 C 和 C++ 中发现的一个有用技巧是从插件中实际运行测试,为 dylib 提供它们可以提供的可选入口点函数,该函数仅用于测试目的。

这样,主测试应用程序仍然可以构建用于测试的“世界”(在我们的例子中是场景图),然后加载并执行适当的测试插件。这使得每个测试插件不再需要每个集成测试中通常需要的所有代码来启动系统、提前构建/加载所有必要的数据、关闭系统等。我们只需在中央二进制文件中设置一次世界,然后加载适当的测试插件。它还倾向于鼓励不太脆弱的测试,即使在集成测试领域,这些测试仍然在测试相当孤立的部分。就人性而言,似乎当任何类型的测试需要大量样板时,人们都希望编写整体测试(不幸的是,这往往更脆弱)。

并非一切都应该是抽象的

即使您的项目符合模拟测试所需的抽象要求,通常也存在边缘情况。例如,即使在我的情况下,系统的大部分依赖于通过 SDK 提供的抽象接口,我们也有一小部分接口一点也不抽象。

我想到的一个突出的例子是我们的数学库,它主要由线性代数的向量/矩阵类模板组成。在这些情况下,数学库形成了一个稳定的根包(罗伯特·C·马丁通过他的不稳定性度量来描述零传入耦合):它不依赖于其他任何东西。因此,这些库很容易单独进行单元测试。我们编写测试以确保矢量点积产生预期结果(预期结果在其他地方获得并被验证是正确的),例如

这种已经独立于世界的稳定“根”很容易单独测试,即使不涉及任何抽象。有时,C++ 模板作为一种解耦机制很有用,可以将类模板或函数模板与外界解耦,使其完全独立。再说一次,您不必仅仅出于测试目的而强制所有内容都成为类模板或函数模板。作为一个例子,通用的、符合标准的序列容器除了可测试性之外还有更多的优点。尽管可测试性绝对是一个强大的优势,但这并不是使某些东西通用的最有力的理由。

不要强迫它

无论如何,所以我的基本建议是不要强迫它。不要仅仅为了测试而强迫所有东西都是独立的类/函数模板或抽象接口。第一个也是最重要的好处是动态或静态多态性。首先应该关注可扩展性和可重用性,然后易于测试遵循仅依赖于抽象接口的代码的解耦本质。然而,仅仅为了可测试性而表面上将整个项目的所有依赖关系重定向到抽象接口并不一定有效。尝试寻找其他原因使事情变得抽象,而不只是考虑测试(尽管这是一个有用的目标)。