【问题标题】:Unit testing large blocks of code (mappings, translation, etc)单元测试大块代码(映射、翻译等)
【发布时间】:2011-01-05 19:00:38
【问题描述】:

我们对大部分业务逻辑进行了单元测试,但仍停留在如何最好地测试我们的一些大型服务任务和导入/导出例程上。例如,考虑将工资单数据从一个系统导出到第 3 方系统。为了以公司需要的格式导出数据,我们需要访问大约 40 个表,这为创建测试数据和模拟依赖关系创造了一个噩梦。

例如,考虑以下(大约 3500 行导出代码的子集):

public void ExportPaychecks()
{
   var pays = _pays.GetPaysForCurrentDate();
   foreach (PayObject pay in pays)
   {
      WriteHeaderRow(pay);
      if (pay.IsFirstCheck)
      {
         WriteDetailRowType1(pay);
      }
   }
}

private void WriteHeaderRow(PayObject pay)
{
   //do lots more stuff
}

private void WriteDetailRowType1(PayObject pay)
{
   //do lots more stuff
}

我们在这个特定的导出类中只有一个公共方法——ExportPaychecks()。对于调用此类的人来说,这确实是唯一有意义的操作……其他一切都是私有的(约 80 个私有函数)。我们可以将它们公开以进行测试,但随后我们需要模拟它们以分别测试每一个(即,如果不模拟 WriteHeaderRow 函数,您将无法在真空中测试 ExportPaychecks。这也是一个巨大的痛苦。

由于这是一个单一的导出,对于单一供应商来说,将逻辑移入域是没有意义的。该逻辑在这个特定类之外没有领域意义。作为测试,我们构建了具有接近 100% 代码覆盖率的单元测试……但这需要输入到存根/模拟对象中的大量测试数据,加上由于存根/模拟我们的许多依赖项而产生的超过 7000 行代码.

作为 HRIS 软件的制造商,我们拥有数百个出口和进口产品。其他公司真的对这类事情进行单元测试吗?如果是这样,有什么捷径可以减轻痛苦吗?我很想说“没有对导入/导出例程进行单元测试”,稍后再实施集成测试。

更新 - 感谢大家的回答。我很想看到一个例子,因为我仍然没有看到有人如何将大文件导出之类的东西变成易于测试的代码块,而不会使代码变得一团糟。

【问题讨论】:

  • +1 表示一个非常有趣的问题。

标签: c# unit-testing etl


【解决方案1】:

这种(尝试的)单元测试风格,你试图通过单一的公共方法覆盖整个巨大的代码库,这总是让我想起通过小开口执行复杂操作的外科医生、牙医或妇科医生。可能,但并不容易。

封装是面向对象设计中的一个古老概念,但有些人将其推向极端以至于可测试性受到影响。还有另一个 OO 原则,称为Open/Closed Principle,它更适合可测试性。封装仍然很有价值,但不能以牺牲可扩展性为代价——事实上,testability is really just another word for the Open/Closed Principle

我并不是说你应该公开你的私有方法,但我的意思是你应该考虑将你的应用程序重构为可组合的部分——许多协作的小类而不是一个大的Transaction Script。您可能认为针对单个供应商的解决方案这样做没有多大意义,但现在您正在受苦,这是一条出路。

当您在复杂的 API 中拆分单个方法时,通常会发生的情况是您还获得了很多额外的灵活性。最初的一次性项目可能会变成可重用的库。


以下是关于如何针对当前问题执行重构的一些想法:每个 ETL 应用程序都必须执行至少这三个步骤:

  1. 从源中提取数据
  2. 转换数据
  3. 将数据加载到目标中

(因此,名称 ETL)。作为重构的开始,这为我们提供了至少三个具有不同职责的类:ExtractorTransformerLoader。现在,不是一个大类,而是三个具有更有针对性的职责。没有什么乱七八糟的,而且已经有了更多的可测试性。

现在放大这三个领域,看看你可以在哪里进一步划分职责。

  • 至少,您需要对源数据的每一“行”进行良好的内存表示。如果源是关系数据库,您可能希望使用 ORM,但如果不是,则需要对此类类进行建模,以便它们正确保护每一行的不变量(例如,如果字段不可为空,则该类应保证如果尝试空值,则抛出异常)。此类类具有明确的用途,可以单独进行测试。
  • 目的地也是如此:您需要一个好的对象模型。
  • 如果在源头进行高级应用程序端过滤,您可以考虑使用Specification 设计模式来实现这些。这些往往也是非常可测试的。
  • Transform 步骤是很多动作发生的地方,但现在您拥有良好的源和目标对象模型,可以通过 Mappers 执行转换 - 同样是可测试的类。

如果您有许多“行”的源数据和目标数据,您可以在 Mappers 中为每个逻辑“行”进一步拆分,等等。

它永远不需要变得凌乱,而且额外的好处(除了自动化测试)是对象模型现在更加灵活。如果您需要编写另一个涉及这两个方面之一的 ETL 应用程序,那么您已经编写了至少三分之一的代码。

【讨论】:

  • 从概念上我理解,但我从未见过有人创建复杂的导出例程、翻译过程或除“交易脚本”之外的任何类似内容的示例。你见过任何例子吗?只要将逻辑分解为不同的功能,程序方法就非常简单易读。当您正在编写以一系列步骤/要求布局的第 3 方规范时,将其转换为一系列可测试的对象/类似乎比仅编写程序更难维护。
  • 是的,这样做可能会容易得多...直到测试它:) 早在 2003-2004 年,当我为 Microsoft 服务工作时,我 TDD 处理了一个非常复杂的问题使用这种方法的 ETL 应用程序,所以我肯定见过例子 :)
  • 编辑了我的答案以包含重构示例。
  • 好答案。管道和过滤器是另一个值得一提的好模式。过滤器很容易进行单元测试-eaipatterns.com/PipesAndFilters.html
