很难为干净的代码找到一个好的定义,因为我们每个人都有自己的单词clean的定义。 但是,有一个似乎是通用的定义:
干净的代码易于阅读。
这可能会让您感到有些惊讶,但是我认为该定义也适用于测试代码。 使测试尽可能具有可读性是我们的最大利益,因为:
- 如果我们的测试易于阅读,那么很容易理解我们的代码是如何工作的。
- 如果我们的测试易于阅读,那么如果测试失败(不使用调试器),很容易发现问题。
编写干净的测试并不难,但是需要大量的实践,这就是为什么如此多的开发人员为此苦苦挣扎的原因。
我也为此感到挣扎,这就是为什么我决定与您分享我的发现的原因。
这是我教程的第三部分,描述了我们如何编写干净的测试。 这次,我们将学习两种可用于从测试中删除幻数的技术。
救援常量
我们使用在我们的代码常量,因为没有常量我们的代码将与被散落幻数 。 使用幻数有两个结果:
- 我们的代码很难阅读,因为幻数只是没有意义的值。
- 我们的代码很难维护,因为如果必须更改幻数的值,则必须查找该幻数的所有出现并更新每个幻数。
换一种说法,
- 常数帮助我们用描述其存在原因的某种事物来代替幻数。
- 常量使我们的代码更易于维护,因为如果常量的值发生变化,我们只需将该更改仅保留到一个位置即可。
如果我们考虑从测试用例中找到的幻数,我们会注意到它们可以分为两组:
- 与单个测试类相关的幻数。 这种幻数的典型示例是在测试方法中创建的对象的属性值。 我们应该在测试类中声明这些常量 。
- 与多个测试类别相关的幻数。 这种魔术数字的一个很好的例子是由Spring MVC控制器处理的请求的内容类型。 我们应该将这些常量添加到非实例化类中 。
让我们仔细看看这两种情况。
在测试类中声明常量
那么,为什么我们要在测试类中声明一些常量呢?
毕竟,如果我们考虑使用常量的好处,首先想到的是,我们应该通过创建包含测试中使用的常量的类来消除测试中的幻数。 例如,我们可以创建一个TodoConstants类,其中包含TodoControllerTest , TodoCrudServiceTest和TodoTest类中使用的常量。
这是一个坏主意 。
尽管有时候以这种方式共享数据是明智的,但我们不应轻易做出这个决定,因为在大多数情况下,我们在测试中引入常数的唯一动机是避免输入错误和幻数。
另外,如果幻数仅与单个测试类相关,则将这种依赖关系引入我们的测试是没有道理的,因为我们想最大程度地减少创建的常量的数量。
我认为,处理这种情况的最简单方法是在测试类中声明常量。
让我们找出如何改进本教程前面部分中描述的单元测试。 编写该单元测试以测试RepositoryUserService类的registerNewUserAccount()方法,并且当使用社交符号提供者和唯一的电子邮件地址创建新的用户帐户时,它验证此方法是否正常工作。
该测试用例的源代码如下所示:
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 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 RegistrationForm();registration.setEmail("john.smith@gmail.com");registration.setFirstName("John");registration.setLastName("Smith");registration.setSignInProvider(SocialMediaService.TWITTER);when(repository.findByEmail("john.smith@gmail.com")).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("john.smith@gmail.com", createdUserAccount.getEmail());assertEquals("John", createdUserAccount.getFirstName());assertEquals("Smith", createdUserAccount.getLastName());assertEquals(SocialMediaService.TWITTER, createdUserAccount.getSignInProvider());assertEquals(Role.ROLE_USER, createdUserAccount.getRole());assertNull(createdUserAccount.getPassword());verify(repository, times(1)).findByEmail("john.smith@gmail.com");verify(repository, times(1)).save(createdUserAccount);verifyNoMoreInteractions(repository);verifyZeroInteractions(passwordEncoder);}
}
问题在于,此测试用例在创建新的RegistrationForm对象,配置UserRepository模拟的行为,验证返回的User对象的信息是否正确以及验证是否调用了UserRepository模拟的正确方法时使用了幻数。在经过测试的服务方法中。
通过在测试类中声明常量来删除这些幻数之后,测试的源代码如下所示:
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 RegistrationForm();registration.setEmail(REGISTRATION_EMAIL_ADDRESS);registration.setFirstName(REGISTRATION_FIRST_NAME);registration.setLastName(REGISTRATION_LAST_NAME);registration.setSignInProvider(SOCIAL_SIGN_IN_PROVIDER);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);}
}
此示例说明在测试类中声明常量具有三个好处:
- 我们的测试用例更易于阅读,因为魔术数字被正确命名的常量所替代。
- 我们的测试用例更易于维护,因为我们可以更改常量的值而无需对实际测试用例进行任何更改。
- 为RepositoryUserService类的registerNewUserAccount()方法编写新测试更加容易,因为我们可以使用常量而不是幻数。 这意味着我们不必担心拼写错误。
但是,有时我们的测试使用与多个测试类别真正相关的幻数。 让我们找出如何应对这种情况。
将常量添加到非实例性类
如果该常量与多个测试类相关,则在使用该常量的每个测试类中声明该常量是没有意义的。 让我们看一下一种情况,将常量添加到非实例化类是有意义的。
假设我们必须为REST API编写两个单元测试:
- 第一个单元测试确保我们不能向数据库添加空的待办事项。
- 第二个单元测试确保我们不能向数据库添加空笔记。
这些单元测试使用Spring MVC测试框架。 如果您不熟悉它,则可能要看一看我的
Spring MVC测试教程 。
第一个单元测试的源代码如下所示:
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.test.context.web.WebAppConfiguration;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;import java.nio.charset.Charset;import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = {WebUnitTestContext.class})
@WebAppConfiguration
public class TodoControllerTest {private static final MediaType APPLICATION_JSON_UTF8 = new MediaType(MediaType.APPLICATION_JSON.getType(), MediaType.APPLICATION_JSON.getSubtype(),Charset.forName("utf8"));private MockMvc mockMvc;@Autowiredprivate ObjectMapper objectMapper;@Autowiredprivate WebApplicationContext webAppContext;@Beforepublic void setUp() {mockMvc = MockMvcBuilders.webAppContextSetup(webAppContext).build();}@Testpublic void add_EmptyTodoEntry_ShouldReturnHttpRequestStatusBadRequest() throws Exception {TodoDTO addedTodoEntry = new TodoDTO();mockMvc.perform(post("/api/todo").contentType(APPLICATION_JSON_UTF8).content(objectMapper.writeValueAsBytes(addedTodoEntry))).andExpect(status().isBadRequest());}
}
第二个单元测试的源代码如下所示:
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.test.context.web.WebAppConfiguration;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;import java.nio.charset.Charset;import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = {WebUnitTestContext.class})
@WebAppConfiguration
public class NoteControllerTest {private static final MediaType APPLICATION_JSON_UTF8 = new MediaType(MediaType.APPLICATION_JSON.getType(), MediaType.APPLICATION_JSON.getSubtype(),Charset.forName("utf8"));private MockMvc mockMvc;@Autowiredprivate ObjectMapper objectMapper;@Autowiredprivate WebApplicationContext webAppContext;@Beforepublic void setUp() {mockMvc = MockMvcBuilders.webAppContextSetup(webAppContext).build();}@Testpublic void add_EmptyNote_ShouldReturnHttpRequestStatusBadRequest() throws Exception {NoteDTO addedNote = new NoteDTO();mockMvc.perform(post("/api/note").contentType(APPLICATION_JSON_UTF8).content(objectMapper.writeValueAsBytes(addedNote))).andExpect(status().isBadRequest());}
}
这两个测试类都声明一个名为APPLICATION_JSON_UTF8的常量。 该常数指定请求的内容类型和字符集。 同样,很明显,在每个测试类中都需要此常量,其中包含用于控制器方法的测试。
这是否意味着我们应该在每个这样的测试类中声明此常量?
没有!
由于以下两个原因,我们应将此常量移至非实例化类:
- 它与多个测试类别相关。
- 将其移到单独的类中使我们可以更轻松地为控制器方法编写新测试并维护现有测试。
让我们创建一个最终的WebTestConstants类,将APPLICATION_JSON_UTF8常量移动到该类,然后向创建的类添加一个私有构造函数。
WebTestConstant类的源代码如下所示:
import org.springframework.http.MediaType;public final class WebTestConstants {public static final MediaType APPLICATION_JSON_UTF8 = new MediaType(MediaType.APPLICATION_JSON.getType(), MediaType.APPLICATION_JSON.getSubtype(),Charset.forName("utf8"));private WebTestConstants() {}
}
完成此操作后,我们可以从测试类中删除APPLICATION_JSON_UTF8常量。 我们的新测试的源代码如下所示:
import com.fasterxml.jackson.databind.ObjectMapper;
import net.petrikainulainen.spring.jooq.config.WebUnitTestContext;
import net.petrikainulainen.spring.jooq.todo.dto.TodoDTO;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.test.context.web.WebAppConfiguration;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;import java.nio.charset.Charset;import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = {WebUnitTestContext.class})
@WebAppConfiguration
public class TodoControllerTest {private MockMvc mockMvc;@Autowiredprivate ObjectMapper objectMapper;@Autowiredprivate WebApplicationContext webAppContext;@Beforepublic void setUp() {mockMvc = MockMvcBuilders.webAppContextSetup(webAppContext).build();}@Testpublic void add_EmptyTodoEntry_ShouldReturnHttpRequestStatusBadRequest() throws Exception {TodoDTO addedTodoEntry = new TodoDTO();mockMvc.perform(post("/api/todo").contentType(WebTestConstants.APPLICATION_JSON_UTF8).content(objectMapper.writeValueAsBytes(addedTodoEntry))).andExpect(status().isBadRequest());}
}
我们刚刚从测试类中删除了重复的代码,并减少了为控制器编写新测试所需的工作。 太酷了吧?
如果我们更改添加到常量类的常量的值,则此更改将影响使用该常量的每个测试用例。 这就是为什么我们应该最小化添加到常量类的常量的数量 。
摘要
现在我们知道,常数可以帮助我们编写干净的测试,并减少编写新测试和维护现有测试所需的工作量。 将本博客文章中给出的建议付诸实践时,我们需要记住以下几点:
- 我们必须给常量和常量类起好名字 。 如果我们不这样做,就不会利用这些技术的全部潜力。
- 在不弄清楚我们想要用该常数实现什么的情况下,我们不应该引入新的常数。 实际情况通常比此博客文章的示例复杂得多。 如果我们在自动驾驶仪上编写代码,很可能会错过针对当前问题的最佳解决方案。
翻译自: https://www.javacodegeeks.com/2014/05/writing-clean-tests-beware-of-magic.html