如何将单元测试引入大型遗留(C / C ++)代码库?

时间:2009-04-14 17:05:18

标签: c++ c unit-testing unix legacy

我们有一个用C语言编写的大型多平台应用程序(只有少量但不断增长的C ++)多年来,它已经发展了许多你期望在大型C / C ++应用程序中使用的功能:

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

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

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

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

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

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

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

#ifdefs当然会为问题添加另一个维度。

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

所以这是我对其他任何人的问题:

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

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

13 个答案:

答案 0 :(得分:48)

  

我们还没有发现任何可以缓解“毛球”的过渡   没有单元测试的代码“到”单元可测试代码“。

多么悲伤 - 没有奇迹般的解决方案 - 只需要花费大量精力来纠正累积的technical debt年。

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

你只能用很小的步骤解决它。每个小步骤都包含以下内容。

  1. 选择一段绝对必要的独立代码。 (不要在垃圾边缘蚕食。)选择一个重要的组件,并且 - 不知何故 - 可以从其余部分中雕刻出来。虽然单个函数是理想的,但它可能是一个纠结的函数集群,也可能是整个函数文件。可以从可测试组件的不完美开始。

  2. 弄清楚它应该做什么。弄清楚它的接口应该是什么。要做到这一点,您可能需要进行一些初步重构,以使目标部分实际上是离散的。

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

  4. 将代码重构为整齐,可测试的单元,使其比您当前的毛球更有意义。您将不得不在整体集成测试中保持一些向后兼容性(现在)。

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

  6. 一旦通过,退出旧API并修复将被更改破坏的内容。如有必要,重新进行原始集成测试;它测试旧的API,你想测试新的API。

  7. 迭代。

答案 1 :(得分:25)

Michael Feathers在此写了圣经,Working Effectively with Legacy Code

答案 2 :(得分:8)

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

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

您可以创建测试一个功能所需的一大堆类,并在fitnesse上进行检查。它类似于“特征测试”,但它高出一层。

答案 3 :(得分:7)

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

然而,团队中其他人购买的唯一方法是,如果他们看到个人对保持测试有效的好处。

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

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

鼓励的一件事是共享代码所有权。如果开发人员更改了他们的代码并破坏了其他人的测试,他们不应该期望该人修复他们的测试,他们应该调查测试不起作用的原因并自行修复。根据我的经验,这是最难实现的目标之一。

大多数开发人员都会编写某种形式的单元测试,有时候是一小段他们没有检查或丢失构建的丢弃代码。将这些集成到构建中很容易,开发人员将开始购买。

我的方法是为new添加测试,并且在修改代码时,有时你不能添加尽可能多的或详细的测试而不需要解耦现有的代码,错误的是实际的。

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

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

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

我们的夜间构建是使用ant和luntbuild自动构建的(ant glues c ++,。net和java build)

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

答案 4 :(得分:5)

我们正在做这件事。三年前,我加入了开发团队的项目,没有单元测试,几乎没有代码审查,还有一个相当特别的构建过程。

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

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

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

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

我们最近专注于改进我们的代码审查流程,这种流程很少见且效率低下。目的是使启动和执行代码审查的成本更低,以便鼓励开发人员更频繁地执行它们。此外,作为我们流程改进的一部分,我正在努力将项目规划中包含的代码审查和单元测试花费在更低的水平上,以确保个别开发人员必须更多地考虑它们,而以前只有一个固定的比例时间投入到他们身上更容易迷失在日程表中。

答案 5 :(得分:4)

我参与过Green field项目,完全经过单元测试的代码库和大型C ++应用程序,这些应用程序已经发展了很多年,并且有很多不同的开发人员。

老实说,我不打算尝试将遗留代码库添加到单元测试和测试第一次开发可以增加很多价值的状态。

一旦遗留代码库达到一定的规模和复杂性,就可以使单元测试覆盖率为您提供许多好处,这相当于完​​全重写。

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

这意味着你要么非常缓慢而且要小心,直到数年之后你才能从单元测试的代码库中获益。 (可能从未发生合并等事件。)与此同时,您可能会向软件的最终用户介绍一些没有明显价值的新错误。

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

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

答案 6 :(得分:3)

要考虑的一种方法是首先建立一个可用于开发集成测试的系统范围的仿真框架。从集成测试开始可能看似违反直觉,但在您描述的环境中进行真正的单元测试的问题非常艰巨。可能不仅仅是模拟软件中的整个运行时间......

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

