在 SpringBoot 中进行单元测试

1. 单元测试概述

最小的可测试的单元就是单元测试,可以是一个函数,一个类。

1.1 为什么需要单元测试

  • 节省测试时间
    测试一个最小单元是否有逻辑问题,无需到测试环境中去(比如创建数据库,创建文件等一些麻烦且耗时的操作)。
  • 防止回归
    我们写的东西不会破坏已有的功能,确保我们的修改和设计不会破坏已有的功能。比如你修改了一个类,这个类会被很多其他的类使用,这样可能会造成一些问题。所以单元测试在代码重构上相当重要。
  • 提高代码质量
  • 保证行为的正确性
    比如你输入了一个异常的数,就需要抛异常。

1.2 什么是好的单元测试

  • 快速:对成熟项目进行数千次单元测试,这很常见。应花非常少的时间来运行单元测试。
  • 独立:单元测试是独立的,可以单独运行,并且不依赖文件系统或数据库等任何外部因素。如果真的连数据库我们叫它集成测试。
  • 可重复:运行单元測试的结果应该保持一致,也就是说,如果在运行期间不更改任何内容,总是返回相同的结果。
  • 自检查:測试应该能够在没有任何人工交互的情况下,自动检测测试是否通过。

1.3 测试用例命名

理想情况下,包括一下三个部分:

  • 要测试方法的名称
  • 测试的方案
  • 调用方案时的预期行为

我们至少包含前两条,比如:
testGetUserInfoByUserIdWithInvalidUserId()
testGetUserInfoByUserId() -> happy case

1.4 AAA(Arrange, Act, Assert) pattern

Arrange-Act-Assert是单元测试的常见模式
包括三个操作:

  • Arrange:安排好所有先要条件和输入,根据需要进行创建和设置。
  • Act:对要测试的对象或者方法进行调用。
  • Assert:断言结果是否按预期进行。

2. 在 SpringBoot中进行单元测试

我们从文档中找到依赖,发现依赖中有exclusion,这是因为使用JUnit 5, 就必须exclude JUnit 4。


    org.springframework.boot
    spring-boot-starter-test
    test
    
        
            org.junit.vintage
            junit-vintage-engine
        
    

2.1 一些用于快速入门的简单例子

public class AddManager {
    public int add(int number) {
        return number += 1;
    }
}
public class AddManagerTest {
    private AddManager addManager = new AddManager();

    @Test
    void testAdd() {
        // Arrange
        int number = 100;

        // Act
        int result = addManager.add(number);

        // Assert
        assertEquals(101, result);
    }
}

如果将101改成80,则会出现以下的报错

org.opentest4j.AssertionFailedError: 
Expected :80
Actual   :101

如果在每个方法执行之前我们要执行一些操作(比如初始化),就可以使用@BeforeEach注解。同理还有@AfterEach(主要用于一些拆卸操作)。

public class AddManagerTest {
    private AddManager addManager;

    @BeforeEach
    void setup() {
        addManager = new AddManager();
    }

    @AfterEach
    void teardown() {
        // .....
    }
    // ......
}

下面是一个真实的例子。我们要去测试Manager层的UserInfoManager类。以下是一些解释。

  • UserInfoMapper 是使用 MyBatis 写一个接口,用于查询数据库并返回用户信息,在源码中没有该接口的实现类。
  • 其他所有的类都有对应的实现类,可以拿来直接用。
  • 如果用户为空,则抛出 ResourceNotFoundException 异常。

该例子的所有类依赖关系如下所示。

UserInfoManage //有实现类 UserInfoManageImpl
└── UserInfoDao //有实现类 UserInfoDaoImpl
    └── userInfoMapper //无实现类,只是个接口
└── UserInfoP2CConverter //有实现类 UserInfoP2CConverter

那么问题来了,UserInfoMapper是个接口,无法直接生成实例,而 UserInfoDao 又依赖 UserInfoMapper,怎么去创造这个实例呢?方法其实很简单我们在test/java/com/.../utils下创建一些测试会使用的到的工具类。因为 UserInfoMapper 的作用就是返回用户信息,我们可以直接返回一些假的数。具体地,我们创建 UserInfoMapperTestImpl 类去继承 UserInfoMapper 作为它的一个实现类,完成它的功能。

