合并到:https://17bang.ren/Code/717
在谈到特性的使用场景时,还有一个绝对离不开的就是
按飞哥的定义,单元测试是开发人员自己用代码实现的测试。注意这个定义,其核心在于:
暂时还有点抽象,同学们记着这个概念,我们先用一个
微软在开源/社区的路上一路狂奔:从什么都要自己有,向借用(不是借鉴)乃至大力支持一切优质开源项目!前景可期。
来看一看单元测试长个什么样。
在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会在整个程序集(项目)中遍历,找到带有特定标签(特性)的类和方法,予以相应的处理。
注意这个类里面的两个方法都被贴上了特性:
NUnit是依据特性而不是方法名来确定如何调用这些方法的,所以Tests的类名和其中的方法名都可以修改。
那么如何启动测试呢?快捷键Ctrl+E+T,或者在VS的菜单栏上,依次:Test-Windows-Test Explore打开测试窗口即可:
然后在Test1上点击右键,就可以Run(运行)或者Debug(调试)这个测试方法了。
演示:
测试方法中现在可以使用
调用各种方法,最常用的是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
这就不得不提到大名鼎鼎的:
其全称是Test-DrivenDevelopment(测试驱动开发),其核心是:在开发功能代码之前,先编写单元测试用例代码。具体来说,它要求的开发流程是这样的:
Head head = new Head(); //Assert.AreEqual(5, 3 + 2); Assert.AreEqual(5, head.Add(2, 3)); Assert.AreEqual(0, head.Add(-2, 2)); Assert.AreEqual(-4, head.Add(-2, -2)); Assert.AreEqual(2147483648, head.Add(int.MaxValue - 1, 2));
以上述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之间。
方法很简单:
但代码怎么实现?你先想一想,^_^
通过前面的学习和作业练习,我们知道了两个原则:
所以,我们应该定义这样的一个实例方法:
/// <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啊!什么是极端情况,想一想,有了:如果是在链表的尾部插入呢?是不是也应该测一测?
这时候我们有两种选择:
我们就用第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,其实就是要求所有的开发代码都有对应的单元测试(因为你要先写单元测试再写开发代码嘛),用单元测试来保证代码的:
一个项目,开发所需的时间要占20%,而维护的时间要占80%
同学们进入工作岗位,更大概率也是进行代码的维护工作(添加新feature,修复老bug等),而不是从头开发。如果没有单元测试覆盖,很多时候维护工作就是“头疼医头脚疼医脚”,修复了旧的bug,带来了新的bug。形象的比喻就是:
目前来说,TDD是一个理论上能够大幅度降低代码维护成本的方法。但注意飞哥用的“理论上”三个字,啥意思呢?实际上,开发过程真正做到TDD的不多,甚至可以说非常少。而TDD也从诞生之初的赞叹不止,变得越来越有争议。
究其根本原因,飞哥认为,无他:
考量而已。最基本的事实,使用TDD开发,代码量至少翻番,值得么?确实,TDD可以降低后期的维护成本;但是,降低多少呢?和现在的投入相比,收益如何呢?更重要更重要的一个问题:能这个项目有后期维护么?99%的互联网项目,根本就活不到后期维护好吧?
另外,单元测试不是那么好写的。尤其是涉及到数据库,涉及到外部调用接口,项目变得越来越复杂耦合度越来越高的时候……,这些需要同学们以后逐渐体会。同学们目前只需要记住两点:
所以,归根结底,还是成本问题。
就飞哥个人而言,更愿意取一个折中:
仅为“核心”代码使用TDD,引入单元测试。
什么是核心代码呢?大致来说,复杂的、被大量使用、被反复修改的……,都可以算。但最终还是要靠开发人员根据实际情况具体掌握了。
多快好省!前端后端,线上线下,名师精讲
更多了解 加: