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(测试驱动开发),其核心是:在开发功能代码之前,先编写单元测试用例代码。具体来说,它要求的开发流程是这样的:
- 写一个未实现的开发代码。比如定义一个方法,但没有方法实现
- 为其编写单元测试。确定方法应该实现的功能
- 测试,无法通过。^_^,因为没有方法实现嘛。但这一步必不可少,以免单元测试的代码有误,无论是否正确实现方法功能测试都可以通过
- 实现开发代码。比如在方法中完成方法体。
- 再次测试。如果通过,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之间。
方法很简单:
- 把2的下一个指向5
- 把5的下一个指向3
- 把3的上一个指向5
- 把5的上一个指向2
但代码怎么实现?你先想一想,^_^
- 首先,转变思路,把“查入2和3之间”转变成“插入2之后(InsertAfter(2))”,这样是不是就简单多了?
- 然后,你得想想,还需要指明“把谁”插入节点2之后?是不是要在InsertAfter()中再添加一个参数?
- 最后,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啊!什么是极端情况,想一想,有了:如果是在链表的尾部插入呢?是不是也应该测一测?
这时候我们有两种选择:
- 继续在InsertAfterTest()中添加Assert行
- 新开一个方法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%的互联网项目,根本就活不到后期维护好吧?
另外,单元测试不是那么好写的。尤其是涉及到数据库,涉及到外部调用接口,项目变得越来越复杂耦合度越来越高的时候……,这些需要同学们以后逐渐体会。同学们目前只需要记住两点:
- 能够单元测试的代码,一定是(高质量的)非常容易解耦的代码。
- 能写出高质量代码的程序员,工资一定是不低的
所以,归根结底,还是成本问题。
就飞哥个人而言,更愿意取一个折中:
仅为“核心”代码使用TDD,引入单元测试。
什么是核心代码呢?大致来说,复杂的、被大量使用、被反复修改的……,都可以算。但最终还是要靠开发人员根据实际情况具体掌握了。
作业:
- 为之前作业添加单元测试,包括但不限于:
- 数组中找到最大值
- 找到100以内的所有质数
- 猜数字游戏
- 二分查找
- 栈的压入弹出
- 继续完成双向链表的测试和开发,实现:
- InerstBefore():在某个节点前插入
- Delete():删除某个节点
- Swap():交互某两个节点
- FindBy():根据节点值查找到某个节点
每日单词:
-------------------------------
源栈第二期,飞哥开始编写更优质的课程讲义了。
太基础的就没有发到园子里,但这一篇TDD相关的,有那么一点点意思,先发到园子里试试水,如果觉得可以的话,别忘记点个赞。以后有好的,我也都发到园子里来,^_^