public class UserInfoMapperTestImpl implements UserInfoMapper {

    @Override
    public UserInfo getUserInfoById(long id) {
        return id > 0 ? UserInfo.builder()
                .username("admin")
                .password("admin")
                .createTime(LocalDate.now())
                .id(1L)
                .build() : null;
    }
}

如下是单元测试的代码。在 Assert 阶段我们分别使用了 JUnit 5 和 AssertJ 中的不同方法去实现,AssertJ看其来更加清晰。一般地,如果测试一些会抛出异常的函数,我们将 Act 和 Assert 写在一起,用 assertThrows 方法。

public class UserInfoManagerTest2 {
    private UserInfoManager userInfoManager;

    @BeforeEach
    void setup() {
        UserInfoP2CConverter userInfoP2CConverter = new UserInfoP2CConverter();
        UserInfoMapper userInfoMapper = new UserInfoMapperTestImpl();
        UserInfoDao userInfoDao = new UserInfoDaoImpl(userInfoMapper);
        userInfoManager = new UserInfoManagerImpl(userInfoDao, userInfoP2CConverter);
    }

    @Test
    void testGetUserInfoById() {
        // Arrange
        long userId = 1L;

        // Act
        UserInfo userInfo = userInfoManager.getUserInfoById(userId);

        // Assert with JUnit 5
        assertEquals("admin", userInfo.getUsername());
        assertEquals("admin", userInfo.getPassword());
        assertEquals(userId, userInfo.getId());

        // Assert With AssertJ
        assertThat(userInfo).isNotNull()
                .hasFieldOrPropertyWithValue("id", userId)
                .hasFieldOrPropertyWithValue("username", "admin")
                .hasFieldOrPropertyWithValue("password", "admin");
    }

    @Test
    void testGetUserInfoByIdWithInvalidUserId() {
        // Arrange
        long userId = -1L;

        // Act & Assert
        assertThrows(ResourceNotFoundException.class, () -> userInfoManager.getUserInfoById(userId));
    }
}

2.2 引入 Mockito 完善单元测试

上文的依赖还不够复杂,如果依赖非常的复杂,我们难道要一个个造 testImp(test place holder) 吗?是否可以直接模拟这些复杂函数的行为呢?比如:

when xxx case 
call UserInfoDao.getUserInfoById()
return xxx value or throw xxx exception

2.2.1 Mockito的简单使用

Mockito 就是完成以上需求的,以下是 Mockito 的简单使用。

 LinkedList mockedList = mock(LinkedList.class);

 //stubbing
 when(mockedList.get(0)).thenReturn("first");
 when(mockedList.get(1)).thenThrow(new RuntimeException());

 //following prints "first"
 System.out.println(mockedList.get(0));

 //following throws runtime exception
 System.out.println(mockedList.get(1));

 //following prints "null" because get(999) was not stubbed
 System.out.println(mockedList.get(999));

 //Although it is possible to verify a stubbed invocation, usually it's just redundant
 //If your code cares what get(0) returns, then something else breaks (often even before verify() gets executed).
 //If your code doesn't care what get(0) returns, then it should not be stubbed.
 verify(mockedList).get(0);

Mockito 以 equals() 方法验证参数。有时,当需要额外的灵活性时,可以使用参数匹配器。参数匹配器,只有两种形式 anyX() 或是 eq()。

 //可以用anyInt(),表示任何int
 when(mockedList.get(anyInt())).thenReturn("element");

 //following prints "element"
 System.out.println(mockedList.get(999));

 //我们也可以在verify的时候用参数匹配
 verify(mockedList).get(anyInt());

//如果你正在使用的是参数匹配器,所有参数都必须由匹配器提供。
verify(mock).someMethod(anyInt(), anyString(), eq("third argument"));
//above is correct - eq() is also an argument matcher

verify(mock).someMethod(anyInt(), anyString(), "third argument");
//above is incorrect - exception will be thrown because third argument is given without an argument matcher.

还可以验证多次调用的函数