【解决方案2】:

我想到的关于重构的一般性想法:

重构并不意味着您将 3.5k LOC 分成 n 个部分。我不建议将您的 80 种方法中的一些公开或类似的东西。这更像是对代码进行垂直切片:

  • 尝试分解出独立的算法和数据结构,例如解析器、渲染器、搜索操作、转换器、专用数据结构......
  • 尝试确定您的数据是否经过多个步骤处理,是否可以构建在一种管道和过滤机制或分层架构中。尝试找到尽可能多的层。
  • 将技术(文件、数据库)部分与逻辑部分分开。
  • 如果您有许多此类导入/导出怪物,请查看它们的共同点,并将这些部分分解并重复使用。
  • 通常认为您的代码太密集,即它在太少的 LOC 中包含太多不同的功能。访问代码中的不同“发明”,并考虑它们是否实际上是值得拥有自己的类的棘手设施。
    • 重构时 LOC 和类的数量都可能增加
    • 尽量使您的代码在类内部变得真正简单(“婴儿代码”),而在类之间的关系上变得复杂。

因此,您根本不必编写涵盖整个 3.5k LOC 的单元测试。单个测试仅涵盖其中的一小部分,并且您将拥有许多相互独立的小测试。


编辑

这是一个不错的list of refactoring patterns。其中,一个很好地表达了我的意图:Decompose Conditional

在示例中,某些表达式被分解为方法。不仅使代码更易于阅读,而且您还获得了对这些方法进行单元测试的机会。

更好的是,您可以将这种模式提升到更高的水平,并将这些表达式、算法、值等分解到方法中,还可以分解到它们自己的类中。

【讨论】:

  • 谢谢沃尔夫冈。这些是一些很好的建议。我仍然希望看到一个示例,其中有人采用本质上是一个大型程序事务脚本 (PoEAA) 并将其重构为易于测试的东西。我们的单元相对较小,但它们都是私有的,因此测试它们非常困难(远比编写代码更难)。
  • 我的想法是,您的 80 种方法可能不是可用于测试的单元。可测试单元可能会跨越这些方法并且可能尚未被识别。稍后我会看看能不能找到一个例子。
  • 我们实际上进行了重构以使其可测试,但最终使我们的开发人员似乎不太清楚。我们一开始基本上是提取方法,但随后需要提取类并为这些类使用依赖注入以使其可测试(否则原始方法无法自行测试 - 即上面的 ExportPaychecks)。这最终创建了大量可测试的类,但大多数只有 1 或 2 个方法。
【解决方案3】:

您最初应该拥有的是集成测试。这些将测试函数是否按预期执行,您可以为此访问实际数据库。

一旦你有了这个安全网,你就可以开始重构代码以使其更易于维护并引入单元测试。

正如 serbrech Workign 所提到的,有效地使用遗留代码将永远帮助您,我强烈建议您阅读它,即使是对于未开发项目。

http://www.amazon.com/Working-Effectively-Legacy-Michael-Feathers/dp/0131177052

我要问的主要问题是代码多久更改一次?如果它不频繁,是否真的值得尝试引入单元测试,如果它经常更改,那么我肯定会考虑清理一下。

【讨论】:

  • 我想我最大的问题是:如何重构这样的东西?不久前我读过“有效地工作......”一书,但将这样的逻辑应用于复杂的文件导出似乎比仅使用大程序更难。例如 - 我的 80 个左右的函数与该文件中的大约 20 个导出类型相关,涉及使用 ~40 个域对象。我可以将它拆分为易于测试的类,但是对于单个数据库,我们有大量的类。当您的代码是“执行第 1 步,然后执行第 2、3、4、... 20 步”时,最容易维护的方法通常会反映这些步骤。或者也许我只是很密集。
  • @Andrew:我不使用几乎和你代码库一样大的东西,但我过去写过一些不那么小的导入器/导出器。它们也是具有一种公共方法的代码块——从今天的角度来看,我清楚地看到,将它们分解成小块,甚至一些基本的自动测试都可以节省大量的调试时间。
  • 阅读以下内容:stackoverflow.com/questions/1620855/unit-testing-a-large-method 听起来你手上有一个神方法(谷歌搜索),它可以做任何事情。它确实需要拆分,并且有一些模式可以解决这类事情。
  • 你见过人们在实践中遵循这些模式的例子吗?我当然可以把事情分成几类,但是没有办法抽象出一张薪水有几十个需要设置/维护的依赖项。我已经向 SO 之外的几十个人询问过这个问题,答案总是“哦,有一个模式”,但是没有人能提供一个真实的例子来说明如何拆分大型导出/报告程序变成易于维护/测试的代码块。
  • 您的依赖项是否位于接口后面?如果是这样,您可以提供 Mocks 或存根来返回信息,而不是触及实际的依赖关系。您是否知道依赖注入,这可以帮助您模拟依赖项。有一些方法可以测试您不想公开的私有方法,例如,如果您将第 1 步、第 2 步……拉到单独的方法中,那么您可以单独测试它们:kurtschindler.net/blog/?tag=/internalsvisibleto
【解决方案4】:

听起来集成测试可能就足够了。特别是如果这些导出例程一旦完成就不会更改或仅在有限时间内使用。只需获取一些带有变化的样本输入数据,并进行测试以验证最终结果是否符合预期。