PS:考虑编写一个命令驱动的仿真框架,可能是基于Python或Tcl构建的。这样可以让您轻松编写测试脚本......

答案 7 :(得分:3)

天儿真好,

我首先要看看任何明显的观点,例如:在头文件中使用dec的一个。

然后开始查看代码的布局方式。这合乎逻辑吗?也许开始将大文件分解成较小的文件。

也许可以获取Jon Lakos的优秀书籍“大规模C ++软件设计”(sanitised Amazon link)的副本,以获得有关如何布局的一些想法。

一旦你开始对代码库本身更加信任,即文件布局中的代码布局,并清除了一些难闻的气味,例如:在头文件中使用dec,然后您可以开始挑选一些可用于开始编写单元测试的功能。

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

但这将是漫长而缓慢的旅程。

HTH

欢呼声,

答案 8 :(得分:2)

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

听起来好像,你想要重构。您需要首先打破最简单的实用程序并在它们上构建。你有你的C模块做了很多事情。也许,例如,那里有一些代码总是以某种方式格式化字符串。也许这可以成为一个独立的实用模块。你已经有了新的字符串格式化模块,你已经使代码更具可读性。它已经有了改进。你断言你处于困境22。你真的不是。只需移动一下,就可以使代码更具可读性和可维护性。

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

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

答案 9 :(得分:2)

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

  1. 重构的大代码库
  2. 与团队合作
  3. 模块化,重构,插入单元测试等都是一项艰巨的任务,我怀疑任何工具都可以接管这项工作的大部分内容。这是一种罕见的技能。一些程序员可以做得很好。最讨厌它。

    与团队一起完成这项任务非常繁琐。我强烈怀疑“强迫”开发人员永远都会工作。 Iains的想法非常好,但我会考虑找到一两个能够和想要“清理”来源的程序员:Refactor,Modualrize,引入单元测试等等。让这些人完成工作,其他人介绍新的错误,aehm功能。只有那些喜欢这类工作的人才能成功完成这项工作。

答案 10 :(得分:1)

轻松使用测试。

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

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

这意味着您需要一个最新版本,您可能需要更改人们如何处理代码的策略等。我知道这样的过程可以是带有嵌入式设备的PITA,我不能给出任何建议。但我知道如果运行测试很难,没有人会写它们。

测试可以测试的内容

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

我很惊讶简单的测试往往会发现。挑选低垂的果实绝不是无用的。

以另一种方式看待它:如果它不是一个成功的产品,你不打算维持那个巨大的毛球混乱。您当前的质量控制并非需要更换的完全故障。相反,使用单元测试很容易做到。

(你需要完成它。不要陷入围绕你的构建过程“修复所有事情”。)

教授如何改进代码库

任何具有该历史记录的代码库都会为改进而尖叫,这是肯定的。但是,你永远不会重构所有这些。

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

  • 如何平衡不同方面
  • 如何同意这段代码足够好
  • 如何在不破坏任何内容的情况下将错误的代码转换为足够好的代码。

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


答案 11 :(得分:1)

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

您真的想要经过测试,功能齐全,整洁的代码吗?这是你的目标吗?你从中得到任何好处吗?。

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

我只是在这里猜测,但是,承担这场巨大战斗所带来的风险可能比通过让代码整洁而获得的回报高得多。如果你缺乏社交技巧来解决这个问题,你将被视为麻烦制造者。我见过这些家伙,我也是一个人。但是,当然,如果你确实这样做的话,这很酷。我会留下深刻的印象。

但是,如果你觉得你现在被欺骗花费额外的时间来保持一个不整洁的系统工作,你真的认为一旦代码变得整洁和漂亮会改变吗?不......一旦代码变得干净整洁,人们就可以在第一个截止日期前将所有这些空闲时间再次彻底销毁。

最终,管理层创造了良好的工作场所,而不是代码。

答案 12 :(得分:0)

不确定它是否实际,但是我在这里有一些建议。据我了解,您会提出方法论问题,涉及将单元测试逐步无创地集成到庞大的旧代码中,并且有很多利益相关者来保护他们的沼泽。

通常,第一步是独立于所有其他代码来构建测试代码。即使是长期存在的旧代码中的这一步骤也非常复杂。我建议将您的测试代码构建为带有运行时链接的动态共享库。这样一来,您就只能重构被测试不足的一小段代码,而不是整个20K文件。因此,您可以开始逐个功能地介绍功能,而无需触摸/修复所有链接问题

相关问题