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相关的,有那么一点点意思,先发到园子里试试水,如果觉得可以的话,别忘记点个赞。以后有好的,我也都发到园子里来,^_^

 

posted @ 2019-12-04 15:33  自由飞  阅读(1144)  评论(6编辑  收藏  举报