开始一个新的系列了。希望把单元测试的方方面面跟实际开发结合起来进行深入的了解。除了单元测试本身,还会涉及重构/修改代码、代码设计、TDD、自动化等方面的内容。好了,开始上路吧!
1、上路
1.1 什么是单元测试?
单元测试是开发者编写的一小段代码,用于检验被测代码的一个很小的、很明确的功能是否正确。通常情况下,一个单元测试(用例)用于判断某个特定条件(或场景)下特定函数的行为。
例如,我们要编写一个类,它要实现有序的List功能,当向其添加一个元素时,程序应该能够按照它的大小将其插到合适的位置。这是我们编写一个测试用例,将一个很大的值放入这个List中,然后确认该值出现在List的尾部。
简单地说,执行单元测试是为了验证某段代码的行为是否与开发者所期望的一致。
单元测试是必要的吗?
单元测试是由开发者编写和执行的,针对的也只是那些很小规模的代码,那么对于客户或最终用户来说,它们影响大吗?它们是必要的吗?
其实,我们在进行单元测试时,我们所关心的规模很小的、独立的代码片段。我们的哲学是首先对所有单独部分的行为建立起信心(确信它们与期望一致),然后再安心的组装和测试整个系统。毕竟,如果盖房所用的砖瓦都没法保证质量,我们对房子还能期望什么呢?如果不先对砖瓦进行检查、验证,那么盲目的进行建筑岂不是极大的浪费?
另一方面,单元测试只是各种测试中的第一步,在此之后,我们还需要其它形式的测试,比如集成测试、系统测试等,这个就视需要而定了。
1.2 为什么要使用单元测试?
“单元测试让生活更轻松。”
这听起来像是某句广告词,它究竟效果如何呢?
让我们回到现实,假定我们正在开发一个网站程序。首先我们会将整个程序设计为三层:数据访问层、业务逻辑层和表现层。首先是编写数据访问层,如何测试它是否正确呢?如果没有进行单元测试,那么就得等到业务逻辑层和表现层开发完毕后才能打开页面进行测试。而这中间,业务逻辑层要调用数据访问层,表现层要调用业务逻辑层的代码。如果通过页面发现某个功能没有通过,我们就得进行调试,调试时要一步一步地跟踪代码,好不容易找到bug所在了,原来是数据访问的一个方法里出了问题,把该方法改好了,编译不通过!看来还得修改另外两层的代码,好,把代码都改好了,再次打开页面进行测试,糟糕还是没通过。上面的过程再来一次。。。
上面这种方式的缺点可以总结为:
- 错误难以定位:每次要打开页面、输入值、调试,单元测试可能也需要这些过程,但其工作量则会小很多。
- 执行时间长:较之单元测试,上面的方式显然耗时得多。
- 代码覆盖:可以理解的是,涉及的代码层次越多,就越是难以确定被测代码和测试值之间的关系。我们要覆盖到所有的数据访问层的代码,就要花费很大的精力。
在应用了(好的)单元测试后,一切都将变得不同了。我们可以快速定位错误,执行的时间也要短得多,代码覆盖也更容易进行。
如果一开始就对数据访问层和业务逻辑层进行了良好的单元测试,那么接下来表现层的开发就顺利得多了,我们相信前面的代码已经相当不错了,可以开心地编写后面的代码。一旦出了问题,我们也很容易定位和修改。
所以说,单元测试让开发人员对自己的代码充满信心,这项看似简单的技术可以让代码变得更加完美,从而让我们的生活更轻松。
1.3 它能给我带来什么?
引入单元测试是简单的,它本身是充满乐趣的,但我们不会将它交给客户和最终用户。我们得考虑一下,单元测试的目的是什么。首先的一点是,使用单元测试是为了让我们的工作更为轻松。
当然了,可执行的文档具有自我验证正确与否的优点,在首次编写后就不需很大的工作量了。不像普通意义上的文档,它不会出现与代码不一致的情况(除非你不再执行测试或者让它们一直失败下去)。
代码的行为与我的期望一致吗?
最根本的是,我们需要回答一个问题:“这段代码达到我的目的了吗?”先别太关心需求,当前要做的事情是确保一段代码的行为与期望一致。
代码的行为一直和我的期望一致吗?
工程师在设计桥梁的时候,必须考虑负载、强风、地震、洪水等等,不能在因洪水出现问题后说,“如果风和日丽就不会有问题了”。
程序开发亦是如此。记得以前开发的时候,一个模块开发完毕后,我就把那些相关的页面打开,按正常的顺序输入正确的数据把功能走一遍,如果都通过了就交给测试人员。而测试人员最喜欢的一件事就是找出开发人员的bug,他们会想法按错误的顺序输入错误的数据,然后等着bug出现。
结果是,我的程序出了一堆bug,它们大多数都是因为错误的顺序或者错误的数据,我气愤地跟他们说,“你如果按正常的顺序输入正确的数据就不会有问题了!”
但事实是,测试桥梁时,不能仅选择在风和日丽的一天,仅让一辆车顺利通过,这远远不够。同样的,在测试代码的行为是否与期望一致时,需要确认:在任何情况下,这段代码是否都与期望一致:比如文件不存在、权限不足、索引越界、网络断掉的时候。
我可以依赖单元测试吗?
不能依赖的代码是没有多大用处的。更为糟糕的是,那些我们认为值得信赖的代码(但其实是有bug的)有时候会让我们花费更多的时间去跟踪和调试。
没有人能够写出完美无缺的代码,但是这没问题——只要我们知道问题的所在就可以了。
我们希望能够依赖于所编写的代码,并且清楚地知道这些代码的功能和约束。
单元测试能否表达我的意图?
编写单元测试,一个额外的好处就是它能够帮助我们表达代码的意图。在效果上,它就像是可执行的文档,说明了在用各种条件调用代码时,我们对代码行为的期望。
当团队的其他成员看到测试代码后可以将其作为代码用法的示例。如果他发现了一个遗漏的测试用例,他会很快知道:代码可能不支持这个用例。
1.4 如何进行单元测试?
单元测试是较为简单易学的技术,如果遵循一些指导性原则,学习会变得更为容易和有效。
首先要考虑的是在编写测试方法之前,如何测试那些可疑的方法。有了大概一个想法之后,可以在编写实现代码的时候,或者在此之前,编写测试代码。
下一步,要运行测试本身,或者所在模块的其他测试,甚至是整个系统的测试,前提是它们要运行得相当快。重要的是所有测试用例都要通过,而不是仅仅新加的那个。这种基本的回归测试(Regression Test)可帮助我们避免对其他的测试带来间接的破坏。
我们还要借助于单元测试框架来进行测试,这样可以大大提高效率。相关的知识可在后面的文章中介绍。
1.5 可是我还是不想测试
看了上面的这些介绍后,也许你能理解单元测试的必要性,也许你还在犹豫,而且还有不少的理由。
编写单元测试很费时间
我听很多人这么说过。我们来看一个容易理解的例子。
小时候经常跟我妈一起去地里除草,我妈经常说,要在草很小的时候把它锄掉,如果不管它,一场雨之后,草就会长得很多、很密集,这时就难除多了。
程序中的bug是不是像地里的杂草呢?
下面的两幅图表明了“立即测试”和“单一测试阶段”的比较。
长远来看,使用“立即测试”模型的的生产力将大大高于“单一测试”模型。在开发过程中多花一点时间在编写单元测试上面,就可以降低在项目后期花费大量时间的风险。
单元测试并非免费的午餐。在“立即测试”模型的开始阶段,要花费一些时间编写单元测试,但随着时间的推进,“单一测试”模型就会花费更多时间——生产力急剧下降,甚至会成为负值。
简单的说,“出来混,总是要还的”。
如果你还是认为没有时间编写测试代码,那么考虑下面这些问题:
1、 对于编写的代码,调试花了多长时间?
2、 对于那些你认为正确,实际上却存在bug的代码,确认它们花了多长时间?
3、 对于一个别人发现的bug,你定位它花了多长时间?
前面说过,单元测试正是能够减少调试、定位时间的一种方法,所以舍弃单元测试实在是得不偿失啊。
运行测试的时间太长了
好的测试不该这样。大多数测试的执行都是很快的,可以在几秒钟内运行成千上万的测试。但是某些测试会花费很长的时间,因此我们不能每次都运行这些测试。
可以将这些很耗时的测试与其它测试分开,通常可以每天运行这种测试一次,或几天一次;而其它运行很快的测试,则可以经常运行。
测试代码不是我的工作
忘了吗?单元测试是由程序员编写并执行的,它能使程序员的代码更加完美。你的工作不就是编写更好的代码吗?
那些可恶的遗留代码没法测试
很多人说,他们不能做单元测试,因为那些既有的代码错综复杂、难以理解,没法进行独立的测试。要测试一小段代码,也要牵扯到整个系统,做出任何修改都有风险。
但这不是单元测试的错,错在那些设计很差的遗留代码。你需要去重构,整理那些遗留代码。注意,这么做可不全是为了测试之需,单元测试的真正威力在于对设计的回馈,如果使用得当,我们会得到更好的面向对象的设计。
由于遗留代码的羁绊,我们将会在那种谨慎、忧虑的状态下编码,其效率可想而知,这不利于项目,不利于程序员,最终也不利于商业利益。引入单元测试将帮助解除这些羁绊。
我不是很确定代码的行为
如果这样,说明还不是编码的时候。这时原型可能会有帮助。
我拿薪水,是因为我在编码,而不是编写测试
如果你这么说,你整天调试的时候,就不该付你薪水。大概说来,你拿到薪水是因为你写出了可以工作的代码,而单元测试就是一个不错的工具来帮你达到此目的。它的作用就像一个编辑器、IDE或编译器。
单元测试会让测试人员和QA失业,我会为此感到内疚
你实在多虑了。我们说的仅仅是单元测试。它是给程序员用的。还有很多其它事情等着测试人员和QA去做呢,比如功能测试、接受性测试、性能和环境测试、验证等等。
公司不允许我在真实环境下运行单元测试
靠!我们现在说的是开发人员进行的单元测试啊。虽然你可能会在其它环境(比如真实的生产系统)运行相同的测试,但它们已不属于单元测试。单元测试应当在你自己的机器伤执行,使用自己的数据库或者使用Mock对象。
我们可是已经单元测试了
单元测试需要很大的热情去维系。如果团队缺乏热情,就有可能没有正确地进行单元测试。看看你的团队是否有如下的警告信号:
l 所做的单元测试其实是集成测试,需要很多代码去组装和测试代码,需要很长的时间去执行,并且需要访问诸如数据库和网络服务这样的资源。
l 单元测试太少,只测试了一条(代码)路径,没有测试例外的情况(没有磁盘空间等),或者并没有表达出代码的意图。
l 单元测试无人维护:如果测试失败了,就每人去管了(甚至删掉),或者不去添加新的单元测试,即使遭遇的bug已经表明单元测试的覆盖率不足了。
如果真的是这样,你的团队就缺乏有效的单元测试。让每个人去温习一下单元测试,接受培训或者尝试下结队编程,来获取一些新观点吧。