与您的测试有关的一个问题是您必须创建大量虚假数据。您可以通过创建共享装置 (http://xunitpatterns.com/Shared%20Fixture.html) 来减少这种情况。对于单元测试,fixture 可能是要导出的业务对象的内存表示,或者对于集成测试的情况,它可能是使用已知数据初始化的实际数据库。关键是您生成的共享夹具在每个测试中都是相同的,因此创建新测试只需对现有夹具进行细微调整以触发您要测试的代码。

那么您应该使用集成测试吗?一个障碍是如何设置共享夹具。如果您可以在某处复制数据库,则可以使用 DbUnit 之类的工具来准备共享夹具。将代码分成几部分(导入、转换、导出)可能更容易。然后使用基于 DbUnit 的测试来测试导入和导出,并使用常规单元测试来验证转换步骤。如果这样做,则不需要 DbUnit 为转换步骤设置共享夹具。如果您可以将代码分解为 3 个步骤(提取、转换、导出),那么您至少可以将测试工作集中在可能有错误或稍后更改的部分上。

【讨论】:

    【解决方案5】:

    我与 C# 无关,但我有一些想法你可以在这里尝试。如果您将代码稍微拆分一下,那么您会注意到您所拥有的基本上是对序列执行的操作链。

    第一个获得当前日期的报酬:

        var pays = _pays.GetPaysForCurrentDate();
    

    第二个无条件处理结果

        foreach (PayObject pay in pays)
        {
           WriteHeaderRow(pay);
        }
    

    第三个执行条件处理:

        foreach (PayObject pay in pays)
        {
           if (pay.IsFirstCheck)
           {
              WriteDetailRowType1(pay);
           }
        }
    

    现在,您可以让这些阶段更通用(抱歉伪代码,我不懂 C#):

        var all_pays = _pays.GetAll();
    
        var pwcdate = filter_pays(all_pays, current_date()) // filter_pays could also be made more generic, able to filter any sequence
    
        var pwcdate_ann =  annotate_with_header_row(pwcdate);       
    
        var pwcdate_ann_fc =  filter_first_check_only(pwcdate_annotated);  
    
        var pwcdate_ann_fc_ann =  annotate_with_detail_row(pwcdate_ann_fc);   // this could be made more generic, able to annotate with arbitrary row passed as parameter
    
        (Etc.)
    

    如您所见,现在您有一组未连接的阶段,可以单独测试,然后以任意顺序连接在一起。这种连接或组合也可以单独测试。依此类推(即 - 您可以选择要测试的内容)

    【讨论】:

      【解决方案6】:

      这是嘲笑一切概念的领域之一。当然,单独测试每个方法将是一种“更好”的做事方式,但是将制作所有方法的测试版本的工作与将代码指向测试数据库的工作进行比较(如果需要,在每次测试运行开始时重置) )。

      这就是我在代码中使用的方法,它在组件之间有很多复杂的交互,而且效果很好。由于每个测试都将运行更多代码,因此您更有可能需要逐步使用调试器来准确找出问题所在,但是您无需付出大量额外努力即可获得单元测试的主要好处(知道出现问题) .

      【讨论】:

      • 谢谢汤姆......其他一些答案非常好,但我们最终仍然需要大量代码块进行测试,但收益甚微。最后,我们仍然需要集成测试,那么为什么不从一开始就使用它们呢?
      【解决方案7】:

      我认为 Tomasz Zielinski 有一个答案。但是如果你说你有 3500 行程序代码,那么问题就更大了。 将其切割成更多功能不会帮助您对其进行测试。但是,这是识别可以进一步提取到另一个类的职责的第一步(如果您有好的方法名称,这在某些情况下可能很明显)。

      我想对于这样一个类,你有一个令人难以置信的依赖项列表来处理,只是为了能够将这个类实例化到一个测试中。然后在测试中创建该类的实例变得非常困难...... Michael Feathers 的书“Working With Legacy Code”很好地回答了这些问题。 能够很好地测试代码的第一个目标应该是识别类的角色并将其分解为更小的类。当然这说起来容易,但具有讽刺意味的是,如果不进行测试来确保您的修改是有风险的......

      您说您在该类中只有 1 个公共方法。这应该可以简化重构,因为您无需担心所有私有方法的用户。封装很好,但如果你在那个类中有这么多私有的东西,那可能意味着它不属于这里,你应该从那个怪物中提取不同的类,你最终将能够测试。一块一块地,设计应该看起来更干净,你将能够测试更多的大块代码。 你最好的朋友,如果你开始这将是一个重构工具,那么它应该可以帮助你在提取类和方法时不破坏逻辑。

      Michael Feathers 的书似乎又是你必读的书:) http://www.amazon.com/Working-Effectively-Legacy-Michael-Feathers/dp/0131177052

      添加示例:

      这个例子来自 Michael Feathers 的书,很好地说明了我认为你的问题:

      RuleParser  
      public evaluate(string)  
      private brachingExpression  
      private causalExpression  
      private variableExpression  
      private valueExpression  
      private nextTerm()  
      private hasMoreTerms()   
      public addVariables()  
      

      这里很明显,将方法 nextTerm 和 hasMoreTerms 公开是没有意义的。没有人应该看到这些方法,我们移动到下一个项目的方式肯定是类内部的。那么如何测试这个逻辑呢??

      如果您看到这是一个单独的职责并提取一个类,例如 Tokenizer。这个方法会突然在这个新类中公开!因为这就是它的目的。测试这种行为变得很容易......

      因此,如果您将其应用于您的大量代码,并将其提取到其他职责较少的类中,并且将这些方法公开会更自然,您也将能够轻松地测试它们. 你说你正在访问大约 40 个不同的表来映射它们。为什么不将其分解为映射的每个部分的类?

      我看不懂的代码有点难以推理。你可能有其他问题阻止你这样做,但这是我最好的尝试。

      希望这会有所帮助 祝你好运:)

      【讨论】:

      • 这不是遗留代码,我们所有的其他代码都很容易测试。很多人说“只是重构它”,但没有人能够就这对于需要许多步骤才能完成的复杂任务的意义提供良好的指导。我很想看到一些例子。
      • 好吧,你说你基本上有一个3500行的方法。它被分割成更小的私有方法这一事实并没有真正改变那段长代码。我确实在我对重构的回答中给了你提示。一个有这么多代码行的类不可能只承担一个责任。如果你分离职责,你最终会得到更多具有不同公共方法的类,这变得可测试。我将编辑我的答案以获取更多详细信息
      • 仔细考虑您在问题中发布的代码,您可以有一个 PayHeaderWriter 类,让 WriteHeaderRow 方法公开?
      • 谢谢,这些想法不错。我想知道人们是否测试自定义 ETL 代码?有时它可能非常复杂,但通常会遇到数千行。我很想在书中或网络上看到其他人所做的示例……我会继续寻找。
      • 但是所有问题(或大部分问题)是关于隐私的吗?我不了解 C#,但在 C++ 中,您有 friend 关键字,可用于将所有内容公开给测试。
      【解决方案8】:

      我真的很难接受您有多个 ~3.5 Klines 数据导出函数,而它们之间根本没有通用功能。如果情况确实如此,那么单元测试可能不是您需要在这里查看的内容。如果每个导出模块确实只做一件事,并且它本质上是不可分割的,那么可能需要一个快照比较、数据驱动的集成测试套件。

      如果有一些共同的功能,则将它们中的每一个提取出来(作为单独的类)并单独测试它们。那些小助手类自然会有不同的公共接口,这应该可以减少私有API无法测试的问题。

      您没有提供有关实际输出格式的详细信息,但如果它们通常是表格、固定宽度或分隔文本,那么您至少应该能够将导出器拆分为结构和格式化代码。我的意思是,而不是上面的示例代码,你会有类似的东西:

      public void ExportPaychecks(HeaderFormatter h, CheckRowFormatter f)
      {
         var pays = _pays.GetPaysForCurrentDate();
         foreach (PayObject pay in pays)
         {
            h.formatHeader(pay);
            f.WriteDetailRow(pay);
         }
      }
      

      HeaderFormatterCheckRowFormatter 抽象类将为这些类型的报表元素定义一个公共接口,并且各个具体的子类(用于各种报表)将包含删除重复行的逻辑,例如(或任何特定供应商要求)。

      另一种分割方法是将数据提取和格式化彼此分开。编写代码,将各种数据库中的所有记录提取到中间表示中,该中间表示是所需表示的超集,然后编写相对简单的过滤例程,将超级格式转换为每个供应商所需的格式。


      再想一想后,我意识到您已将其识别为 ETL 应用程序,但您的示例似乎将所有三个步骤结合在一起。这表明第一步是拆分事物,以便首先提取所有数据,然后翻译,然后存储。您当然可以至少单独测试这些步骤。

      【讨论】:

        【解决方案9】:

        我维护了一些与您描述的类似的报告,但没有那么多,而且数据库表也更少。我使用了 3 倍策略,该策略可能会很好地扩展以对您有用:

        1. 在方法级别,我对任何我主观认为“复杂”的东西进行单元测试。这包括 100% 的错误修复,以及任何让我感到紧张的事情。

        2. 在模块级别,我对主要用例进行单元测试。正如您所遇到的,这是相当痛苦的,因为它确实需要以某种方式模拟数据。我通过抽象数据库接口(即我的报告模块中没有直接的 SQL 连接)来实现这一点。对于一些简单的测试,我手动输入了测试数据,对于其他测试,我编写了一个记录和/或回放查询的数据库接口,以便我可以使用真实数据引导我的测试。换句话说,我在记录模式下运行一次,它不仅获取真实数据,而且还在文件中为我保存了快照;当我在播放模式下运行时,它会参考这个文件而不是真正的数据库表。 (我确信有一些模拟框架可以做到这一点,但是由于我的世界中的每个 SQL 交互都有签名 Stored Procedure Call -> Recordset,所以我自己编写非常简单。)

        3. 我很幸运能够访问具有完整生产数据副本的暂存环境,因此我可以执行集成测试,并针对以前的软件版本进行完全回归。

        【讨论】:

        • 这是否意味着您将所有方法都公开以便可以测试?
        • 在报告代码的情况下,不会在应用程序甚至程序集之间重用,是的,我只是将其公开。如果是库代码,我会将我确实想要测试但不想在外部可见的方法标记为内部,然后使用InternalsVisibleTo 明确允许访问测试程序集
        【解决方案10】:

        你看过Moq?

        来自网站的引述:

        起订量(发音为“Mock-you”或只是 "Mock") 是唯一的模拟库 为 .NET 从头开发到 充分利用 .NET 3.5(即 Linq 表达式树)和 C# 3.0 特征(即 lambda 表达式) 这使它最有生产力, 类型安全和重构友好 可用的模拟库。

        【讨论】:

        • 我们实际上相当广泛地使用了起订量,并且在我们对这个类的测试中这样做了。但是当你基本上有 3500 行程序代码(分解成许多简短的私有函数)时,像 Moq 这样的东西并没有多大帮助。
        • 我认为这里的问题不是如何模拟,而是如何重构以便能够测试它
        • @serbrech - 可能,这就是我问这个问题的原因。我们可以重构它,使所有调用都是公开的,但这似乎有点愚蠢。 1)这会让我们的开发人员感到困惑(因为会有很多公共调用,但只有 1 个应该被另一个程序调用 2)这意味着我们必须创建测试/模拟对象以在各种函数之间传递,而这些测试对象在导出类的上下文之外没有任何意义。我觉得我要么错过了什么,要么就是这种类型的东西没有经过测试。与我交谈过的大型企业的人都说后者。
        • 你能做的就是让方法内部化和虚拟化。通过这种方式,您可以覆盖该类并测试被覆盖的类,确保在您要测试的程序集上设置了 InternalSVisibleTo(Google for this)属性。
        猜你喜欢
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 2016-10-02
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 2022-01-13
        • 1970-01-01
        相关资源
        最近更新 更多