单元测试大块代码(映射,翻译等)

时间:2010-01-16 06:16:00

标签: c# unit-testing etl

我们对大部分业务逻辑进行单元测试,但仍然坚持如何最好地测试我们的一些大型服务任务和导入/导出例程。例如,考虑将工资单数据从一个系统导出到第三方系统。要以公司需要的格式导出数据,我们需要达到约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软件的制造商,我们拥有数百种出口和进口产品。其他公司真的对这种类型的东西进行单元测试吗?如果是这样,是否有任何捷径可以减少痛苦?我很想说“没有单元测试导入/导出例程”,只是稍后实现集成测试。

更新 - 感谢所有答案。我希望看到的一件事就是一个例子,因为我还没有看到有人可以将像大文件导出这样的东西变成一个易于测试的代码块,而不会把代码弄得一团糟。

10 个答案:

答案 0 :(得分:18)

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

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

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

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


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

  1. 从源
  2. 中提取数据
  3. 转换数据
  4. 将数据加载到目标
  5. (因此,名称 ETL )。作为重构的开始,这给了我们至少三个具有不同职责的类:ExtractorTransformerLoader。现在,你有三个更有针对性的责任,而不是一个大班。没有什么比这更麻烦了,而且已经有点可测试了。

    现在放大这三个区域中的每一个区域,看看你可以将责任分得更多。

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

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

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

答案 1 :(得分:7)

我强烈想到重构

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

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

因此,您根本不必编写覆盖整个3.5k LOC的单元测试。在一次测试中只覆盖了一小部分,并且您将进行许多彼此独立的小测试。


修改

这是一个不错的list of refactoring patterns。其中一个显示了我的意图:Decompose Conditional

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

更好的是,您可以将此模式提升到更高的级别,并将这些表达式,算法,值等分解为方法,而不仅仅是方法,还要考虑到自己的类。

答案 2 :(得分:6)

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

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

正如serbrech Workign所提到的,有效地使用遗留代码将帮助你永无止境,我强烈建议你阅读它,即使是绿地项目。

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

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

答案 3 :(得分:4)

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

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

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

答案 4 :(得分:3)

我与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.)

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

答案 5 :(得分:2)

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

我想有了这样一个类,你有一个令人难以置信的依赖列表,只是为了能够将这个类实例化为一个测试。那么在测试中创建该类的实例真的很难...... Michael Feathers的着作“使用传统代码”很好地回答了这些问题。 能够很好地测试代码的第一个目标应该是识别类的角色并将其分解为更小的类。当然,这很容易说,而且具有讽刺意味的是,如果没有测试来保证您的修改,那将是冒险的......

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

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个不同的表来映射它们。为什么不将它分成映射的每个部分的类?

我无法理解我无法阅读的代码。您可能还有其他问题阻止您这样做,但这是我最好的尝试。

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

答案 6 :(得分:2)

我真的很难接受你有多个,~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抽象类将为这些类型的报表元素定义公共接口,并且各个具体子类(针对各种报表)将包含用于删除重复行的逻辑,例如(或特定供应商要求的任何内容)。

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


在考虑了这个之后,我意识到你已经将它识别为ETL应用程序,但是你的例子似乎将所有三个步骤结合在一起。这表明第一步是将事情分开,以便首先提取所有数据,然后翻译,然后存储。你当然可以分别测试至少这些步骤。

答案 7 :(得分:2)

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

这就是我使用的代码,它在组件之间有很多复杂的交互,并且运行良好。由于每个测试都会运行更多代码,因此您更有可能需要通过调试器逐步找到出错的地方,但是您可以获得单元测试的主要好处(知道出现了问题)而无需付出额外的努力

答案 8 :(得分:1)

我保留了一些类似于你所描述的报告,但没有那么多报告和数据库表少。我使用了一个可以扩展到足以对您有用的3倍策略:

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

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

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

答案 9 :(得分:0)

您是否查看了Moq?

来自网站的引用:

  

Moq(发音为“Mock-you”或只是   “Mock”)是唯一的模拟库   for .NET从头开发到   充分利用.NET 3.5(即   Linq表达树)和C#3.0   功能(即lambda表达式)   使它最有效率,   类型安全和重构友好   模拟库可用。