代码之家  ›  专栏  ›  技术社区  ›  Andrew

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

  •  27
  • Andrew  · 技术社区  · 15 年前

    我们对大部分业务逻辑进行了单元测试,但仍然坚持如何最好地测试一些大型服务任务和导入/导出例程。例如,考虑将工资单数据从一个系统导出到第三方系统。要以公司需要的格式导出数据,我们需要访问大约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函数,就不能在真空中测试exportpaymecks)。这也是一种巨大的痛苦。

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

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

    更新 -谢谢你的回答。我想看到的一件事是一个例子,因为我仍然不知道如何有人可以将像大文件导出这样的东西变成一个易于测试的代码块,而不会将代码变成一团乱麻。

    10 回复  |  直到 15 年前
        1
  •  2
  •   Tom Clarkson    15 年前

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

    这就是我在代码中使用的方法,它在组件之间有很多复杂的交互,并且工作得很好。由于每个测试将运行更多的代码,因此您更可能需要使用调试器单步执行,以准确地找到出错的地方,但是您可以获得单元测试的主要好处(知道出错的地方),而不需要付出大量的额外努力。

        2
  •  18
  •   Mark Seemann    15 年前

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

    封装 在面向对象的设计中是一个古老的概念,但有些人认为它是一个极端,以致于测试性受到了影响。还有一个OO原则叫做 Open/Closed Principle 这更符合可测试性。封装仍然是有价值的,但不会以牺牲可扩展性为代价——事实上, testability is really just another word for the Open/Closed Principle .

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

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


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

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

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

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

    • 至少,您需要一个良好的源数据“行”内存表示。如果源是关系数据库,您可能希望使用ORM,但如果不是,则需要对此类类进行建模,以便它们正确地保护每行的不变量(例如,如果字段不可为空,则类应通过在尝试为空值时引发异常来保证这一点)。这些类有一个明确的用途,可以单独测试。
    • 对于目的地也是如此:您需要一个好的对象模型。
    • 如果在源代码处进行高级应用程序端筛选,则可以考虑使用 Specification 设计模式。这些往往也是非常可测试的。
    • 转换步骤是许多操作发生的地方,但是现在您已经拥有了好的源和目标对象模型,可以通过 映射器 -又是可测试类。

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

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

        3
  •  7
  •   Wolfgang    15 年前

    我想到的是 重构 :

    重构并不意味着你将你的3.5K位置划分为 n 部分。我不建议把你80种方法中的一些公开或者像这样的东西。这更像是垂直分割代码:

    • 尝试找出独立的算法和数据结构,如解析器、渲染器、搜索操作、转换器、专用数据结构…
    • 尝试找出您的数据是通过几个步骤处理的,并且可以构建在一种管道和过滤机制中,或者是分层的体系结构中。试着找到尽可能多的层。
    • 将技术(文件、数据库)部分与逻辑部分分开。
    • 如果你有很多这样的进出口怪兽,看看它们有什么共同点,把它们分解并重新使用。
    • 一般来说,您的代码也是 稠密的 也就是说,它包含了太多不同的功能,每个功能旁边的位置太少。访问代码中的不同“发明”,思考它们是否实际上是值得拥有自己的类的复杂设施。
      • loc和类的数量都可能 增加 当你重构的时候。
      • 尝试使代码在类内变得真正简单(“baby code”),并使类之间的关系变得复杂。

    因此,您不必编写覆盖整个3.5K loc的单元测试。在一个测试中只包含其中的一小部分,您将有许多相互独立的小测试。


    编辑

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

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

    更好的是,您可以将此模式提升到更高的层次,并将这些表达式、算法、值等分解出来,不仅限于方法,还包括它们自己的类。

        4
  •  6
  •   Burt    15 年前

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

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

    正如serbrech所提到的,有效地使用遗留代码将帮助您永不停息,我强烈建议您甚至为绿地项目阅读它。

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

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

        5
  •  4
  •   Frank Schwieterman    15 年前

    听起来集成测试可能足够了。尤其是当这些导出例程完成后不会更改,或者只在有限的时间内使用时。只需获取一些带有变体的示例输入数据,并进行测试,以验证最终结果是否如预期的那样。

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

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

        6
  •  3
  •   Tomasz Zieliński    15 年前

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

    如您所见,现在您已经有了一组未连接的阶段,它们可以分别测试,然后以任意顺序连接在一起。这种连接或成分也可以单独测试。等等(例如-你可以选择测试什么)

        7
  •  2
  •   Stéphane    15 年前

    我想托马兹·齐林斯基有一个答案。但是如果你说你有3500行程序代码,那么问题就更大了。 将它切割成更多的函数并不能帮助您测试它。但是,这是识别可以进一步提取到另一个类中的职责的第一步(如果您对这些方法有好的名称,在某些情况下这是显而易见的)。

    我想有了这样一个类,您就有了一个令人难以置信的依赖项列表,可以处理这些依赖项,只是为了能够将这个类声明为一个测试。然后很难在测试中创建该类的实例… 迈克尔·费瑟的《处理遗留代码》一书很好地回答了这些问题。 能够很好地测试代码的第一个目标应该是识别类的角色并将其分成更小的类。当然,这很容易说,具有讽刺意味的是,没有测试来保证修改的安全性是有风险的……

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

    迈克尔·费瑟的书似乎又是你必须读的书:) 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和hasmore的方法公开是没有意义的。没有人应该看到这些方法,我们移动到下一项的方式绝对是类内部的。那么如何测试这个逻辑呢??

    如果你看到这是一个单独的责任,并提取一个类,比如记号赋予器。这个方法将突然在这个新类中公开!因为这就是它的目的。然后很容易测试这种行为…

    因此,如果您将它应用到您的巨大代码块上,并将其片段提取到职责较少的其他类中,并且在将这些方法公开的情况下,您也可以轻松地测试它们。 你说你正在访问大约40个不同的表来映射它们。为什么不为映射的每个部分将其分解为类呢?

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

    希望这有帮助 祝你好运:

        8
  •  2
  •   Mark Bessey    15 年前

    我真的很难接受你有多个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);
       }
    }
    

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

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


    在进一步考虑之后,我意识到您已经将它标识为ETL应用程序,但是您的示例似乎将这三个步骤结合在一起。这意味着第一步是将事物拆分,以便先提取所有数据,然后翻译,然后存储。您当然可以单独测试这些步骤。

        9
  •  1
  •   Eric    15 年前

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

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

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

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

        10
  •  0
  •   Paul Sasik    15 年前

    你查过了吗 Moq?

    从网站引用:

    moq(发音为“mock you”或just “mock”)是唯一的mocking库 对于从头开发到 充分利用.NET 3.5(即 linq表达式树)和c 3.0 特性(即lambda表达式) 使它成为最有成效的, 类型安全和重构友好 模拟库可用。