28

我们对大部分业务逻辑进行单元测试,但仍停留在如何最好地测试我们的一些大型服务任务和导入/导出例程上。例如,考虑将工资单数据从一个系统导出到第 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 软件的制造商,我们有数百个出口和进口。其他公司真的对这类事情进行单元测试吗?如果是这样,有什么捷径可以减轻痛苦吗?我很想说“没有对导入/导出例程进行单元测试”,稍后再实施集成测试。

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

4

10 回答 10

18

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

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

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

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


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

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

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

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

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

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

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

于 2010-01-18T08:38:05.313 回答
7

我想到的关于重构的一些一般性的东西:

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

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

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


编辑

这是一个很好的重构模式列表。其中,一个很好地显示了我的意图:分解条件

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

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

于 2010-01-19T22:09:25.407 回答
6

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

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

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

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

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

于 2010-01-16T23:11:02.237 回答
4

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

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

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

于 2010-01-20T21:21:45.647 回答
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.)

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

于 2010-01-16T08:32:27.293 回答
2

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

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

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

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

添加示例:

这个例子来自迈克尔羽毛的书,很好地说明了我认为你的问题:

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

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

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

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

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

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

于 2010-01-16T15:22:29.130 回答
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抽象类将为这些类型的报表元素定义一个公共接口,并且各个具体子类(用于各种报表)将包含用于删除重复行的逻辑,例如(或任何特定供应商要求的)。

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


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

于 2010-01-20T00:41:12.080 回答
2

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

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

于 2010-01-25T02:21:38.887 回答
1

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

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

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

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

于 2010-01-16T14:48:37.783 回答
0

你看过起订量吗?

引自网站:

Moq(发音为“Mock-you”或简称为“Mock”)是唯一一个从头开始开发的 .NET 模拟库,以充分利用 .NET 3.5(即 Linq 表达式树)和 C# 3.0 特性(即 lambda 表达式),它是可用的最高效、类型安全和重构友好的模拟库。

于 2010-01-16T06:32:50.703 回答