在 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。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
    <exclusions>
        <exclusion>
            <groupId>org.junit.vintage</groupId>
            <artifactId>junit-vintage-engine</artifactId>
        </exclusion>
    </exclusions>
</dependency>

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\"}"));                                                                                      
    }                                                                                                                                                                                                                                                    
}

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,185评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,445评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,684评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,564评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,681评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,874评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,025评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,761评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,217评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,545评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,694评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,351评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,988评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,778评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,007评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,427评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,580评论 2 349

推荐阅读更多精彩内容