77

我们有一个用 C 编写的大型多平台应用程序。(使用少量但不断增长的 C++)它经过多年发展,具有许多您期望在大型 C/C++ 应用程序中具有的特性:

  • #ifdef地狱
  • 难以隔离可测试代码的大文件
  • 过于复杂而无法轻松测试的功能

由于此代码是针对嵌入式设备的,因此在实际目标上运行它会产生很多开销。因此,我们希望在本地系统上以快速的周期进行更多的开发和测试。但我们想避免“复制/粘贴到系统上的 .c 文件、修复错误、复制/粘贴回去”的经典策略。如果开发人员要不厌其烦地这样做,我们希望以后能够重新创建相同的测试,并以自动化的方式运行。

这是我们的问题:为了将代码重构为更加模块化,我们需要它更具可测试性。但是为了引入自动化单元测试,我们需要它更加模块化。

一个问题是,由于我们的文件太大,我们可能在一个文件中有一个函数,该函数调用一个文件中的一个函数,我们需要将其存根以进行良好的单元测试。随着我们的代码变得更加模块化,这似乎不再是一个问题,但这还有很长的路要走。

我们想做的一件事是用注释标记“已知可测试”的源代码。然后我们可以为可测试代码编写脚本扫描源文件,将其编译到单独的文件中,并将其与单元测试链接。当我们修复缺陷并添加更多功能时,我们可以慢慢引入单元测试。

但是,有人担心维护这个方案(以及所有必需的存根函数)会变得太麻烦,并且开发人员将停止维护单元测试。所以另一种方法是使用一个工具,自动为所有代码生成存根,并将文件与它链接起来。(我们发现可以做到这一点的唯一工具是昂贵的商业产品)但是这种方法似乎要求我们所有的代码在开始之前都更加模块化,因为只有外部调用可以被存根。

就个人而言,我宁愿让开发人员考虑他们的外部依赖关系并智能地编写他们自己的存根。但是,对于一个 10,000 行的文件来说,这可能是为了消除所有依赖关系而难以承受的。可能很难说服开发人员他们需要为所有外部依赖项维护存根,但这是正确的方法吗?(我听到的另一个论点是子系统的维护者应该为他们的子系统维护存根。但我想知道“强迫”开发人员编写他们自己的存根是否会导致更好的单元测试?)

#ifdefs当然,这个问题又增加了一个完整的维度。

我们已经查看了几个基于 C/C++ 的单元测试框架,并且有很多看起来不错的选项。但是我们还没有找到任何东西来缓解从“没有单元测试的毛球代码”到“可单元测试的代码”的过渡。

所以这是我对其他经历过这个的人的问题:

  • 什么是好的起点?我们是在朝着正确的方向前进,还是我们错过了一些明显的东西?
  • 哪些工具可能有助于过渡?(最好是免费/开源,因为我们现在的预算大约是“零”)

请注意,我们的构建环境是基于 Linux/UNIX 的,因此我们不能使用任何仅限 Windows 的工具。

4

13 回答 13

52

我们还没有找到任何东西来缓解从“没有单元测试的代码毛球”到“可单元测试的代码”的过渡。

多么可悲——没有奇迹般的解决方案——只是为了纠正多年积累的技术债务而进行的大量艰苦工作。

没有简单的过渡。你有一个大的、复杂的、严重的问题。

您只能通过微小的步骤来解决它。每个微小的步骤都涉及以下内容。

  1. 选择一段绝对必要的离散代码。(不要在垃圾的边缘蚕食。)选择一个重要的组件,并且——不知何故——可以从其余部分中剔除。虽然单个函数是理想的,但它可能是一组错综复杂的函数,也可能是一个完整的函数文件。可以从对您的可测试组件来说不太完美的东西开始。

  2. 弄清楚它应该做什么。弄清楚它的界面应该是什么。为此,您可能必须进行一些初始重构以使您的目标片段实际上是离散的。

  3. 编写一个“整体”集成测试——现在——或多或少地测试你发现的离散代码。在您尝试更改任何重要内容之前,请先通过此操作。

  4. 将代码重构为整洁、可测试的单元,这些单元比您当前的毛球更有意义。您将不得不(目前)与您的整体集成测试保持一些向后兼容性。

  5. 为新单元编写单元测试。

  6. 一切都通过后,停用旧 API 并修复更改将破坏的内容。如有必要,返工原始集成测试;它测试旧的 API,你想测试新的 API。

