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


74

我们有一个大型的,用C语言编写的多平台应用程序(使用的C ++数量很少,但数量在不断增长)。随着多年来的发展,它具有许多您希望在大型C / C ++应用程序中使用的功能:

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

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

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

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

我们考虑做的一件事是用注释标记“已知是可测试的”源代码。然后,我们可以编写脚本扫描源文件以获取可测试的代码,将其编译为单独的文件,然后将其与单元测试链接。我们可以在修复缺陷和添加更多功能时慢慢介绍单元测试。

但是,令人担忧的是,维护此方案(以及所有必需的桩函数)将变得很麻烦,并且开发人员将停止维护单元测试。因此,另一种方法是使用一种工具,该工具会自动为所有代码生成存根,并将其与文件链接。(我们发现唯一可以执行此操作的工具是昂贵的商业产品),但是这种方法似乎要求我们所有的代码在开始之前都必须更加模块化,因为只能进行外部调用。

就个人而言,我希望开发人员考虑其外部依赖关系并智能地编写自己的存根。但是,如果将所有依赖项存根为一个严重过度增长的10,000行文件,可能会不堪重负。可能很难说服开发人员他们需要维护所有外部依赖项的存根,但这是正确的方法吗?(我听到的另一个论点是子系统的维护者应维护其子系统的存根。但是我想知道是否“强迫”开发人员编写自己的存根会导致更好的单元测试?)

#ifdefs,当然,再添全尺寸的问题。

我们已经研究了几种基于C / C ++的单元测试框架,并且有很多看起来不错的选项。但是我们还没有发现任何简化从“没有单元测试的代码圈”到“可单元测试的代码”的过渡的方法。

因此,这是我对遇到过此问题的其他人的疑问:

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

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


5
@ S.Lott很抱歉,但是关于单元测试的问题如何对TDD的问题造成重复?
罗布·威尔斯

7
@ S.Lott:单元测试不是TDD。TDD涉及到单元测试,但是编写代码也可以对单元测试进行改造,这就是这个问题所要解决的问题。至于第二个,我也不同意。这是特定于C / C ++的语言,这些语言带来了自己的单元测试挑战。
jalf

4
这个问题非常适合我的情况,我什至检查了我是否写过!
Markus Schnell,2009年

1
相关的SO问题:08年9月18日起stackoverflow.com/questions/91683/…
linuxbuild 2011年

1
@Mike我发现您没有排除答案,而且我也开始面临类似的问题。也许您可以分享有关此问题的经验,以及过去五年中最适合您的解决方案。
robbmj 2014年

Answers:


49

我们没有发现任何简化从“没有单元测试的代码圈”到“可单元测试的代码”的过渡的方法。

多么难过-没有奇迹般的解决方案-只是纠正多年积累的技术债务的艰苦工作。

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

您只能一步步解决。每个小步骤都涉及以下内容。

  1. 选择绝对必要的离散代码。(不要在垃圾边缘四处闲逛。)选择一个重要的组件,并且可以以某种方式将其雕刻出来。尽管单个功能是理想的,但它可能是功能缠结的群集,或者可能是功能的整个文件。可以从对可测试组件而言不完美的东西开始。

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

  3. 编写一个“总体”集成测试,目前,它或多或少地测试了您发现的离散代码段。在尝试更改任何重大更改之前,请先通过此更改。

  4. 将代码重构为整齐的,可测试的单元,比当前的毛线球更有意义。您现在必须与整体集成测试保持一些向后兼容性。

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

  6. 一旦全部通过,请停用旧的API并修复更改所导致的问题。如有必要,请对原始集成测试进行重做;它会测试旧的API,您想测试新的API。

重复。


这个答案听起来不错。但它假设我们在时间表中有时间进行此操作。;)我们可能最终会基本上做到这一点,但并不能完全遵循您的建议。也就是说,我们可能需要“四处乱扔垃圾”。垃圾是指任何包含错误的代码,或者是我们要引入的任何代码,或者是为了引入新代码而必须接触的任何代码。我们可能没有奢侈地选择一个庞大的核心功能。
mpontillo

