自从我编写第一个单元测试以来已经有10年了。 从那时起,我不记得我已经编写了成千上万的单元测试。 老实说,我在源代码和测试代码之间没有任何区别。 对我来说是同一回事。 测试代码是源代码的一部分。 在过去的3-4年中,我与多个开发团队合作,并且有机会查看了大量的测试代码。 在这篇文章中,我总结了经验不足的开发人员在编写单元测试时通常会犯的最常见错误。
让我们看一下以下简单的类示例,该类收集注册数据,对其进行验证并执行用户注册。 显然,该方法非常简单,其目的是演示单元测试的常见错误,而不是提供功能齐全的注册示例:
public class RegistrationForm {private String name,email,pwd,pwdVerification;// Setters - Getters are ommitted public boolean register(){validate();return doRegister();}private void validate () {check(name, "email");check(email, "email");check(pwd, "email");check(pwdVerification, "email");if (!email.contains("@")) {throw new ValidationException(name + " cannot be empty.");} if ( !pwd.equals(pwdVerification))throw new ValidationException("Passwords do not match.");}private void check(String value, String name) throws ValidationException {if ( value == null) {throw new ValidationException(name + " cannot be empty.");}if (value.length() == 0) {throw new ValidationException(name + " is too short.");}}private boolean doRegister() {//Do something with the persistent contextreturn true;}
这是注册方法的相应单元测试,有意显示单元测试中最常见的错误。 实际上,我已经看过很多次非常相似的测试代码,所以这不是我所说的科幻小说:
@Testpublic void test_register(){RegistrationForm form = new RegistrationForm();form.setEmail("Al.Pacino@example.com");form.setName("Al Pacino");form.setPwd("GodFather");form.setPwdVerification("GodFather");assertNotNull(form.getEmail());assertNotNull(form.getName());assertNotNull(form.getPwd());assertNotNull(form.getPwdVerification());form.register();}
现在,此测试显然将通过,开发人员将看到绿灯,所以竖起大拇指! 让我们转到下一个方法。 但是,此测试代码有几个重要问题。
在我的拙见中,第一个是单元测试的最大误用是测试代码没有充分测试寄存器方法。 实际上,它仅测试许多可能路径中的一个。 我们确定该方法将正确处理空参数吗? 如果电子邮件中不包含@字符或密码不匹配,该方法将如何工作? 开发人员倾向于只为成功的路径编写单元测试,而我的经验表明,代码中发现的大多数错误都与成功的路径无关。 一个非常好的规则要记住的是,对于每一个方法,你需要N个测试,其中N等于在圈复杂度将所有私有方法调用的圈复杂度的方法。
接下来是测试方法的名称。 为此,我部分归咎于所有这些现代IDE,它们自动为测试方法(如示例中的方法)生成愚蠢的名称。 测试方法的命名应向读者解释将要测试的内容和条件 。 换句话说,它应该描述正在测试的路径。 在我们的情况下,更好的名称可以是: should_register_when_all_registration_data_are_valid。 在本文中,您可以找到几种命名单元测试的方法,但是对我来说,“应该”模式最接近人类语言,并且在阅读测试代码时更容易理解。
现在,让我们看一下代码的内容。 有几个断言,这违反了每个测试方法应断言一件事的规则 。 此声明四(4)个RegistrationForm属性的状态。 这使测试更难以维护和阅读(哦,是的,测试代码应该像源代码一样可维护和可读。请记住,对我而言它们之间没有区别),并且很难理解测试的哪一部分失败。
此测试代码还声明了setter / getter。 这真的有必要吗? 为了回答这个问题,我将引用罗伊·奥什罗夫(Roy Osherove)的名言:“ 单元测试的艺术 ”
属性(Java中的获取器/设置器)是很好的示例代码,通常不包含任何逻辑,并且不需要测试。 但是要当心:在属性中添加任何检查后,您将要确保逻辑已经过测试。
在我们的案例中,设置器/获取器中没有业务逻辑,因此这些断言完全没有用。 此外,他们错了,因为他们甚至没有测试安装员的正确性。 想象一下,一个邪恶的开发人员将getEmail方法的代码更改为始终返回常量String而不是email属性值。 该测试仍将通过,因为它断言setter不为null,并且未断言期望值。 因此,这可能是您要记住的一条规则。 断言方法的返回值时,请始终尝试尽可能具体 。 换句话说,除非您不关心实际的返回值,否则请尽量避免使用assertIsNull,assertIsNotNull。
我们正在查看的测试代码的最后但并非最不重要的问题是,从未断言正在测试的实际方法( 寄存器 )。 它在测试方法内部被调用,但是我们从不评估其结果。 这种反模式的变化甚至更糟。 在测试用例中甚至不会调用被测方法。 因此,请记住, 您不仅应调用被测方法,而且还应始终声明预期结果,即使它只是一个布尔值 。 有人可能会问:“无效方法是什么?”。 好的问题,但这是另一次讨论–可能是另一篇文章,但是为您提供一些提示,测试void方法可能会掩盖不好的设计,或者应该使用验证方法调用的框架(例如Mockito.Verify )来完成
作为奖励,您应该记住这是一条最终规则。 想象一下, doRegister实际上已实现,并且对外部数据库做了一些实际的工作。 如果某个本地环境中未安装数据库的开发人员尝试运行测试,将会发生什么情况。 正确! 一切都会失败。 确保测试即使从仅可访问代码和JDK的最笨拙的终端运行,也将具有相同的行为 。 没有网络,没有服务,没有数据库,没有文件系统。 没有!
翻译自: https://www.javacodegeeks.com/2014/09/common-mistakes-junior-developers-do-when-writing-unit-tests.html