verify(mockedList, times(3)).add("three times");
verify(mockedList, never()).add("never happened");
verify(mockedList, atMostOnce()).add("once");
verify(mockedList, atLeastOnce()).add("three times");
verify(mockedList, atLeast(2)).add("three times");
verify(mockedList, atMost(5)).add("three times");

subbing 一些抛出异常的函数

doThrow(new RuntimeException()).when(mockedList).clear();

subbing 的两种写法

// doNothing doThrow 只能这样写
doReturn("Hello World").when(mockList).get(1)
when(mockedList.get(1)).thenReturn("Hello World");

2.2.2 使用 Mockito 改写例子

除了用 mock() 的方式外,我们还可以使用注解 @Mock 的方式进行mock,值得注意的是使用注解的方式必须初始化MockitoAnnotations.initMocks(this)(这是老版本的写法,目前已经废弃,这里提供新的写法)。可以看到使用了 Mockito 后我们无需再为一些接口创建实现类了,我们更加注重需要 mock 的这个函数到底该完成了什么样的功能。

public class UserInfoManagerTest {
    private UserInfoManager userInfoManager;

    @Mock
    private UserInfoDao userInfoDao;

    @BeforeEach
    void setup() {
        MockitoAnnotations.initMocks(this);
        userInfoManager = new UserInfoManagerImpl(userInfoDao, new UserInfoP2CConverter());
    }

    @Test
    public void testGetUserInfoById() {
        long userId = 1L;
        String username = "admin";
        String password = "admin";
        LocalDate createTime = LocalDate.now();

        val userInfo = UserInfo.builder()
                .id(userId)
                .username(username)
                .password(password)
                .createTime(createTime)
                .build();

        doReturn(userInfo).when(userInfoDao).getUserInfoById(userId);

        val result = userInfoManager.getUserInfoById(userId);
        assertEquals(userId, result.getId());
        assertEquals("admin", result.getUsername());
        assertEquals("admin", result.getPassword());

        verify(userInfoDao).getUserInfoById(eq(userId));
    }

    @Test
    public void testGetUserInfoByIdWithInvalidParameter() {
        long userId = -1L;

        doReturn(null).when(userInfoDao).getUserInfoById(userId);

        assertThrows(ResourceNotFoundException.class, () -> userInfoManager.getUserInfoById(userId));

        verify(userInfoDao).getUserInfoById(eq(userId));
    }
}

controller层的测试有些不一样。按我们前面的做法,在arrange的时候,我们直接 new 一个 controller 的实例用于测试,这样是不能测试 MVC 的一些特性(返回的一些东西)。这里我们先举两个例子。
例1. 假设有这样一个GreetingController。

@RestController
public class GreetingController {
    @GetMapping("/greeting")
    public String greeting(@RequestParam("name") String name) {
        return "Hello " + name;
    }
}

Spring 自带了用于测试 MVC 的 MockMvc 类。生成 MockMvc 需要用MockMvcBuilders 的 standaloneSetup 方法。MockMvc 的 perform 方法用于执行一个请求并返回一个 ResultActions 类,该类型允许对结果链式操作操作,例如断言期望。考虑到 java 中有很多的 get、status、content 方法,这里给出具体的方法(千万不要 import 错了):
MockMvcRequestBuilders.get()
MockMvcResultMatchers.status()
MockMvcResultMatchers.content()

public class GreetingControllerTest {
    private MockMvc mockMvc;

    @BeforeEach
    public void setup() {
        mockMvc = MockMvcBuilders.standaloneSetup(new GreetingController()).build();
    }

    @Test
    void testGreeting() throws Exception {
        mockMvc.perform(get("/greeting").param("name", "admin"))
                .andExpect(status().isOk())
                .andExpect(content().string("Hello admin"));
    }
}

例2. 另有一个 UserController,如果接受到的 userId 小于等于0则抛出 InvalidParameterException,而我们用 @RestControllerAdvice 对异常进行了统一处理(在 SpringBoot 中的异常处理)。我们在 MockMvcBuilders 创建MockMvc 时设置Controller的增强(setControllerAdvice)。