迭代。

于 2009-04-14T17:19:27.337 回答
25

Michael Feathers 写了这本圣经,有效地使用遗留代码

于 2009-04-14T17:24:40.133 回答
9

我对遗留代码和引入测试的一点经验是创建“特征测试”。您开始使用已知输入创建测试,然后获取输出。这些测试对于您不知道它们真正做什么但您知道它们正在工作的方法/类很有用。

但是,有时几乎不可能创建单元测试(甚至是特征测试)。在那种情况下,我通过验收测试(在这种情况下为Fitnesse )来解决问题。

您创建了测试一个功能所需的一大堆类,并在 Fitnesse 上进行检查。它类似于“表征测试”,但要高一级。

于 2009-04-14T17:35:08.423 回答
7

正如乔治所说,有效地使用遗留代码是这类事情的圣经。

但是,您团队中的其他人会接受的唯一方法是,如果他们看到了保持测试正常运行对他们个人的好处。

为此,您需要一个尽可能易于使用的测试框架。计划让其他开发人员将您的测试作为示例来编写自己的测试。如果他们没有单元测试经验,不要指望他们花时间学习框架,他们可能会认为编写单元测试会减慢他们的开发速度,因此不知道框架是跳过测试的借口。

花一些时间使用巡航控制、luntbuild、cdash 等进行持续集成。如果您的代码每晚自动编译并运行测试,那么如果单元测试在 qa 之前发现错误,开发人员将开始看到好处。

值得鼓励的一件事是共享代码所有权。如果开发人员更改了他们的代码并破坏了其他人的测试,他们不应该期望那个人修复他们的测试,他们应该调查为什么测试不工作并自己修复它。根据我的经验,这是最难实现的事情之一。

大多数开发人员编写某种形式的单元测试,有时是一小段他们不签入或集成构建的一次性代码。将这些轻松集成到构建中,开发人员将开始购买。

我的方法是为新的代码添加测试,并且随着代码的修改,有时您无法在不解耦太多现有代码的情况下添加尽可能多或尽可能详细的测试,在实用方面会出错。

我坚持单元测试的唯一地方是平台特定的代码。#ifdefs 被平台特定的更高级别函数/类替换的地方,必须在所有平台上使用相同的测试进行测试。这可以节省大量添加新平台的时间。

我们使用 boost::test 来构建我们的测试,简单的自注册函数使编写测试变得容易。

这些封装在 CTest(CMake 的一部分)中,它一次运行一组单元测试可执行文件并生成一个简单的报告。

我们的夜间构建使用 ant 和 luntbuild 自动化(ant 粘合 c++、.net 和 java 构建)

很快我希望在构建中添加自动化部署和功能测试。

于 2009-04-16T00:14:03.917 回答
5

我们正在这样做。三年前,我加入开发团队的项目没有单元测试,几乎没有代码审查,以及相当临时的构建过程。

代码库由一组 COM 组件 (ATL/MFC)、跨平台 C++ Oracle 数据盒和一些 Java 组件组成,所有这些都使用跨平台 C++ 核心库。有些代码已经有将近十年的历史了。

第一步是添加一些单元测试。不幸的是,这种行为非常受数据驱动,因此在生成单元测试框架(最初是 CppUnit,现在通过 JUnit 和 NUnit 扩展到其他模块)方面进行了一些初步努力,该框架使用来自数据库的测试数据。大多数最初的测试都是功能测试,它锻炼了最外层,而不是真正的单元测试。您可能需要花费一些精力(您可能需要为此进行预算)来实施测试工具。

如果你尽可能降低添加单元测试的成本,我发现它会很有帮助。测试框架使得在修复现有功能中的错误时添加测试相对容易,新代码可以进行适当的单元测试。当您重构和实现新的代码区域时,您可以添加适当的单元测试来测试更小的代码区域。

去年,我们增加了与 CruiseControl 的持续集成,并使我们的构建过程自动化。这增加了使测试保持最新和通过的更多动力,这在早期是一个大问题。因此,我建议您将常规(至少每晚)单元测试运行作为开发过程的一部分。

