zoukankan      html  css  js  c++  java
  • .NET重构—单元测试重构

    .NET重构—单元测试重构

    阅读目录:

    • 1.开篇介绍
    • 2.单元测试、测试用例代码重复问题(大量使用重复的Mock对象及测试数据)
      • 2.1.单元测试的继承体系(利用超类来减少Mock对象的使用)
        • 2.1.1.公用的MOCK对象;
        • 2.1.2.公用的MOCK行为;
        • 2.1.3.公用的MOCK数据;
    • 3.LINQ表达式的重构写法(将必要的LINQ写成普通的Function穿插在LINQ表达式中)
    • 4.面向特定领域的单元测试框架(一切原则即是领域驱动)
      • 4.1.分散测试逻辑、日志记录(让测试逻辑可以重组,记录形式为领域模型)
      • 4.2.测试用例的数据重用(为自动化测试准备固定数据,建立Assert的比较测试数据)

    1】开篇介绍

    最近一段时间结束了一个Sprint,在这次的开发当中有些东西觉得还不错有总结分享的价值,所以整理成本文;

    重构已是老生常谈的话题,我们或多或少对它有所了解但是对它的深刻理解恐怕需要一段实践过后才能体会到;提到重构就不得不提为它保驾护航的大功臣单元测试,重构能有今天的风光影响力完全少不了单元测试的功劳;最近一段时间写单元测试用例的时间远超过我写逻辑代码的时间和多的多的代码量,这是为什么?我一开始很难给自己一个理由去做好这件事,心态上还是转变不过来,可是每当我心浮气躁的时候它总能给我点惊喜,让我继续下去,天生具有好奇心的程序员怎么会就此结束呢,只有到达了一扇门之后我们回过头来看一下走的路才能真正的明白这是条对的路还是错的路;

    单元测试简单写起来没有什么太大问题,但是我们不仅为了达到代码的100%覆盖还要到达到逻辑的100%覆盖,代码的覆盖不代表逻辑的覆盖;一个简单的逻辑判断虽然只有一行代码,但是里面可能会有正反向很多种逻辑在里面;比如:Order.ToString()简单的代码,想要覆盖很简单,只要对象不为空都能正确的覆盖到,但是如果我们没有测试到它为NULL的情况下的边界逻辑,这个时候我们就会漏掉这种可能会导致BUG的逻辑路径;所以我们会尽可能的多去写用例来达到最终的理想效果;

    (总之把单元测试的所有精力集中在可能会出问题的地方,也是自己最担心的地方,这个地方通常是逻辑比较复杂的地方;)

    2】单元测试、测试用例代码重复问题(大量使用重复的Mock对象及测试数据)

    单元测试代码中最常见的代码就是Mock或者Fake接口逻辑,那么在一个具有上百个用例覆盖的代码中会同时使用到一组相关的Mock接口对象,这无形中增加了我们编写单元测试的效率给后期的维护测试用例带来了很大的隐患及工作量;

    单元测试代码的组成都是按照用例来划分,一个用例可以用来包括一个单一入口的所有逻辑也可以是一个判断分支的部分逻辑;为了构造一个能完美覆盖的代码步骤,我们需要构建测试数据、Mock接口,划分执行顺序等等,那么一旦被测试代码发生一点点的变化都会很大程度上影响测试代码,毕竟测试代码都是步步依赖的;

    那么我们应该最大程度的限制由于被测试代码的变动而引起的测试代码的变动,这个时候我们应该将重构应用到测试代码中;

    2.1】单元测试的继承体系(利用超类来减少Mock对象的使用)

    将多个相关的测试用例代码通过超类的方式关联起来统一管理将大大减少重复代码的构建;就跟我们重构普通代码一样,将多个类之间共享的逻辑代码或者对象提取出来放到基类中;这当然也同样适用于测试代码,只不过需要控制一些更测试相关的逻辑;

    其实大部分重复的代码就是Mock接口的过程,我们需要将它的Mock过程精简化,但是又不能太过于精简,一切精简的过程都是需要牺牲可观察性;我们需要适当的平衡提取出来的对象个数,将它们放入基类中,然后在Mock的时候能通过一个简单的方法就能获取到一个Mock过后的对象;

    下面我们来看一下提取公共部分到基类的一个 简单过程,当然对于大项目而言不一定具有说服力,就当抛砖引玉吧;

    2.1.1】公用的Mock对象

    首要的任务就是将公共的Mock接口提取出来,因为这一类接口是肯定会在各个用例中共享的,提取过程过主要分为两个重构过程;

    第一:将用例中的公用接口放到类的声明中,供所有用例使用;

    第二:如果需要将公用接口提供给其他的单元测试使用,就需要提取出相关的测试基类;

    我们先来看一下第一个过程,看一下测试示例代码:

     View Code

    这个类表示远程Order服务,只有一个方法GetOrders,该方法可以根据OrderId来查询Order信息,为了简单起见,如果返回true说明服务调用成功,如果返回false表示调用失败;其中构造函数包含了三个接口,分别用来表示不同用途的接口抽象;IServiceConnection表示对远程服务链接的抽象,IServiceReader表示对不同服务接口读取的抽象,IServiceWriter表示对不同服务接口写入的抽象;这么做可以最大化的分解耦合;

     View Code

    这个单元测试类是专门用来测试刚才那个OrderService的,里面包括两个GetOrders方法的测试用例;可以一目了然的看见,这两个测试用例代码中都包含了对测试类的构造函数的参数接口Mock代码;

    图1:

    像这种简单的情况下,我们只需要将公共的部分拿出来放到测试的类中声明,就可以公用这块对象;

    图2:

    这样可以解决内部重复问题,但是这里需要小心的地方是,当我们在不同的用例之间共享部分Mock逻辑的时候可能会出现问题;比如我们在OrderService_GetOrders_NormalFlows用例中,对IServiceConnection接口进行了部分行为的Mock但是当执行到OrderService_GetOrders_OrderIdIsNull用例时可能是用的我们上一次的Mock逻辑;所以这里需要注意一下,当然如果设计合理的话是不太可能会出现这种问题的;单一职责原则只要满足我们的接口是不会包含其他的逻辑在里面,也不会出现在不同的用例之间共存相同的接口逻辑;同时也满足接口隔离原则,就会更加对单元测试有利;

    我们接着看一下第二个过程,看一下测试示例代码:

     View Code

    这个是表示Product服务,构造函数中同样和之前的OrderService一样的参数列表,然后就是一个简单的GetProduct方法;

     View Code

    这是单元测试类,没有什么特别的,跟之前的OrderService一样的逻辑;是不是发现两个测试类都在公用一组相关的接口,这里就需要我们将他们提取出来放入基类中;

     View Code

    提取出来的测试基类;

     View Code

    ProductService_UnitTests类;

     View Code

    OrderService_UnitTests 类;

    提取出来的抽象基类能在后面的单元测试重构中帮很大忙,也是为了后面的面向特定领域的单元测试框架做要基础工作;由于不同的单元测试类具有不同的基类,这里需要我们自己的分析抽象,比如这里跟Service相关的,可能还有跟Order处理流程相关的,相同的一组接口也只能出现在相关的测试类中;

    2.1.2】公用的Mock行为

    前面2.1.1】小结,我们讲了Mock接口对象的重构,这一节我们将来分析一下关于Mock对象行为的重构;在上面的IServiceConnection中我们加入了一个Open方法,用来打开远程链接;

     View Code

    如果返回true表示远程链接成功建立并且已经成功打开,如果返回false表示链接失败;那么在每一个用例代码中,只要使用到了IServiceConnection接口都会需要Mock接口的Open方法;

     View Code

    类似这样的代码会很多,如果这个时候我们需要每次都在用例中对三个接口都进行类似的重复代码也算是一种地效率的重复劳动,并且在后面的改动中会很费事;所以这个时候抽象出来的基类就派上用场了,我们可以将构建接口的逻辑代码放入基类中进行统一构造;

     View Code
     View Code

    这样在需要修改接口的时候很容易找到,可能这里两三个用例,而且用例代码也很简单所以看起来没有太多的必要,但是实际情况没有这么简单;

    2.1.3】公用的Mock数据

    说到Mock数据,其实需要解释一下,准确点讲是Mock时需要用到的测试数据,它是碎片化的简单的测试数据;它也同样存在着和2.1.2】小结的修改问题,实践告诉我单元测试代码在整个开发周期中最易被修改,当我们简单的修改一个逻辑之后就需要面临着大面积的单元测试代码修改而测试数据修改占比重最大;

    因为测试数据相对没有灵活性,但是测试数据的结构易发生由需求带来的变化;比如实体的属性类型,在我们编写实体测试数据的时候我们用的是String,一段时间过后,实体发生变化很正常;领域模型在开发周期中被修改的次数那是无法估计,因为我们的项目中是需要迭代重构的,我们需要重构来为我们的项目保证最高的质量;

    所以单元测试修改的次数和重构的次数应该是成1:0的这样的比例,修改的范围那就不是1:10了,有时候甚至是几何的倍数;

    OrderService中的AddOrder方法:

     View Code

    OrderService_AddOrder测试代码:

     View Code

    这是两个用例,用来对AddOrder方法进行测试,里面都包含了一条Order testOrder = new Order() 这样的测试数据的构造;Order实体是一个比较简单的对象,属性也就只有两个,但是真实环境中不会这么简单,会有几十个字段都需要进行测试验证,再加上N多个用例,会使相同的代码变的很多;

    那么我们同样需要将这部分的代码提取出来放到基类中去,适当的留有空间让用例中修改的特殊的字段;

    完整的实体构造:

     View Code

    测试OrderId为空的逻辑,需要手动设置为String.Empty:

     View Code

    这样慢慢的就会形成抗变化的测试代码结构,尽管一开始很别扭,将一些直观的对象提取出来放入一眼看不见的地方是有点不太舒服,但是长远看来值得这么做;

    3】LINQ表达式的重构写法(将必要的LINQ写成普通的Function穿插在LINQ表达式中)

    在使用LINQ语法编写代码的时候,现在发现最大的问题就是单元测试不太方便,LINQ写起来很方便,确实是个很不错的编程思想,在面对集合类型的操作时确实是无法形容的优雅,但是面对单元测试的问题需要解决才行,所以需要我们平衡一下在什么情况下需要将LINQ表达式替换成普通的Function来支持;

    LINQ在面对集合类型的时候,能发挥很大的作用;不仅在Linq to Object中,在其他的Linq to Provider中都能在LINQ中找到了合适的使用之地;比如在对远程Service进行LINQ设计的时候,我们都是按照这样的方式进行编写,但是就怕LINQ中带有逻辑判断的表达式,这个时候就会在单元测试中总是无法覆盖到的情况出现,所以就需要将它提取出来使用普通的函数进行替代;

    我们来继续看一下如果使用提取出来的函数解决链式的判断,还是使用上面的OrderService为例:

     View Code

    这是一个根据OrderId获取Order实例的方法,纯粹为了演示;首先构造了一个测试集合,然后使用了Where扩展方法来选择集合中满足条件的Order;我们的重点是Where中的条件,条件的第一个表达式很简单而第二个表达式是SubmitDT必须大于当前的日期,还会有很多类似这样的判断,这样测试起来很困难,而且很难维护,所以我们有必要将他们提取出来;

     View Code

    其实这很像企业架构模式中的规约模式,将规则对象化后就能随便的控制他们,当然这里是提取出方法,如果是大型企业级项目对这些易变化的点是需要抽取出来的;

    总之遇到这样的情况就使用简单的提取方法的方式将复杂的逻辑提取出来,这也是《重构》中的重构策略的首要的模式;

    4.面向特定领域的单元测试框架(一切原则即是领域驱动)

    领域驱动设计已经不是什么新鲜的话题了,它已经被我们或多或少的使用过,它强调一切从领域出发;那么特定领域单元测试框架是一个什么样的框架呢,需要的价值在哪里;其实从特定领域开发框架,特定领域架构我们能简单的体会到一丝意思,面向特定领域单元测试框架是在单元测试框架的基础之上进行二次领域相关的封装;比如:如何很好的将领域规则独立起来,如果在单元测试中使用这些独立起来的领域规则;

    其实在软件开发的任何一个角落都能找到领域驱动的影子,这也是为什么领域驱动会得到我们认可的重要因素;如果一切都围绕着领域模型来的话,那么任何一个概念都不会牵强的,我们只有关注领域本身才能使软件真的很有价值,而不是一堆代码;

    下面我们来简单的看一下 面向特定领域测试框架 的两个基本功能:

    4.1.分散测试逻辑、日志记录(让测试逻辑可以重组,记录形式为领域模型)

    测试代码执行到最后是需要对其执行的结果进行断言的,如:Assert.IsTrue(testResult.SubmitDT > DateTime.Now);像这样的一段代码我们可以适当的包装Assert.IsTrue方法,让他在验证这段逻辑的时候能识别出领域概念,比如:“Order的提交时间大于今天的时间”,我们可以从两方面入手,一个是领域的抽象,一个是规则的分解;

    如果这里的验证不通过,我们实时的记录领域的概念到日志系统,而不是报告那里代码出问题,这样就算不是自己写的代码都能一目了然;

    4.2.测试用例的数据重用(为自动化测试准备固定数据,建立Assert的比较测试数据)

    同样比较重要的领域概念就是领域数据,领域数据也是单元测试中用例数据;为了能让测试进行自动化测试,我们需要维护一组相对固定的测试数据来供测试程序运行;其实如果想最大化建立领域测试框架有必要开发一套专门的领域测试工具,它能够实时的读取真实数据进行Assert,也就更加的接近自动化测试;

    但是单元测试也不需要对真实数据进行验证,真实数据一般是集成测试的时候使用的,如果能用真实数据进行逻辑测试还是很有保障的;

     

    VS2012 Unit Test——Microsoft Fakes入门

    如题,本文主要作为在VS2012使用Fakes的入门示例,开发工具必须是VS2012或更高版本。

    关于Fakes的MSDN地址:http://msdn.microsoft.com/en-us/library/hh549175.aspx

    关于VS2012单元测试的前期文章:

    1.《在Visual Studio 2012使用单元测试》、

    2.《VS2012 单元测试之泛型类(Generics Unit Test)》、

    3.《VS2012 Unit Test —— 我对接口进行单元测试使用的技巧

    4.《VS2012 Unit Test(Void, Action, Func) —— 对无返回值、使用Action或Func作为参数、多重载的方法进行单元测试

    依我个人理解单元测试就是对程序的小单元进行测试,一个测试不应包含两个或更多单元,总体而言大多都是对方法、属性的编码正确性进行验证。但是往往一个方法又会调用其他的方法或属性,我这里暂称之为外部依赖,因而外部依赖会影响程序单元的测试结果,要避免这样的情况就不得不使用一些外部依赖的模拟进行隔离(Isolate),本文就是使用了Microsoft Fakes,当然还有其他更为流行的框架可以选择使用(Moq、Rhino Mocks、Type Mock)

    Fakes有两种形式:stub 和 shim。具体的介绍我就不啰嗦,因为我英文不好可能会表达错误误导新人。

    我的Demo也是看了MSDN后以个人理解后进行简单的编写,如果MSDN看懂了也就不用看以下内容了,期待和我一样正在使用VS2012 MSTest进行单元测试的一起交流进步。

    一、shim

    以下将模拟DateTime的Now属性,假设我现在需要在活动服务类ActivityService添加一个方法验证某个线下活动是否过期。

    1. 打开VS2012,创建单元测试项目FakesTesting,我这是测试先行。重命名项目自动生成的类UnitTest1为ActivityServiceTest,将TestMethod1改为IsExpireTest(是否过期).

    2. 添加代码“ActivityService service = new ActivityService();”并使用VS快捷功能为我们创建ActivityService 类

    3. 添加Fakes,由于DateTime位于System程序集,因而将添加System的Fake程序集(右键System程序集),  然后在测试类“using System.Fakes;”

    4.  编写测试代码如下

    复制代码
    using System;
    using Microsoft.VisualStudio.TestTools.UnitTesting;
    using System.Fakes;
    using Microsoft.QualityTools.Testing.Fakes;
    
    namespace FakesTesting.Test
    {
        [TestClass]
        public class ActivityServiceTest
        {
            [TestMethod]
            public void IsExpireTest()
            {
                ActivityService service = new ActivityService();
                bool actual = service.IsExpire();
                Assert.IsFalse(actual);
    
                using (ShimsContext.Create())
                {
                    ShimDateTime.NowGet = () => new DateTime(2014, 5, 5);
                    actual = service.IsExpire();
                    Assert.IsFalse(actual);
                }
            }
        }
    }
    复制代码

    5. 然后编写ActivityService类

    复制代码
        public class ActivityService
        {
            public DateTime BeginTime { get; set; }
    
            public ActivityService()
            {
                this.BeginTime = new DateTime(2014, 3, 3);  //仅作演示,无意义
            }
    
            public bool IsExpire()
            {
                return BeginTime >= DateTime.Now;
            }
        }
    复制代码

    6. 运行测试通过。然后就可以把实际业务类移动到相应VS项目中,并调整命名空间。

    二、Stub

    现在假设ActivityService类有一个方法获取是否还能报名,但是它依赖于仓储IActivityRepository(只有遵循依赖反转与接口隔离原则的代码才好使用Stub填充外部依赖)提供的RegisterNumber方法。

    1. IActivityRepository接口(新建IRepositories项目并添加该接口)

    复制代码
        public interface IActivityRepository
        {
            /// <summary>
            /// 已报名人数
            /// </summary>
            int RegisterNumber();
        }
    复制代码

    2. 而我们的单元测试现在不能依赖具体(实际环境中的Repository可能对测试带来影响),这时候就能使用Stub来填充该接口了,添加IRepositories引用,然后与上一个Demo一样的添加IRepositories的Fakes程序集。

    3. 在测试类中添加Using代码

    using IRepositories;
    using IRepositories.Fakes;

    4. 编写测试代码

    复制代码
            [TestMethod]
            public void CanRegisterTest()
            {
                StubIActivityRepository repository = new StubIActivityRepository();
                ActivityService service = new ActivityService(repository);
    
                //如果已报名人数小于最多可报名数量则不能再报名,断言CanRegister方法应为True
                repository.RegisterNumber = ()=> 20;
                bool actual = service.CanRegister();
                Assert.IsTrue(actual);
    
                //如果已报名人数大于等于最多可报名数量则不能再报名,断言CanRegister方法应为False
                repository.RegisterNumber = () => 50;
                actual = service.CanRegister();

            Assert.IsFalse(actual);
          }

    
    
    复制代码

    5. ActivityService代码:

    复制代码
        public class ActivityService
        {
            public DateTime BeginTime { get; set; }
    
            /// <summary>
            /// 最多可报名数量
            /// </summary>
            private int maxCount = 50;
            private IActivityRepository repository;
    
            public ActivityService()
            {
                this.BeginTime = new DateTime(2014, 3, 3);  //仅作演示,无意义
            }
    
            public ActivityService(IActivityRepository repository)
            {
                // TODO: Complete member initialization
                this.repository = repository;
            }
            
            public bool IsExpire()
            {
                return BeginTime >= DateTime.Now;
            }
    
            public bool CanRegister()
            {
                return repository.RegisterNumber() < this.maxCount;
            }
        }
    复制代码

    总结

    stub用于我们可控的代码,shim用于不可控的,例如.NET Framework以及第三方类库等。


     分割线:我的个人原创,请认准 http://freedong.cnblogs.com/ (转摘不标原文出处可耻)

     
  • 相关阅读:
    训练20191009 2018-2019 ACM-ICPC, Asia East Continent Finals
    [学习笔记] 有上下界网络流
    [HDU4609] 3-idiots
    [HDU1402] A * B Problem Plus
    [HNOI2017] 礼物
    训练20191007 2017-2018 ACM-ICPC Latin American Regional Programming Contest
    [ZJOI2014] 力
    训练20191005 2017-2018 ACM-ICPC Asia East Continent League Final
    [一本通学习笔记] 树链剖分
    [一本通学习笔记] 快速幂
  • 原文地址:https://www.cnblogs.com/Leo_wl/p/3356317.html
Copyright © 2011-2022 走看看