如何编写优秀的测试代码|单元测试
无论如何组织测试,无论有多少测试,如果你不能信任、维护以及阅读它们,这些测试就几乎没有价值。要成为优秀的测试,它们应该同时具有如下三个属性。
- 可靠性****。开发人员希望运行的測试可靠,能够对测试结果有信心。可靠的测试没有缺陷而且测试正确的事情
- 可维护。性无法维护的測试是梦,它们会拖延项目计划,或者当项目日程紧张时被搁置一旁。如果修改测试花费时间过多,或者经常需要为很小的产品代码频繁变更修改测试,开发人员会直接停止测试的维护和修复工作
- 可读性。人们不仅要能够阅读测试,还要在测试出问题时找出症结所在。失去可读性另外两个支柱很快也会倒塌。如果无法理解测试,测试的维护工作就会变得困难,也无法得到人们的信任。
1. 可靠性
1.1 及时维护测试代码
测试代码与产品代码一样需要不断进行维护,一旦测试写好了并且通过了,通常是不应该修改或删除这些测试的。这些测试是你的保护网告诉你修改的代码是否破坏了已有的功能。虽说如此,有时可能还是需要修改或者删除已有的测试。要理解什么情况下修改或删除测试会带来问题,什么情况下这么做是合理的。
删除一个测试的主要的理由是这个测试失败了。如果一个测试突然开始失败,可能有如下原因
- 产品缺陷 被测试的产品代码有缺陷。
- 测试缺陷 测试中有缺陷。
- 语义或者AP变更 被测试代码的语义发生变化,但是功能不变
- 冲突或者无效的测试 和测试相关的产品需求发生变化,产品代码随之变更
如果测试或代码没有任何问题,修改或删除测试的原因有:
- 重命名或者重构测试
不可读的测试带来的麻烦比解决的问题更多。它会影响代码的可读性,妨碍你理解测试发现的问题
如果你看到测试名含义不清或者令人误解,或者测试的可维护性有待提高,就应该修改测试代码(但是不要改变测试的基本功能)
- 去除重复代码
1.2 避免测试代码中的逻辑
如果单元测试中有下列任何一种语句,你的测试就包含了不应该有的逻辑:*
- switch、if或e1se语句;*
- foreach、for或whi1e循环。
包含逻辑的测试通常会一次测试多个东西,我们不推荐这种做法,因为这样的测试可读性较也比较脆弱。而且测试逻辑也增加了代码复杂度,可能包含隐藏的缺陷通常来说,一个单元测试应该是一系列的方法调用和断言,但是不包含控制流语句,甚至不应将断言语句包含在try- catch中。任何更复杂的语句都可能导致如下问题。
- 测试难以阅读和理解
- 测试难以重现。(设想一下,如果一个多线程测试或者使用随机数的测试突然失败了,该如何处理。)
- 测试较容易包含缺陷或者测试错误的事情
- 难以命名测试,因为它执行多件任务
1.3 只测一个关注点
如前所述,一个关注点是一个工作单元的一个最终结果:一个返回值、系统状态的一个改变或者对第三方对象的一个调用。例如:如果你的单元测试对多个对象进行了断言,那么这个测试有可能测试了多个关注点。另一种情况是,它既测试了一个对象返回正确的值,又验证系统状态改变导致这个对象的行为发生变化,那么这个测试也可能测试了多个关注点。
测试多个关注点听起来没什么,但是等到你要命名测试,或者考虑第一个对象的断言失败该如何处理时,就会遇到问题。
命名测试看似简单,但是如果同时测试了多个东西,就几乎不可能给测试起一个能说明测试内容的好名字。你最后起的名字可能非常通用,使得读者不得不去阅读测试代码(本章的可读性节详细对此进行讨论)。如果一次只测试一个关注点,测试命名就很简单
1.4 单元测试与集成测试分离
把集成混在单元测试里放在测试项目中会导致很多方面的问题。这种测试难以运行,会让人们误以为代码有问题,浪费时间和精力进行检查,最后导致开发人员不再信任这组测试。混在单元测试里的集成测试就像筐里的烂苹果连累了其他的测试。如果下一次再发生类似的事情,开发人员甚至都不会去调查失败原因,直接就说:“哦,那个测试有时候就是会失败,没事的。”要避免这样的事情发生,就要建一个绿色安全区把集成测试和单元测试分开。
绿色安全区里只包含单元测试。运行绿色安全区里的所有测试测试结果应该全部是绿色的,如果有测试失败,就说明出现了真正的代码问题,而不是因为某些配置或外部依赖倒置的假警报。
1.5 代码审查与覆盖率结合
代码覆盖率100%说明什么呢?如果没有做代码审查,这个覆盖率不能说明什么。你的团队可能会要求所有人的测试“达到95%以上的代码覆盖率”,大家可能也确实做到了。但是也许这些测试连断言都没有。人们通常会选择做最少的事情达到某个指定的目标。
那么代码覆盖率100%再加上测试和代码审查能说明什么呢?这说明整个世界都是你的。如果你做了代码审查和测试审查,确保测试优秀而且覆盖了所有代码,那么你就拥有了一个安全网,可以避免愚蠢的错误,同时团队也获得了分享的知识,从持续的学习中获益
2. 可维护性
2.1 去除重复(Extract Method)
作为开发者,单元测试中的重复代码和产品代码中的重复一样(如果不是更加)有害。DRY原则应该同样适用于测试代码。重复代码意味测试对象某方面改变时要修改更多的测试代码。如果测试中有大量重复代码,构造函数变更或者使用类的语义变化会产生极大的影响
2.2 测试隔离
测试隔离的基本概念是:一个测试应该总是在它自己的小世界中运行,与其他进行类似或不同的工作的测试隔离甚至不知道其他测试的存在。
如果没有很好地隔离测试,它们会互相影响,使你非常悲惨,后悔在项目中尝试单元测试决心以后再也不做单元测试了。我见过这种情况。开发人员不愿费心检查测试中的问题,因此当出现问题时,需要花很多时间才能找到原因有些测试同样存在着一些坏味道能够提示测试隔离可能有问题。
- 强制的测试顺序 测试需要以某种特定顺序执行,或者需要来自其他测试结果的信息
- 隐藏的测试调用 测试调用其他测试。
- 共享状态损坏 测试共享内存里的状态,却没有回滚状态。
- 外部共享状态损坏 集成测试共享资源,却没有回滚资源。
2.3 避免对不同的关注点多次断言(使用参数化测试)
Assert.AreEqual(2,Sum(1,2));
Assert.AreEqual(5,Sum(2,2));
Assert.AreEqual(6,Sum(5,2));
如上示例,这个测试方法中使用了三个断言,进行了三个测试。这样看起来在实际过程中会节省一些写代码的时间,但会有一些问题。如果第一个断言失败,则后续断言就不会在执行。而在这个示例中我们是进行了三个测试。第一个断言失败就会导致我们无法得知另外两个测试的测试结果。对于这种情况我们可以采取别的方式进行测试
- 给每个断言创建一个单独的测试
- 使用参数化测试
- 把断言代码放在一个try-catch块中
2.4 避免过度指定
过度指定的测试对一个具体的被测试单元如何实现其内部行为进行了假设,而不是只检查其最终行为的正确性单元测试中过度指定主要有以下几种情况
- 测试对一个被测试对象的纯内部状态进行了断言
- 测试使用多个模拟对象
- 测试在需要存根时使用模拟对象
- 测试在不必要的情况下指定顺序或使用了精确匹配。
3. 可读性
不可读的测试几乎没有任何意义。可读性这条线连接着编写测试的人和几个月后阅读测试的人。测试是你向项目的下一代开发者讲述的故事,帮助开发者理解一个应用程序的组成及其开端。
测试可读性有如下几个方面
- 命名单元测试
- 命名变量
- 使用好的断言信息
- 把断言和操作分离
3.1 单元测试命名
命名标准非常重要,提供了合理的规则和模板,列出应该包括的测试信息。测试名一般包括三部分。
- **被测试方法名 **非常关键,指明了被测试逻辑的位置。把被测试方法名放在测试方法开头,可以很容易地在测试类中浏览测试和使用智能感知(如果IDE支持)
- 测试场景 说明了测试使用的条件:“如果我用一个nu11值调用方法x,那么它应该执行Y。”
- 预期行为 基于当前场景,方法应该产生的行为结果或者返回值,或者行为方式:“如果用一个null值调用方法X,那么它应该执行Y。”如果测试名缺少上面列出的任何一部分,测试的读者就会疑惑测试究竟在做什么,需要阅读测试代码。合理地命名测试,主要目的就是为了使后来的开发者从为了理解测试而阅读代码的负担中解脱出来。
public void IsValidFileName(){
...
}
[Test]
public void IsValidFileName_WhenPNG_ReturnFalse(){
...
}
如上示例,通过测试的方法命名我们就可以大概知道要测试的是方法是IsValidFileName当输入参数是PNG的时候,预期返回False。
当然,你的团队也可以有适合自己的命名方式,但重要的是如果一个团队中都有统一的有意义命名规范,那么单元测试的可读性将大大提升,并且有利于后来者快速进入项目,理解测试。
3.2 变量命名
测试中的变量命名和产品代码中的命名规范同样重要,通过合理的变量命名,我们可以确保阅读测试的人可以尽快的理解你要验证什么。
// 反例
Assert.AreEqual(100,actual);
如上示例,我们经常会看到测试中出现”100″这样的魔法数字。因为测试中没有描述性的名字,也许你在刚刚写完的时候还知道它是什么意思,但是一周后,一月后,一年后呢?甚至你未来的继任者看到这样的测试代码也是一头雾水。
3.4 断言和操作分离
很多人为了“偷懒”经常会把断言和方法调用卸载同一行里,但这是一个很不好的习惯,它会大大降低代码的可读性。
// 反例
Assert.AreEqual(true,fileManger.IsValidName())
// 正例
bool expect=true;
bool actual=fileManger.IsValidName();
Assert.AreEqual(expect,actual)