我们最近专注于改进我们的代码审查流程,该流程相当少见且效率低下。其目的是降低启动和执行代码审查的成本,从而鼓励开发人员更频繁地进行审查。此外,作为我们流程改进的一部分,我试图在项目规划中获得时间进行代码审查和单元测试,以确保单个开发人员必须更多地考虑它们,而以前只有固定的比例花费在他们身上的时间更容易在日程安排中迷失。

于 2009-04-16T19:19:41.010 回答
4

我曾参与过具有完全单元测试代码库的 Green field 项目和多年来不断发展的大型 C++ 应用程序,并且有许多不同的开发人员参与其中。

老实说,我不会费心尝试将遗留代码库置于单元测试和测试优先开发可以增加很多价值的状态。

一旦遗留代码库达到一定的大小和复杂性,使其达到单元测试覆盖率为您提供很多好处的地步,就变成了一项相当于完全重写的任务。

主要问题是,一旦您开始重构以实现可测试性,您就会开始引入错误。只有当您获得高测试覆盖率时,您才能期望找到并修复所有这些新错误。

这意味着您要么非常缓慢而谨慎地进行,并且直到几年后您才能获得经过良好单元测试的代码库的好处。(可能从没有发生合并等。)与此同时,您可能会引入一些对软件的最终用户没有明显价值的新错误。

或者你速度很快,但代码库不稳定,直到你的所有代码都达到了高测试覆盖率。(所以你最终有 2 个分支,一个在生产中,一个用于单元测试版本。)

当然,对于某些项目来说,这一切都是规模问题,重写可能只需要几周时间,而且肯定是值得的。

于 2009-04-14T18:24:56.277 回答
3

要考虑的一种方法是首先放置一个系统范围的模拟框架,您可以使用它来开发集成测试。从集成测试开始可能看起来违反直觉,但是在您描述的环境中进行真正的单元测试的问题是相当可怕的。可能不仅仅是在软件中模拟整个运行时......

这种方法只会绕过您列出的问题——尽管它会给您许多不同的问题。但在实践中,我发现使用健壮的集成测试框架,您可以开发在单元级别执行功能的测试,尽管没有单元隔离。

PS:考虑编写一个命令驱动的仿真框架,可能建立在 Python 或 Tcl 之上。这将使您可以很容易地编写脚本测试...

于 2009-04-14T17:18:32.630 回答
3

天,

我将首先查看任何明显的点,例如在头文件中使用 dec。

然后开始查看代码是如何布局的。合乎逻辑吗?也许开始将大文件分解成更小的文件。

或许可以拿一份 Jon Lakos 的优秀著作《Large-Scale C++ Software Design》(经过消毒的 Amazon 链接)来了解应该如何布局。

一旦你开始对代码库本身有了更多的信心,即文件布局中的代码布局,并且已经清除了一些不好的气味,例如在头文件中使用 dec,那么你就可以开始挑选一些你可以使用的功能用于开始编写单元测试。

选择一个好的平台,我喜欢 CUnit 和 CPPUnit,然后从那里开始。

不过,这将是一段漫长而缓慢的旅程。

高温高压

干杯,

于 2009-04-14T17:18:39.680 回答
2

首先使其更加模块化要容易得多。您不能真正对具有大量依赖项的东西进行单元测试。何时重构是一个棘手的计算。您确实必须权衡成本和风险与收益。这段代码会被广泛重用吗?或者这段代码真的不会改变。如果您打算继续使用它,那么您可能想要重构。

听起来,你想重构。您需要首先分解最简单的实用程序并在它们的基础上进行构建。你的 C 模块做了很多事情。例如,可能有一些代码总是以某种方式格式化字符串。也许这可以成为一个独立的实用程序模块。您已经有了新的字符串格式化模块,使代码更具可读性。它已经是一个改进。您是在断言您处于第 22 条捕获状态。你真的不是。仅仅通过移动事物,您就使代码更具可读性和可维护性。

现在您可以为这个断开的模块创建一个单元测试。你可以通过几种方式做到这一点。您可以制作一个单独的应用程序,只包含您的代码并在您的 PC 上的主例程中运行一堆案例,或者定义一个名为“UnitTest”的静态函数,它将执行所有测试用例并在它们通过时返回“1”。这可以在目标上运行。

也许你不能 100% 使用这种方法,但它是一个开始,它可能会让你看到其他可以轻松分解为可测试实用程序的东西。

于 2009-04-14T17:18:20.960 回答
2

这一切都有一个哲学方面。

