断言工具的编写
很难为干净的代码找到一个好的定义,因为我们每个人都有自己的单词clean的定义。 但是,有一个似乎是通用的定义:
简洁的代码易于阅读。
这可能会让您感到有些惊讶,但我认为该定义也适用于测试代码。 使测试尽可能具有可读性是我们的最大利益,因为:
- 如果我们的测试易于阅读,那么很容易理解我们的代码是如何工作的。
- 如果我们的测试易于阅读,那么如果测试失败(不使用调试器),很容易发现问题。
编写干净的测试并不难,但是需要大量的实践,这就是为什么如此多的开发人员为此苦苦挣扎的原因。
我也为此感到挣扎,这就是为什么我决定与您分享我的发现的原因。
这是本教程的第五部分,介绍了如何编写干净的测试。 这次,我们将使用特定于域的语言替换断言。
数据不是那么重要
在我以前的博客文章中,我确定了以数据为中心的测试引起的两个问题。 尽管该博客文章讨论了新对象的创建,但是这些问题对于断言也有效。
让我们刷新内存,看一下单元测试的源代码,该代码可确保当使用唯一电子邮件地址和社交符号创建新用户帐户时, RepositoryUserService类的registerNewUserAccount(RegistrationForm userAccountData)方法能够按预期工作在提供者中。
我们的单元测试如下所示(相关代码突出显示):
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
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 org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import static org.mockito.Matchers.isA;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
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 Role ROLE_REGISTERED_USER = Role.ROLE_USER;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_SocialSignInAndUniqueEmail_ShouldCreateNewUserAccountAndSetSignInProvider() 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);assertEquals(REGISTRATION_EMAIL_ADDRESS, createdUserAccount.getEmail());assertEquals(REGISTRATION_FIRST_NAME, createdUserAccount.getFirstName());assertEquals(REGISTRATION_LAST_NAME, createdUserAccount.getLastName());assertEquals(SOCIAL_SIGN_IN_PROVIDER, createdUserAccount.getSignInProvider());assertEquals(ROLE_REGISTERED_USER, createdUserAccount.getRole());assertNull(createdUserAccount.getPassword());verify(repository, times(1)).findByEmail(REGISTRATION_EMAIL_ADDRESS);verify(repository, times(1)).save(createdUserAccount);verifyNoMoreInteractions(repository);verifyZeroInteractions(passwordEncoder);}
}
如我们所见,从单元测试中找到的断言可确保返回的User对象的属性值正确。 我们的主张确保:
- email属性的值正确。
- firstName属性的值正确。
- lastName属性的值正确。
- signInProvider的值正确。
- 角色属性的值正确。
- 密码为空。
这当然很明显,但是以这种方式重复这些断言很重要,因为它可以帮助我们确定断言的问题。 我们的断言是以数据为中心的 ,这意味着:
- 读者必须知道返回对象的不同状态 。 例如,如果我们考虑示例,读者必须知道,如果返回的RegistrationForm对象的email , firstName , lastName和signInProvider属性具有非null值,并且password属性的值为null,则意味着对象是通过使用社交登录提供程序进行的注册。
- 如果创建的对象具有许多属性,则我们的断言会乱码我们测试的源代码。 我们应该记住,即使我们要确保返回对象的数据正确无误,但描述返回对象的状态也更为重要。
让我们看看如何改善断言。
将断言转变为特定领域的语言
您可能已经注意到,开发人员和领域专家通常在相同的事情上使用不同的术语。 换句话说,开发人员讲的语言与领域专家讲的语言不同。 这在开发人员和领域专家之间造成了不必要的混乱和摩擦 。
域驱动设计(DDD)为该问题提供了一种解决方案。 埃里克·埃文斯(Eric Evans)在他的《 域驱动设计 》( Domain-Driven Design)一书中引入了泛在语言一词。
维基百科指定了普遍使用的语言 ,如下所示:
无处不在的语言是围绕领域模型构造的语言,所有团队成员都使用该语言将团队的所有活动与软件联系起来。
如果我们想写断言使用“正确的”语言,那么我们必须弥合开发人员和领域专家之间的鸿沟。 换句话说,我们必须创建一种特定于域的语言来编写断言。
实施我们的领域特定语言
在实现我们特定领域的语言之前,我们必须对其进行设计。 当我们为断言设计特定领域的语言时,我们必须遵循以下规则:
- 我们必须放弃以数据为中心的方法,而应该更多地考虑从用户对象中找到其信息的真实用户。
- 我们必须使用领域专家所说的语言。
我不会在这里进行详细说明,因为这是一个巨大的主题,不可能在单个博客中进行解释。 如果要了解有关特定于域的语言和Java的更多信息,可以通过阅读以下博客文章开始:
- Java Fluent API设计器速成课程
- 用Java创建DSL,第1部分:什么是领域特定语言?
- 用Java创建DSL,第2部分:流利性和上下文
- 用Java创建DSL,第3部分:内部和外部DSL
- 用Java创建DSL,第4部分:元编程很重要
如果遵循这两个规则,则可以为特定于域的语言创建以下规则:
- 用户具有名字,姓氏和电子邮件地址。
- 用户是注册用户。
- 用户是使用社交符号提供者注册的,这意味着该用户没有密码。
现在,我们已经指定了特定领域语言的规则,我们已经准备好实现它。 我们将通过创建一个自定义的AssertJ断言来实现此目的,该断言实现我们特定于域的语言的规则。
我不会在此博客文章中描述所需的步骤,因为我已经写了一篇博客来描述这些步骤 。 如果您不熟悉AssertJ,建议您先阅读该博客文章,然后再阅读本博客文章的其余部分。
我们的自定义断言类的源代码如下所示:
mport org.assertj.core.api.AbstractAssert;
import org.assertj.core.api.Assertions;public class UserAssert extends AbstractAssert<UserAssert, User> {private UserAssert(User actual) {super(actual, UserAssert.class);}public static UserAssert assertThat(User actual) {return new UserAssert(actual);}public UserAssert hasEmail(String email) {isNotNull();Assertions.assertThat(actual.getEmail()).overridingErrorMessage( "Expected email to be <%s> but was <%s>",email,actual.getEmail()).isEqualTo(email);return this;}public UserAssert hasFirstName(String firstName) {isNotNull();Assertions.assertThat(actual.getFirstName()).overridingErrorMessage("Expected first name to be <%s> but was <%s>",firstName,actual.getFirstName()).isEqualTo(firstName);return this;}public UserAssert hasLastName(String lastName) {isNotNull();Assertions.assertThat(actual.getLastName()).overridingErrorMessage( "Expected last name to be <%s> but was <%s>",lastName,actual.getLastName()).isEqualTo(lastName);return this;}public UserAssert isRegisteredByUsingSignInProvider(SocialMediaService signInProvider) {isNotNull();Assertions.assertThat(actual.getSignInProvider()).overridingErrorMessage( "Expected signInProvider to be <%s> but was <%s>",signInProvider,actual.getSignInProvider()).isEqualTo(signInProvider);hasNoPassword();return this;}private void hasNoPassword() {isNotNull();Assertions.assertThat(actual.getPassword()).overridingErrorMessage("Expected password to be <null> but was <%s>",actual.getPassword()).isNull();}public UserAssert isRegisteredUser() {isNotNull();Assertions.assertThat(actual.getRole()).overridingErrorMessage( "Expected role to be <ROLE_USER> but was <%s>",actual.getRole()).isEqualTo(Role.ROLE_USER);return this;}
}
现在,我们已经创建了一种特定于域的语言,用于将断言写入User对象。 下一步是修改单元测试,以使用我们新的领域特定语言。
用特定于域的语言替换JUnit断言
在重写断言以使用特定于域的语言之后,单元测试的源代码如下所示(相关部分已突出显示):
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
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 org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import static org.mockito.Matchers.isA;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
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 Role ROLE_REGISTERED_USER = Role.ROLE_USER;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_SocialSignInAndUniqueEmail_ShouldCreateNewUserAccountAndSetSignInProvider() 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);assertThat(createdUserAccount).hasEmail(REGISTRATION_EMAIL_ADDRESS).hasFirstName(REGISTRATION_FIRST_NAME).hasLastName(REGISTRATION_LAST_NAME).isRegisteredUser().isRegisteredByUsingSignInProvider(SOCIAL_SIGN_IN_PROVIDER);verify(repository, times(1)).findByEmail(REGISTRATION_EMAIL_ADDRESS);verify(repository, times(1)).save(createdUserAccount);verifyNoMoreInteractions(repository);verifyZeroInteractions(passwordEncoder);}
}
我们的解决方案具有以下优点:
- 我们的断言使用领域专家可以理解的语言。 这意味着我们的测试是可执行的规范,它易于理解并且始终是最新的。
- 我们不必浪费时间弄清楚测试失败的原因。 我们的自定义错误消息可确保我们知道失败的原因。
- 如果User类的API发生了变化,我们不必修复所有将断言写入User对象的测试方法。 我们唯一需要更改的类是UserAssert类。 换句话说,将实际的断言逻辑从测试方法中移开会使我们的测试不那么脆弱,更易于维护。
让我们花点时间总结一下我们从此博客文章中学到的知识。
摘要
现在,我们已将断言转换为特定领域的语言。 这篇博客文章教会了我们三件事:
- 遵循以数据为中心的方法会在开发人员和领域专家之间造成不必要的混乱和摩擦。
- 为我们的断言创建一种特定于域的语言会使我们的测试不那么困难,因为实际的断言逻辑已移至自定义断言类。
- 如果我们使用特定领域的语言编写断言,则会将测试转换为可执行的规范,这些规范易于理解和说出领域专家的语言。
翻译自: https://www.javacodegeeks.com/2014/06/writing-clean-tests-replace-assertions-with-a-domain-specific-language.html
断言工具的编写