如果我们的代码有明显的错误,我们很有动力进行改进。 但是,在某些时候,我们认为我们的代码“足够好”并继续前进。
通常,当我们认为改进现有代码的好处小于所需的工作时,就会发生这种情况。 当然,如果我们低估了投资的回报,我们可能会打错电话,这会伤害我们。
这就是发生在我身上的事情,我决定写这篇文章,以便您避免犯同样的错误。
编写“良好”单元测试
如果我们要编写“好的”单元测试,则必须编写以下单元测试:
- 只测试一件事 。 好的单元测试只能因一个原因而失败,并且只能断言一件事。
- 被正确命名 。 测试方法的名称必须显示测试失败的原因。
- 模拟外部依赖关系(和状态) 。 如果单元测试失败,我们将确切知道问题出在哪里。
补充阅读:
- 单元测试只能测试一件事情
- 编写干净的测试:命名问题
- 编写干净的测试:分而治之
- 编写干净的测试:验证或不验证
如果我们编写满足这些条件的单元测试,我们将编写好的单元测试。 对?
我曾经这样认为。 现在我对此表示怀疑 。
善意铺平地狱之路
我从未见过决定编写糟糕的单元测试的软件开发人员。 如果开发人员正在编写单元测试,则他/她很有可能要编写好的单元测试。 但是,这并不意味着该开发人员编写的单元测试是好的。
我想编写既易于阅读又易于维护的单元测试。 我什至写了一个教程,描述了如何编写干净的测试 。 问题在于,本教程中给出的建议还不够好(尚未)。 它可以帮助我们入门,但是并没有显示出兔子洞的真正深度。
我的教程中描述的方法存在两个主要问题:
命名标准是FTW吗?
如果我们使用Roy Osherove引入的“命名标准”,则会注意到很难描述被测状态和预期行为。
当我们为简单场景编写测试时,此命名标准非常有效。 问题在于,真正的软件并不简单。 通常,我们最终使用以下两个选项之一来命名测试方法:
首先 ,如果我们尝试尽可能具体,则测试方法的方法名称会变得太过糟糕。 最后,我们必须承认我们不能像我们想要的那样具体,因为方法名称会占用太多空间。
其次 ,如果我们尝试使方法名称尽可能短,则方法名称将不会真正描述测试状态和预期行为。
选择哪个选项实际上并不重要,因为无论如何我们都会遇到以下问题:
- 如果测试失败,则方法名称不一定描述要出错的方法。 我们可以使用自定义断言来解决此问题,但是它们不是免费的。
- 很难对我们的测试涵盖的场景进行简要概述。
以下是我们在“ 编写干净测试”教程中编写的测试方法的名称:
- registerNewUserAccount_SocialSignInAndDuplicateEmail_ShouldThrowException()
- registerNewUserAccount_SocialSignInAndDuplicateEmail_ShouldNotSaveNewUserAccount()
- registerNewUserAccount_SocialSignInAndUniqueEmail_ShouldSaveNewUserAccountAndSetSignInProvider()
- registerNewUserAccount_SocialSignInAndUniqueEmail_ShouldReturnCreatedUserAccount()
- registerNewUserAccount_SocialSignInAnquequeEmail_ShouldNotCreateEncodedPasswordForUser()
这些方法的名称不是很长,但是我们必须记住,编写这些单元测试是为了测试一种简单的注册方法。 当我使用这种命名约定为现实生活中的软件项目编写自动化测试时,最长的方法名称是我们最长的示例名称的两倍。
那不是很干净或可读。 我们可以做得更好 。
没有通用配置
在本教程中,我们使单元测试变得更好了 。 尽管如此,他们仍然遭受这样的事实,即没有“自然的”方式在不同的单元测试之间共享配置。
这意味着我们的单元测试包含许多重复的代码,这些代码配置了我们的模拟对象并创建了在单元测试中使用的其他对象。
另外,由于没有“自然”的方式表明某些常量仅与特定的测试方法相关,因此我们必须将所有常量添加到测试类的开头。
我们的测试类的源代码如下(突出显示有问题的代码):
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.invocation.InvocationOnMock;
import org.mockito.runners.MockitoJUnitRunner;
import org.mockito.stubbing.Answer;
import org.springframework.security.crypto.password.PasswordEncoder;import static com.googlecode.catchexception.CatchException.catchException;
import static com.googlecode.catchexception.CatchException.caughtException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Matchers.isA;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyZeroInteractions;
import static org.mockito.Mockito.when;@RunWith(MockitoJUnitRunner.class)
public class RepositoryUserServiceTest {private static final String REGISTRATION_EMAIL_ADDRESS = "john.smith@gmail.com";private static final String REGISTRATION_FIRST_NAME = "John";private static final String REGISTRATION_LAST_NAME = "Smith";private static final SocialMediaService SOCIAL_SIGN_IN_PROVIDER = SocialMediaService.TWITTER;private RepositoryUserService registrationService;@Mockprivate PasswordEncoder passwordEncoder;@Mockprivate UserRepository repository;@Beforepublic void setUp() {registrationService = new RepositoryUserService(passwordEncoder, repository);}@Testpublic void registerNewUserAccount_SocialSignInAndDuplicateEmail_ShouldThrowException() throws DuplicateEmailException {RegistrationForm registration = new RegistrationFormBuilder().email(REGISTRATION_EMAIL_ADDRESS).firstName(REGISTRATION_FIRST_NAME).lastName(REGISTRATION_LAST_NAME).isSocialSignInViaSignInProvider(SOCIAL_SIGN_IN_PROVIDER).build();when(repository.findByEmail(REGISTRATION_EMAIL_ADDRESS)).thenReturn(new User());catchException(registrationService).registerNewUserAccount(registration);assertThat(caughtException()).isExactlyInstanceOf(DuplicateEmailException.class);}@Testpublic void registerNewUserAccount_SocialSignInAndDuplicateEmail_ShouldNotSaveNewUserAccount() throws DuplicateEmailException {RegistrationForm registration = new RegistrationFormBuilder().email(REGISTRATION_EMAIL_ADDRESS).firstName(REGISTRATION_FIRST_NAME).lastName(REGISTRATION_LAST_NAME).isSocialSignInViaSignInProvider(SOCIAL_SIGN_IN_PROVIDER).build();when(repository.findByEmail(REGISTRATION_EMAIL_ADDRESS)).thenReturn(new User());catchException(registrationService).registerNewUserAccount(registration);verify(repository, never()).save(isA(User.class));}@Testpublic void registerNewUserAccount_SocialSignInAndUniqueEmail_
ShouldSaveNewUserAccountAndSetSignInProvider() throws DuplicateEmailException {RegistrationForm registration = new RegistrationFormBuilder().email(REGISTRATION_EMAIL_ADDRESS).firstName(REGISTRATION_FIRST_NAME).lastName(REGISTRATION_LAST_NAME).isSocialSignInViaSignInProvider(SOCIAL_SIGN_IN_PROVIDER).build();when(repository.findByEmail(REGISTRATION_EMAIL_ADDRESS)).thenReturn(null);registrationService.registerNewUserAccount(registration);ArgumentCaptor<User> userAccountArgument = ArgumentCaptor.forClass(User.class);verify(repository, times(1)).save(userAccountArgument.capture());User createdUserAccount = userAccountArgument.getValue();assertThatUser(createdUserAccount).hasEmail(REGISTRATION_EMAIL_ADDRESS).hasFirstName(REGISTRATION_FIRST_NAME).hasLastName(REGISTRATION_LAST_NAME).isRegisteredUser().isRegisteredByUsingSignInProvider(SOCIAL_SIGN_IN_PROVIDER);}@Testpublic void registerNewUserAccount_SocialSignInAndUniqueEmail_ShouldReturnCreatedUserAccount() throws DuplicateEmailException {RegistrationForm registration = new RegistrationFormBuilder().email(REGISTRATION_EMAIL_ADDRESS).firstName(REGISTRATION_FIRST_NAME).lastName(REGISTRATION_LAST_NAME).isSocialSignInViaSignInProvider(SOCIAL_SIGN_IN_PROVIDER).build();when(repository.findByEmail(REGISTRATION_EMAIL_ADDRESS)).thenReturn(null);when(repository.save(isA(User.class))).thenAnswer(new Answer<User>() {@Overridepublic User answer(InvocationOnMock invocation) throws Throwable {Object[] arguments = invocation.getArguments();return (User) arguments[0];}});User createdUserAccount = registrationService.registerNewUserAccount(registration);assertThatUser(createdUserAccount).hasEmail(REGISTRATION_EMAIL_ADDRESS).hasFirstName(REGISTRATION_FIRST_NAME).hasLastName(REGISTRATION_LAST_NAME).isRegisteredUser().isRegisteredByUsingSignInProvider(SOCIAL_SIGN_IN_PROVIDER);}@Testpublic void registerNewUserAccount_SocialSignInAnUniqueEmail_ShouldNotCreateEncodedPasswordForUser() throws DuplicateEmailException {RegistrationForm registration = new RegistrationFormBuilder().email(REGISTRATION_EMAIL_ADDRESS).firstName(REGISTRATION_FIRST_NAME).lastName(REGISTRATION_LAST_NAME).isSocialSignInViaSignInProvider(SOCIAL_SIGN_IN_PROVIDER).build();when(repository.findByEmail(REGISTRATION_EMAIL_ADDRESS)).thenReturn(null);registrationService.registerNewUserAccount(registration);verifyZeroInteractions(passwordEncoder);}
}
一些开发人员认为看起来像上面示例的单元测试足够干净。 我理解这种情绪,因为我曾经是其中之一。 但是,这些单元测试存在三个问题:
- 该案的实质并没有那么清楚 。 因为每种测试方法在调用被测试方法并验证预期结果之前都会进行自我配置,所以我们的测试方法变得比必要的更长。 这意味着我们不能只看一眼随机测试方法并弄清楚它要测试什么。
- 编写新的单元测试很慢 。 因为每个单元测试都必须自行配置,所以向我们的测试套件中添加新的单元测试比它可能要慢得多。 另一个“意外”的缺点是,这种单元测试鼓励人们练习复制和粘贴编程 。
- 维持这些单元测试是一件痛苦的事情 。 如果我们向注册表单添加新的必填字段,或者更改registerNewUserAccount()方法的实现,则必须对每个单元测试进行更改。 这些单元测试太脆弱了。
换句话说,这些单元测试很难阅读,很难编写和维护。 我们必须做得更好 。
摘要
这篇博客文章教会了我们四件事:
- 即使我们认为我们正在编写好的单元测试,也不一定是正确的。
- 如果由于必须更改许多单元测试而导致更改现有功能的速度很慢,那么我们就不会编写好的单元测试。
- 如果添加新功能的速度很慢,因为我们必须向单元测试中添加大量重复的代码,那么我们就不会编写好的单元测试。
- 如果我们看不到单元测试所涵盖的情况,那么我们就没有编写好的单元测试。
本教程的下一部分将回答这个非常相关的问题:
如果现有的单元测试很烂,我们该如何解决?
如果要编写干净的测试,则应阅读我的“ 编写干净的测试”教程 。
翻译自: https://www.javacodegeeks.com/2015/03/writing-clean-tests-trouble-in-paradise.html