zoukankan      html  css  js  c++  java
  • C#-面向对象:争议TDD(测试驱动开发)

    -----------------------

    绝对原创!版权所有,转发需经过作者同意。

    -----------------------

    在谈到特性的使用场景时,还有一个绝对离不开的就是

    单元测试

    按飞哥的定义,单元测试是开发人员自己用代码实现的测试 。注意这个定义,其核心在于:

    • 主体是“开发人员”,是测试人员。
    • 途径是“通过代码实现”,是通过手工测试。
    • 实质是一种“测试”,是代码调试。

    暂时还有点抽象,同学们记着这个概念,我们先用一个

     

    NUnit项目

    来看一看单元测试长个什么样。

    在solution上右键添加项目,选择Test中的NUnit Test Project,输入项目名称,点击OK:

    Visual Studio直接集成了NUnit说明微软在开源和社区支持的路上确实是一路狂奔,因为NUnit是一个由社区支持的、完全开源的、和微软自己的MSTest Test和Unit Test直接竞争的单元测试框架。微软确实已经从“什么都要自己有”向“借用(不仅是借鉴)乃至大力支持一切优质开源项目”华丽转身。

    新建的单元测试项目包含一个默认的类文件:UnitTest1.cs,其中首先使用了using:

    using NUnit.Framework;
    

    因为NUnit的所有成员(类和方法等)都在NUnit.Framework命名空间之下。

    然后有一个类:

        public class Tests
        {
            [SetUp]
            public void Setup()
            {
            }
    
            [Test]
            public void Test1()
            {
                Assert.Pass();
            }
        }
    

    你发现这个项目和Console Project不同,它没有没有Main()函数作为入口,怎么运行呢?就算我知道它可以由NUnit调用,但NUnit怎么调用呢?这就需要用到 反射 了:NUnit会在整个程序集(项目)中遍历,找到带有特定标签(特性)的类和方法,予以相应的处理。

    注意这个类里面的两个方法都被贴上了特性:

    • SetUp:被标记的方法将会在每一个测试方法被调用前调用
    • Test:被标记的方法会被依次调用

    NUnit是依据特性而不是方法名来确定如何调用这些方法的,所以Tests的类名和其中的方法名都可以修改。

    那么如何启动测试呢?快捷键Ctrl+E+T,或者在VS的菜单栏上,依次:Test-Windows-Test Explore打开测试窗口即可:

    然后在Test1上点击右键,就可以Run(运行)或者Debug(调试)这个测试方法了。

    演示:

     

    测试方法中现在可以使用

    Assert(断言)

    调用各种方法,最常用的是Assert.AreEqual(),比较传入的两个参数:

            [Test]
            public void Test1()
            {
                Assert.AreEqual(5, 3 + 2);
            }
    
            [Test]
            public void Test2()
            {
                Assert.AreEqual(8, 3 + 2);
            }
    

    前面一个参数代表你期望获得的值,后面一个参数代表实际获得的值。如果两个值相等,测试通过;否则会抛出AssertException异常。

    一个方法里可以有多条Assert语句,只有方法里所有Assert语句全部通过,方法才算通过测试。方法通过,用绿色√表示;否则,用红色×标识。

    点击未通过的方法,可以看到其详细信息:

    尤其是StackTrace,是我们定位未通过Assert的有力工具。

     

    当然上面的演示是没有实际作用的,3+2=5这是在测试C#的运算能力呢,^_^。我们要测试的,是我们自己写的代码(通常是方法)。比如,Student类(学生)有一个实例方法Grow(),每调用一次该方法,这个学生的年龄就增长一岁。

    所以我们应该怎么做?先实现这个方法吧……注意,注意,注意!标准(推荐)的做法不是这样的,而应该是:先测试,再开发 。

    啥?一脸懵逼,(黑人问号.jpg

    这就不得不提到大名鼎鼎的:

    TDD

    其全称是Test-Driven Development(测试驱动开发),其核心是:在开发功能代码之前,先编写单元测试用例代码。具体来说,它要求的开发流程是这样的:

    1. 写一个未实现的开发代码。比如定义一个方法,但没有方法实现
    2. 为其编写单元测试。确定方法应该实现的功能
    3. 测试,无法通过。^_^,因为没有方法实现嘛。但这一步必不可少,以免单元测试的代码有误,无论是否正确实现方法功能测试都可以通过
    4. 实现开发代码。比如在方法中完成方法体。
    5. 再次测试。如果通过,Over;否则,查找原因,修复,直到通过。

    以上述Student.Grow()的需求为例:

    首先,在Student中定义该方法但不要有真正的实现,所以可以是这样的:

        public class Student
        {
            public int Age { get; set; }
            public void Grow()
            {
                //没有方法实现
            }
        }
    

    然后,为该方法编写一个单元测试:

            [Test]
            public void Grow()
            {
                //测试准备:得到一个学生对象,其年龄为18岁
                Student student = new Student();
                student.Age = 18;
    
                //调用Grow()方法
                student.Grow();
    
                //检查是否实现了预期的结果
                //该学生的年龄变成了19(=18+1)
                Assert.AreEqual(19, student.Age);
            }
    

    注意我们是在一个新项目中测试另外一个项目,一个项目使用另外一个项目的代码,必须要添加引用。

    演示:接下来,不要忘了要跑一遍这个测试,当然这个测试是无法通过的。

    再然后,才去完成方法Grow():

            public void Grow()
            {
                Age++;
            }
    

    再跑一遍测试,通过!收工,^_^

    为什么要这么做呢?为了避免你的开发代码影响了你的测试思路

    同学们注意调试和测试的区别:调试是为了实现功能修复bug,而测试是为了找到bug!换言之,测试就是要get到你开发没有get到的点上去。如果你先写了开发代码,脑子里已经有了实现的细节,那就很容易出现:写的测试代码,无非就是把开发代码再“翻译”一遍,这样的测试几乎没有意义。

    你说,我其实也没看出来你上面这个单元测试有啥意义,^_^

    Wonderful!这说明你是带着脑子在听课的。

    为了表现出单元测试的意义,我们来完成这样一个功能:

    双向链表

    大家看我们一起帮的文章单页,每一篇底部都有一个“上一篇”和“下一篇”

    对应到文章对象,是不是它里面就应该包含两个属性:Previous(上一篇)和Next(下一篇)。我们再把它进一步的抽象,不局限于文章,就可以得到这样一个数据结构对象:

        public class DoubleLinked
        {
            public DoubleLinked Previous { get; set; }
            public DoubleLinked Next { get; set; }
            public int Value { get; set; }
        }
    

    因为每一个对象都有,就可以串成一串,这就是所谓的双向链表。用图表示:

    双向链表是有头(Head)和尾(Tail)的,头前面没有节点,尾后面没有节点。用代码表示就是:

            public bool IsHead
            {
                get
                {
                    return Previous == null;
                }
            }
    
            public bool IsTail
            {
                get
                {
                    return Next == null;
                }
            }
    

    注意:DoubleLinked既可以看成是双向链表中的一个节点,也可以看成是双向链表本身——因为从这个节点出发,向前(Previous)向后(Next)就能够获得全部的节点;即使是双向链表,也不会存储所有节点,而是存储一个头或/和尾即可。这里为了简便,就直接使用DoubleLinked进行各种操作了。

     

    现在我们来实现双向链表中最

    基本的操作

    ,插入一个节点,如下图所示,把节点5查入2和3之间。

    方法很简单:

    1. 把2的下一个指向5
    2. 把5的下一个指向3
    3. 把3的上一个指向5
    4. 把5的上一个指向2

    但代码怎么实现?你先想一想,^_^

    1. 首先,转变思路,把“查入2和3之间”转变成“插入2之后(InsertAfter(2))”,这样是不是就简单多了?
    2. 然后,你得想想,还需要指明“把谁”插入节点2之后?是不是要在InsertAfter()中再添加一个参数?
    3. 最后,InsertAfter()这个方法放哪里?静态的还是实例的?

    通过前面的学习和作业练习,我们知道了两个原则:

    • 能够实例就不要静态
    • 尽可能的减少方法参数个数

    所以,我们应该定义这样的一个实例方法:

            /// <summary>
            /// 在node之后插入当前节点
            /// </summary>
            /// <param name="node">在哪一个节点之后插入</param>
            public void InsertAfter(DoubleLinked node)
            {
            }
    

    OK,方法有了,你马上就撸柚子准备实现了……停停停!我们要先写单元测试。事情没有你想象的那么简单,你要不信这个邪呢,我们后面还有作业,你可以直接试一试。

    趁我们现在头脑还清醒的时候,先想想测试的事。

    首先我们要添加一个InsertAfterTest()方法,注意不要忘记在这个方法上添加[Test]特性,否则它不会被当做测试方法被NUnit调用运行:

            [Test]   //不要忘记[Test]特性
            public void InsertAfterTest()  //测试方法也不需要任何返回值
            {
            }
    

    为了测试,我们是不是首先要构建一个链表?然后才能往里面插入啊,怎么构建呢?只有手工,在InsertAfterTest()中添加:

                //在单元测试中,命名可以带123等后缀区分
                DoubleLinked node1 = new DoubleLinked();
                DoubleLinked node2 = new DoubleLinked();
                DoubleLinked node3 = new DoubleLinked();
                DoubleLinked node4 = new DoubleLinked();
    
                node1.Next = node2;
                node2.Next = node3;
                node3.Next = node4;
    
                node4.Previous = node3;
                node3.Previous = node2;
                node2.Previous = node1;
    

    然后,再新建一个inserted节点,将其插入节点2之后:

                DoubleLinked inserted = new DoubleLinked();
                inserted.InsertAfter(node2);
    

    OK,完成插入过后,应该是怎么样的一个情形?我们用代码表示:

                Assert.AreEqual(inserted, node2.Next);
                Assert.AreEqual(inserted, node3.Previous);
                Assert.AreEqual(node2, inserted.Previous);
                Assert.AreEqual(node3, inserted.Next);
    

    跑一跑测试,当然是跑不过的,因为InsertAfterTest()根本没实现嘛。

    好了,让我们去实现InsertAfterTest()方法吧……停停停!别慌,测试是为了找到bug,什么情况容易出bug,

     

    极端情况

    下就容易出bug啊!什么是极端情况,想一想,有了:如果是在链表的尾部插入呢?是不是也应该测一测?

    这时候我们有两种选择:

    1. 继续在InsertAfterTest()中添加Assert行
    2. 新开一个方法InsertAfterTailTest()

    我们就用第2种吧,看上去更规范更清晰一些。

    这时候就会有一个问题,是不是要在InsertAfterTailTest()中把构建链表的代码再写一遍?你说不用,我可以复制粘贴!你真是个机灵鬼,记住:程序员憎恨ctrl+c加ctrl+v

    我们的单元测试类还是一个类,这个类里面一样可以有各种类成员,比如字段方法属性等等。既然这些链表节点可以反复使用,我们为什么不把他们定义为字段呢?再回想一下我们的[Setup]特性,它是会在每一个测试方法被调用前运行一次的。我们可以在这里面完成节点的链接:

            //在单元测试中,命名可以带123等后缀区分
            DoubleLinked node1, node2, node3, node4;
    
            [SetUp]
            public void Setup()
            {
                node1 = new DoubleLinked();
                node2 = new DoubleLinked();
                node3 = new DoubleLinked();
                node4 = new DoubleLinked();
    
                node1.Next = node2;
                node2.Next = node3;
                node3.Next = node4;
    
                node4.Previous = node3;
                node3.Previous = node2;
                node2.Previous = node1;
            }
    

    于是,InsertAfterTailTest()里面的代码就非常简单了:

            [Test]
            public void InsertAfterTailTest()
            {
                DoubleLinked inserted = new DoubleLinked();
                inserted.InsertAfter(node4);
    
                Assert.AreEqual(inserted, node4.Next);
                Assert.AreEqual(node4, inserted.Previous);
                Assert.AreEqual(null, inserted.Next);
            }
    

    (InsertAfterTest()方法一样按此精简,此处略过)

    那还有没有其他“极端情况”?有,但飞哥不告诉你,接下来做作业的时候自己去想!^_^

    终于,我们可以实现InsertAfter()并运行单元测试了……

    演示:稍有不慎就无法通过测试,按下葫芦浮起瓢:

    这里有一个小技巧:先专注于通过最常规的InsertAfterTest(),然后再想办法同时通过InsertAfterTest()和InsertAfterTailTest()。

    好了,一路改,千辛万苦通过了这个单元测试,如下所示:

            public void InsertAfter(DoubleLinked node)
            {
                if (node.Next == null)
                {
                    node.Next = this;
                    this.Previous = node;
                }
                else
                {
                    this.Next = node.Next;
                    this.Previous = node;
                    node.Next = this;
                    this.Next.Previous = this;
                }
            }
    

     

    然后,你看这if...else里面好像有一些重复代码,比如:

    node.Next = this;
    this.Previous = node;
    

    这不是重复代码么?可不可以提出来?进行

    重构

    其实飞哥之前给同学们进行作业点评。如果你的代码没有错误,但我还是给你改了,这就是在做重构

    在不改变代码运行结果的前提下,优化代码质量(安全、性能和可读性)

    不知道大家有没有听说过一句话:

    好代码都是改出来的。

    很少有人一次性的写出非常完美的代码——尤其是代码会随着业务逻辑不断变化的时候,你根本就不可能一次性的完成代码,一定是不断的修修补补。但是,实际开发中,你会发现“修修补补”就会把代码慢慢地变成了“屎山”。最有越改越烂,哪有什么“千锤百炼”?!

    可以想象的一个场景:你满怀激情地正准备要重构,被你项目经理一把扑倒在地,“小子,不要命啦!?”

    为什么?

    你试试重构一下我们刚才的代码,按照我们想的:

            public void InsertAfter(DoubleLinked node)
            {
                node.Next = this;
                this.Previous = node;
    
                if (node.Next != null)
                {
                    this.Next = node.Next;
                    this.Next.Previous = this;
                }
            }
    

    看起来代码是整洁多了!然而,就在你沾沾自喜的时候,跑一下单元测试试试?

    这就是为什么不能重构的原因:

    没有单元测试做保证,你的重构风险太大

    其实添加新的feature(功能),修复旧的bug也一样,很容易对其他代码产生干扰,引入新的bug。而且这些bug可能很隐蔽,不一定能够被及时发现——除非你有单元测试。有了单元测试,每次代码改动,把所有的(注意,是所有的!)单元测试跑一遍,都跑过了,就证明改动没有影响现有代码。

    所谓TDD,其实就是要求所有的开发代码都有对应的单元测试(因为你要先写单元测试再写开发代码嘛),用单元测试来保证代码的:

    • 正确性。理论上,TDD的代码bug率非常低——那得你单元测试和开发代码都有疏漏,且双方的疏漏“相兼容”才行。否则,开发代码的bug会被单元测试暴露出来;单元测试的bug也会被开发代码暴露出来。
    • 可维护性。这其实才是TDD最重要的价值。以后同学们会越来越多的体会到代码维护工作的难度和重要性。业界有一句非常著名的论断:
    一个项目,开发所需的时间要占20%,而维护的时间要占80%

    同学们进入工作岗位,更大概率也是进行代码的维护工作(添加新feature,修复老bug等),而不是从头开发。如果没有单元测试覆盖,很多时候维护工作就是“头疼医头脚疼医脚”,修复了旧的bug,带来了新的bug。形象的比喻就是:

    • 这里有个坑,我在旁边挖点土填上,于是旁边又有了一个坑;
    • 好丑的一坨屎,怎么办?再上面再拉一坨屎盖住它!于是那些历史遗留代码都被称之为屎山。

    目前来说,TDD是一个理论上能够大幅度降低代码维护成本的方法。但注意飞哥用的“理论上”三个字,啥意思呢?实际上,开发过程真正做到TDD的不多,甚至可以说非常少。而TDD也从诞生之初的赞叹不止,变得越来越有争议。

    究其根本原因,飞哥认为,无他:

     

    成本和收益

    考量而已。最基本的事实,使用TDD开发,代码量至少翻番,值得么?确实,TDD可以降低后期的维护成本;但是,降低多少呢?和现在的投入相比,收益如何呢?更重要更重要的一个问题:能这个项目有后期维护么?99%的互联网项目,根本就活不到后期维护好吧?

    另外,单元测试不是那么好写的。尤其是涉及到数据库,涉及到外部调用接口,项目变得越来越复杂耦合度越来越高的时候……,这些需要同学们以后逐渐体会。同学们目前只需要记住两点:

    1. 能够单元测试的代码,一定是(高质量的)非常容易解耦的代码。
    2. 能写出高质量代码的程序员,工资一定是不低的

    所以,归根结底,还是成本问题。

    就飞哥个人而言,更愿意取一个折中:

    仅为“核心”代码使用TDD,引入单元测试。

    什么是核心代码呢?大致来说,复杂的、被大量使用、被反复修改的……,都可以算。但最终还是要靠开发人员根据实际情况具体掌握了。

     

    作业

    1. 为之前作业添加单元测试,包括但不限于:
      1. 数组中找到最大值
      2. 找到100以内的所有质数
      3. 猜数字游戏
      4. 二分查找
      5. 栈的压入弹出
    2. 继续完成双向链表的测试和开发,实现:
      1. InerstBefore():在某个节点前插入
      2. Delete():删除某个节点
      3. Swap():交互某两个节点
      4. FindBy():根据节点值查找到某个节点

    每日单词

    -------------------------------

    源栈第二期,飞哥开始编写更优质的课程讲义了。

    太基础的就没有发到园子里,但这一篇TDD相关的,有那么一点点意思,先发到园子里试试水,如果觉得可以的话,别忘记点个赞。以后有好的,我也都发到园子里来,^_^

  • 相关阅读:
    第四次团队作业
    第三次团队作业博客——系统设计
    第二次团队作业博客
    第一次团队作业博客
    团队作业——总结
    软件工程课程总结
    Beta冲刺提交-星期三
    个人作业-Alpha项目测试
    第四次团队作业
    第三次团队作业——系统设计
  • 原文地址:https://www.cnblogs.com/freeflying/p/11983193.html
Copyright © 2011-2022 走看看