4
如果您不是从核心功能开始,那么测试是可选的。经理将决定不需要测试,并将放弃测试。如果您从核心开始,那么测试就变得至关重要。
S.Lott

我同意你的看法,但愿事情就这么简单。不幸的是,华尔街只关注下一个季度。因此,中层管理人员希望测试必不可少,而高层管理人员则希望尽快提供更多功能!他们想要质量,范围和进度表,但是他们不愿意接受范围或进度表的打击。因此,我们需要保持平衡。
mpontillo

3
@迈克:这很简单。除非您深入研究重要问题,否则管理冲突将不可避免地使工作失败。情况很糟,但并不复杂。当模块测试被取消或否决时,请妥善保存您的电子邮件并窃笑。
S.Lott

1
每个开发人员在项目后期加入的痛苦。:(
Narayana


8

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

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

您创建了测试一个功能并在fitnesse上进行检查所需的全部类。它类似于“特征化测试”,但级别更高。


7

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

但是,团队中其他人唯一能接受的方法是,他们个人看到保持测试正常进行的好处。

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

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

鼓励的一件事是共享代码所有权。如果开发人员更改了代码并破坏了其他人的测试,他们不应期望该人来修复他们的测试,他们应该调查为什么该测试无法正常工作并自己进行修复。以我的经验,这是最难实现的事情之一。

大多数开发人员会编写某种形式的单元测试,有时会写一些一小段的即弃即弃代码,而他们不会检入或集成该版本。使这些易于集成到构建中,开发人员将开始接受。

我的方法是为新代码添加测试,并且随着代码的修改,有时您可能无法添加任意数量的详细测试,而又不去耦合太多现有代码,这在实践上是错误的。

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

我们使用boost :: test构造测试,简单的自注册功能使编写测试变得容易。

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

我们的每晚构建都是通过ant和luntbuild自动化的(ant将c ++ 、. net和Java构建粘合在一起)

很快,我希望为该构建添加自动部署和功能测试。


5

我们正在完全做到这一点。三年前,我加入了开发团队,参与了一个项目,该项目没有单元测试,几乎没有代码审查,而且构建过程相当临时。

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

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

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

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

最近,我们集中精力改进了代码审查流程,这种流程很少见且效率低下。目的是使启动和执行代码审查的成本降低很多,从而鼓励开发人员更频繁地执行它们。另外,作为流程改进的一部分,我试图在较低的级别上花时间进行项目计划中的代码复审和单元测试,以确保各个开发人员都必须对它们进行更多的考虑,而以前只有固定比例花在他们身上的时间很容易在时间表中迷失。


4

我曾在Greenfield项目中进行过工作,这些项​​目具有经过完全单元测试的代码库和大型C ++应用程序,这些应用程序已经发展了很多年,并且拥有许多不同的开发人员。

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

一旦遗留代码库达到一定的大小和复杂度,使其达到单元测试覆盖率可为您带来很多好处的地步,便成为等效于完全重写的任务。

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

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

或者您走得很快,但代码库不稳定,直到您对所有代码都进行了较高的测试覆盖为止。(因此,最终有2个分支,一个在生产中,一个在单元测试版本中。)

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


您可以稍微重构代码以使其可测试,然后编写测试,最后进行完整的重构。放弃旧的代码库并非易事,通常情况下,代码包含必不可少的隐藏假设或“显而易见的”(对用户而言)逻辑。从头开始是丢弃它们的肯定方法。
休伯特·卡里奥

3

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

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

PS:考虑编写一个命令驱动的仿真框架,该框架可能基于Python或Tcl。这将使您非常轻松地编写测试脚本...


很好的建议!有了良好的集成测试,他就可以开始将代码重构为更具模块化和单元测试性。根本没有任何测试,开始重构为可单元测试的代码的风险将太大。
2009年

感谢你的回答。整个系统的仿真将是很棒的。这是一种可能,但是它需要大量工作。一些模块已经足够分离,以便开发人员可以在自己的仿真环境中运行,但是现在我要说这是例外,而不是规则。
mpontillo

3

G'day,

首先,我要看一下任何明显的地方,例如在头文件中使用dec。

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

也许可以获取乔恩·拉科斯(Jon Lakos)的出色著作“大型C ++软件设计”(经过消毒的Amazon链接)的副本,以获取有关其布局的一些想法。

一旦您对代码库本身有了更多的信心,即像文件布局中的代码布局一样,并且清除了一些不良气味(例如在头文件中使用dec's),则可以开始选择一些功能,用于开始编写单元测试。

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

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

高温超导

干杯,


2

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

听起来好像,您想重构。您需要首先突破最简单的实用程序并在它们之上进行构建。您的C模块可以完成大量工作。例如,也许其中有些代码始终以某种方式格式化字符串。也许可以将其作为一个独立的实用程序模块。您已经有了新的字符串格式化模块,并使代码更具可读性。它已经是一个进步。您断言自己处于困境22状态。你真的不是。只需四处移动,就可以使代码更具可读性和可维护性。

现在,您可以为此细分模块创建一个单元测试。您可以通过两种方法来实现。您可以制作一个单独的应用程序,其中仅包含您的代码并在PC上的主例程中运行大量案例,或者可以定义一个名为“ UnitTest”的静态函数,该函数将执行所有测试案例,如果通过则返回“ 1”。这可以在目标上运行。

也许您不能百分百采用这种方法,但这只是一个开始,它可能使您看到可以轻松分解为可测试实用程序的其他内容。


2

我认为,基本上,您有两个独立的问题:

  1. 大量代码库可重构
  2. 与团队合作

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

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


让“合适”的人去做这项工作非常重要。这可能会在现实生活中有所作为。同样,这使得更容易考虑成本(增加n人,而不是n人付出无法量化的宝贵时间)。
罗伯特·罗伯特(A.Robert

1

使使用测试变得容易。

我将从将“自动运行”放到适当位置开始。如果您希望开发人员(包括您自己)编写测试,请使其易于运行并查看结果。

编写三行测试,对最新版本进行测试,然后只需单击一下即可看到结果,而无需将开发人员发送到咖啡机。

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

测试可以测试的东西

我知道我在这里违背了通用的单元测试哲学,但这就是我要做的事情:针对易于测试的内容编写测试。我不喜欢嘲笑,我不重构使其可测试,并且如果涉及UI,则我没有单元测试。但是越来越多的我的库例程有一个。

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

用另一种方式看待它:如果它不是一个成功的产品,您将不打算保留那种巨大的毛团。您当前的质量控制并非需要完全解决的问题。而是在易于执行的地方使用单元测试。

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

教如何改善代码库

可以肯定的是,具有该历史记录的任何代码库都要求改进。但是,您将永远不会重构所有这些。

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

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

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



1

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

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

是的,起初听起来完全是愚蠢的。但老实说,除非您是系统的真正所有者,而不仅仅是雇员,否则Bug意味着更多的工作,更多的工作意味着更多的钱。在毛发球上工作时,您可能会完全快乐。

我只是在这里猜测,但是,进行这场巨大的战斗所承受的风险可能比使代码整洁而可能获得的回报要高得多。如果您缺乏社交能力来实现这一目标,那么您将被视为麻烦制造者。我见过这些家伙,我也是其中之一。但是,当然,如果您确实做到这一点,那就太酷了。我会被感动。

但是,如果您觉得自己现在被欺负花费更多的时间来保持不整洁的系统正常工作,那么您真的认为一旦代码变得整洁漂亮就可以改变这种状况吗?不。一旦代码变得井井有条,人们将获得所有这些空闲时间,以在第一个可用的最后期限内完全销毁它。

最后,是由管理人员创造的工作场所,而不是代码。


0

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

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

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.