你真的想要经过测试、功能齐全、整洁的代码吗?这是你的目标吗?你有什么好处吗?

是的,起初这听起来很愚蠢。但老实说,除非你是系统的实际所有者,而不仅仅是一名员工,否则 bug 就意味着更多的工作,更多的工作意味着更多的钱。在处理毛球时,您会非常开心。

我只是在这里猜测,但是,你承担这场巨大的战斗所承担的风险可能比你通过整理代码获得的可能回报要高得多。如果你缺乏社交技巧来解决这个问题,你只会被视为麻烦制造者。我见过这些人,我也是其中之一。但是,当然,如果你能做到这一点,那就太酷了。我会印象深刻的。

但是,如果你觉得你现在被欺负了花费额外的时间来保持一个不整洁的系统工作,你真的认为一旦代码变得整洁和漂亮,这种情况就会改变吗?不.. 一旦代码变得整洁,人们将有这么多空闲时间在第一个可用的截止日期再次完全销毁它。

最后,创造美好工作场所的是管理层,而不是代码。

于 2013-01-22T20:33:09.963 回答
2

我认为,基本上你有两个不同的问题:

  1. 需要重构的大型代码库
  2. 与团队合作

模块化、重构、插入单元测试等是一项艰巨的任务,我怀疑任何工具都可以接管大部分工作。是一种难得的技能。一些程序员可以很好地做到这一点。大多数人讨厌它。

与团队一起完成这样的任务很乏味。我强烈怀疑“强迫”开发人员是否会奏效。Iains 的想法很好,但我会考虑找一两个能够并且想要“清理”源代码的程序员:重构、模块化、引入单元测试等。让这些人完成工作,其他人引入新的错误,aehm 功能。只有喜欢这种工作的人才会在这份工作上取得成功。

于 2009-04-16T08:27:10.020 回答
2

使使用测试变得容易。

我首先将“自动运行”放置到位。如果您希望开发人员(包括您自己)编写测试,请轻松运行它们并查看结果。

编写三行测试,针对最新版本运行它并查看结果应该只需单击一下即可,而不是将开发人员发送到咖啡机。

这意味着您需要最新版本,您可能需要更改人们如何处理代码等的政策。我知道这样的过程可以是嵌入式设备的 PITA,对此我无法提供任何建议。但我知道,如果运行测试很难,没有人会编写它们。

测试什么可以测试

我知道我在这里违背了常见的单元测试理念,但这就是我所做的:为易于测试的事物编写测试。我不打扰嘲笑,我不重构使其可测试,如果涉及 UI,我没有单元测试。但是越来越多的我的图书馆例程都有一个。

我很惊讶简单的测试会发现什么。采摘低垂的果实绝不是无用的。

换个角度来看:如果它不是一个成功的产品,你就不会打算维持那个巨大的毛球烂摊子。您当前的质量控制不是需要更换的完全失败。相反,在容易做到的地方使用单元测试。

(不过,您需要完成它。不要陷入构建过程中“修复所有问题”的困境。)

教你如何改进你的代码库

任何具有这种历史的代码库都需要改进,这是肯定的。但是,您永远不会重构所有这些。

查看具有相同功能的两段代码,大多数人都会同意哪一个在给定方面(性能、可读性、可维护性、可测试性……)下“更好”。困难的部分是三个:

  • 如何平衡不同的方面
  • 如何同意这段代码足够好
  • 如何在不破坏任何东西的情况下将糟糕的代码变成足够好的代码。

第一点可能是最难的,也是一个社会问题和工程问题。但其他点可以学习。我不知道任何采用这种方法的正式课程,但也许你可以在内部组织一些东西:从两个人一起写到“研讨会”,你可以在其中获取一段讨厌的代码并讨论如何改进它。


于 2009-04-16T09:09:55.610 回答
-1

不确定它是否真实,但我在这里有一些小建议。据我了解,您提出了有关将单元测试增量非侵入性集成到大量遗留代码中的方法问题,许多利益相关者保护他们的沼泽。

通常,第一步是独立于所有其他代码构建您的测试代码。即使是长期遗留代码中的这一步也非常复杂。我建议将您的测试代码构建为具有运行时链接的动态共享库。这将允许您仅重构一小段测试不足的代码,而不是整个 20K 文件。因此,您可以开始逐个功能覆盖,而无需触及/修复所有链接问题

于 2020-02-18T10:12:35.573 回答