@RestController
@Slf4j
public class UserController {
    private final UserInfoManager userInfoManager;
    private final UserInfoC2SConverter userInfoC2SConverter;

    @Autowired
    public UserController(UserInfoManager userInfoManager, UserInfoC2SConverter userInfoC2SConverter) {
        this.userInfoManager = userInfoManager;
        this.userInfoC2SConverter = userInfoC2SConverter;
    }

    @GetMapping("/{id}")
    public ResponseEntity getUserInfoById(@PathVariable("id") long id) {
        if (id <= 0L) {
            throw new InvalidParameterException(String.format("User id %s is invalid", id));
        }
        val userInfo = userInfoManager.getUserInfoById(id);
        return ResponseEntity.ok(userInfoC2SConverter.convert(userInfo));
    }
}

具体写法如下所示,大同小异。亲测,如果不设置Conroller增强,则会报奇怪的错误。

public class UserControllerTest {                                                                                                                                                                                                                        
    private MockMvc mockMvc;

    @Mock                                                                                                                                                                                                                                                
    public UserInfoManager userInfoManager;

    @BeforeEach                                                                                                                                                                                                                                          
    void setup() {                                                                                                                                                                                                                                       
        MockitoAnnotations.initMocks(this);                                                                                                                                                                                                              
        mockMvc = MockMvcBuilders.standaloneSetup(new UserController(userInfoManager, new UserInfoC2SConverter()))                                                                                                                                       
                .setControllerAdvice(new GlobalExceptionHandler())                                                                                                                                                                                       
                .build();

    }

    @AfterEach                                                                                                                                                                                                                                           
    void teardown() {                                                                                                                                                                                                                                    
        reset(userInfoManager);                                                                                                                                                                                                                          
    }

    @Test                                                                                                                                                                                                                                                
    void testGetUserInfoById() throws Exception {                                                                                                                                                                                                        
        // Arrange                                                                                                                                                                                                                                       
        val userId = 100L;                                                                                                                                                                                                                               
        val username = "admin";                                                                                                                                                                                                                          
        val password = "admin";

        val userInfoInCommon = com.lazyben.accounting.model.common.UserInfo.builder()                                                                                                                                                                    
                .id(userId)                                                                                                                                                                                                                              
                .username(username)                                                                                                                                                                                                                      
                .password(password)                                                                                                                                                                                                                      
                .build();

        doReturn(userInfoInCommon).when(userInfoManager).getUserInfoById(userId);

        // Act & Assert                                                                                                                                                                                                                                  
        mockMvc.perform(MockMvcRequestBuilders.get("/" + userId))                                                                                                                                                                                        
                .andExpect(content().string("{\"id\":100,\"username\":\"admin\",\"password\":null}"))                                                                                                                                                    
                .andExpect(content().contentType("application/json"))                                                                                                                                                                                    
                .andExpect(status().isOk());

        verify(userInfoManager).getUserInfoById(anyLong());                                                                                                                                                                                              
    }

    @Test                                                                                                                                                                                                                                                
    void testGetUserInfoByIdWithInvalidUserId() throws Exception {                                                                                                                                                                                       
        // Arrange                                                                                                                                                                                                                                       
        val userId = -1L;

        doThrow(new InvalidParameterException(String.format("User %s is not found", userId)))                                                                                                                                                            
                .when(userInfoManager)                                                                                                                                                                                                                   
                .getUserInfoById(anyLong());

        // Act & Assert                                                                                                                             {"code":"INVALID_PARAMETER","message":"User id -1 is invalid","statusCode":400,"errorType":"Client"} 
        mockMvc.perform(MockMvcRequestBuilders.get("/" + userId))                                                                                                                                                                                        
                .andExpect(status().is4xxClientError())                                                                                                                                                                                                  
                .andExpect(content().contentType("application/json"))                                                                                                                                                                                    
                .andExpect(content().string("{\"code\":\"INVALID_PARAMETER\",\"message\":\"User id -1 is invalid\",\"statusCode\":400,\"errorType\":\"Client\"}"));                                                                                      
    }                                                                                                                                                                                                                                                    
}

你可能感兴趣的:(在 SpringBoot 中进行单元测试)