天天看點

SpringBoot2.x 單元測試

一個 bug 被隐藏的時間越長,修複這個 bug 的代價就越大。

我曾經在 單元測試指南 一文中寫到過單元測試的必要性和 Java 單元測試相關的工具及方法。單元測試能幫助我們在早期就規避、發現和修複很多不易察覺的 bug 和漏洞,而且更能保障後期的需求變動和代碼重構時所帶來的隐患,減少測試成本和維護成本。在 SpringBoot2.x 內建和寫單元測試更加容易了。

建立 SpringBoot2.x 項目

在 start.spring.io 中建立一個自己的 SpringBoot2.x 項目,目前版本

2.1.3

。選出自己需要的一些元件生成項目即可,我這裡選了如下幾個:

  • Web

    : Web項目
  • JPA

    : 資料庫持久層采用Spring Data JPA,友善實用
  • Lombok

    : 可以通過注解大量減少Java中重複代碼的書寫
  • HSQLDB

    : 記憶體資料庫,用來對

    Repository

    層做單元測試

生成之後可以在

pom.xml

中看到 SpringBoot2.x 項目中已經引入了

spring-boot-starter-test

這個啟動元件,包含了幾乎絕大多數測試場景需要的元件。然後通過

mvn clean install

來建構本項目或者直接導入 IDE 開發工具即可。

下面将以對部落格資訊做簡單修改和查詢為示例來說明在 Spring Boot 中如何分别對

DAO

Service

Controller

做單元測試。

DAO 層的單元測試

建立資料庫腳本

DAO 層的測試我這裡采用的是

HSQLDB

的記憶體資料庫,最好準備一些初始化的資料表結構和腳本,當然也可用直接通過官方示例的

JPA

特性和 API 代碼來初始化資料。這裡我還是通過腳本的方式來做,便于統一管理和維護表結構和資料。

src/test

目錄下建立

resources

資源目錄,并在

resources

目錄下建立

db

目錄,在

db

目錄下分别,建立用于管理的表結構檔案(

schema.sql

)和初始化資料檔案(

data.sql

)的 SQL 腳本。

schema.sql

檔案中的内容如下:

-- 建立資料庫表所在的模式 schema.
CREATE SCHEMA test;
commit;

-- 在 test 模式下建立資料庫表.
DROP TABLE IF EXISTS test.t_test_blog;
CREATE TABLE test.t_test_blog (
    c_id varchar(32) NOT NULL,
    c_author varchar(255),
    c_content varchar(255),
    dt_publish_time timestamp(6) NULL,
    c_title varchar(255),
    c_url varchar(255),
    n_status int,
    c_create_user varchar(255),
    dt_create_time timestamp(6) NULL,
    dt_update_time timestamp(6) NULL,
    constraint pk_test_blog primary key(c_id)
);
commit;           

複制

data.sql

檔案中的内容如下:

-- 初始化插入一些部落格資訊資料.
INSERT INTO test.t_test_blog VALUES ('1', '張三', '這是内容', '2019-03-01 00:41:01', 'Spring從入門到精通', 'https://baidu.com', '1', 'tom', '2019-03-01 00:41:33', '2019-03-01 00:41:36');
INSERT INTO test.t_test_blog VALUES ('2', '李四', '這是Mybatis的内容', '2019-03-01 00:41:01', 'Mybatis基礎', 'https://qq.com', '2', 'jack', '2019-03-01 00:41:33', '2019-03-01 00:41:36');
commit;           

複制

增加 yaml 配置檔案及内容

resources

目錄下建立

application-hsqldb.yml

配置檔案,用于存放 HSQLDB 及 JPA 相關的配置資訊,主要配置内容如下:

spring:
  datasource:
    url: jdbc:hsqldb:mem:db_test # 以記憶體資料庫的方式來運作.
    username: root
    password: 123456
    driver-class-name: org.hsqldb.jdbc.JDBCDriver
    platform: hsqldb
    schema: classpath:db/schema.sql
    data: classpath:db/data.sql
  jpa:
    show-sql: true
    hibernate:
      ddl-auto: none # 這裡沒用 JPA 的自動生成表結構等功能,你可以視自己的具體情況來開啟.
    generate-ddl: false # 啟動時是否初始化資料庫.           

複制

準備實體 POJO 和 DAO 層 Repository 類

部落格資訊的實體 POJO 類如下:

package com.blinkfox.springbootsample.pojo;

import java.util.Date;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.Table;

import lombok.Getter;
import lombok.Setter;
import lombok.experimental.Accessors;

/**
 * 部落格實體.
 *
 * @author blinkfox on 2019-2-26.
 */
@Getter
@Setter
@Accessors(chain = true)
@Entity
@Table(name = "t_test_blog", schema = "test")
public class Blog {

    /**
     * ID.
     */
    @Id
    @Column(name = "c_id")
    private String id;

    /**
     * 作者.
     */
    @Column(name = "c_author")
    private String author;

    /**
     * 标題.
     */
    @Column(name = "c_title")
    private String title;

    /**
     * 内容.
     */
    @Column(name = "c_content")
    private String content;

    /**
     * 釋出時間.
     */
    @Column(name = "dt_publish_time")
    private Date publishTime;

    /**
     * 連結位址.
     */
    @Column(name = "c_url")
    private String url;

    /**
     * 狀态.
     */
    @Column(name = "n_status")
    private Integer status;

    /**
     * 建立使用者.
     */
    @Column(name = "c_create_user")
    private String createUser;

    /**
     * 建立時間.
     */
    @Column(name = "dt_create_time")
    private Date createTime;

    /**
     * 最後更新時間.
     */
    @Column(name = "dt_update_time")
    private Date updateTime;

}           

複制

下面是

BlogRepository

中的一個簡單的自定義

@Query

查詢,當然你也可以采用名稱的規則來寫本查詢,我這裡為了做示例,使用了

@Query

查詢。

package com.blinkfox.springbootsample.repository;

import com.blinkfox.springbootsample.pojo.Blog;

import java.util.List;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;

/**
 * BlogRepository.
 *
 * @author blinkfox on 2019-02-27.
 */
@Repository
public interface BlogRepository extends JpaRepository<Blog, String> {

    @Query("SELECT b FROM Blog AS b WHERE b.title like 'Spring%'")
    List<Blog> querySpringBlogs();

}           

複制

BlogRepository 的單元測試

然後在 Intellij IDEA 中通過

Ctrl + Shift + T

來為

BlogRepository

生成它對應的單元測試類

BlogRepositoryTest

package com.blinkfox.springbootsample.repository;

import com.blinkfox.springbootsample.pojo.Blog;

import java.util.List;
import java.util.Optional;
import javax.annotation.Resource;

import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.junit4.SpringRunner;

/**
 * BlogRepositoryTest.
 *
 * @author blinkfox on 2019-03-01.
 */
@RunWith(SpringRunner.class)
@ActiveProfiles("hsqldb")
@DataJpaTest
public class BlogRepositoryTest {

    @Resource
    private BlogRepository blogRepository;

    /**
     * 測試新增部落格的情況.
     */
    @Test
    public void save() {
        String id = "newblogId";
        String title = "Java 從入門到放棄";
        blogRepository.save(new Blog().setId(id).setTitle(title));

        Optional<Blog> blogOptional = blogRepository.findById(id);
        Assert.assertTrue(blogOptional.isPresent() && title.equals(blogOptional.get().getTitle()));
    }

    /**
     * 測試查詢所有 Spring 相關的部落格資訊.
     */
    @Test
    public void querySpringBlogs() {
        List<Blog> blogs = blogRepository.querySpringBlogs();
        Assert.assertEquals(1, blogs.size());
        Assert.assertEquals("Spring從入門到精通", blogs.get(0).getTitle());
    }

}           

複制

這樣就完成了 DAO 層代碼的測試,以上程式主要依托于記憶體資料庫 HSQLDB 和 Spring Data JPA。

Service 層的單元測試

實際開發過程中,Service 層中的類依賴了 DAO 層中的類或其他 Service 類。為了隔離對其他 Service 類或 DAO 層中的類的依賴,隻測試本 Service 類中的方法邏輯,就需要 Mock 資料和做打樁等操作。Spring Boot 中原生內建了 Mockito,可以非常友善我們對 Java 代碼做單元測試。

建立 BlogService 類

package com.blinkfox.springbootsample.service;

import com.blinkfox.springbootsample.pojo.Blog;
import com.blinkfox.springbootsample.repository.BlogRepository;

import java.util.List;
import java.util.Optional;
import javax.annotation.Resource;

import lombok.extern.slf4j.Slf4j;

import org.springframework.stereotype.Service;

/**
 * BlogService.
 *
 * @author blinkfox on 2019-03-01.
 */
@Slf4j
@Service
public class BlogService {

    @Resource
    private BlogRepository blogRepository;

    /**
     * 查詢所有 Spring 相關的部落格資訊.
     *
     * @return 部落格資訊
     */
    public List<Blog> getSpringBlogs() {
        log.info("進入了擷取 Spring 相關部落格的 Service 方法.");
        return blogRepository.querySpringBlogs();
    }

    /**
     * 根據部落格ID來修改該部落格的名稱.
     *
     * @param id 部落格ID
     * @param title 部落格标題
     */
    public void modifyTitileById(String id, String title) {
        Optional<Blog> blogOptional = blogRepository.findById(id);
        if (!blogOptional.isPresent()) {
            log.warn("需要修改名稱的部落格不存在,id為【{}】請檢查!", id);
            return;
        }

        blogRepository.save(blogOptional.get().setTitle(title));
    }

}           

複制

BlogService 的單元測試

通過

BlogService

可以生成和書寫出其對應的單元測試類和測試方法,代碼如下:

package com.blinkfox.springbootsample.service;

import com.blinkfox.springbootsample.pojo.Blog;
import com.blinkfox.springbootsample.repository.BlogRepository;

import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.junit.MockitoJUnitRunner;

/**
 * BlogServiceTest.
 *
 * @author blinkfox on 2019-03-01.
 */
@RunWith(MockitoJUnitRunner.class)
public class BlogServiceTest {

    @Mock
    private BlogRepository blogRepository;

    @InjectMocks
    private BlogService blogService;

    /**
     * 測試service層中擷取Spring相關部落格的方法.
     */
    @Test
    public void getSpringBlogs() {
        // 構造需要傳回的部落格資訊集合資料.
        Blog blog = new Blog()
                .setId("1")
                .setTitle("Spring Action");
        List<Blog> blogList = new ArrayList<>();
        blogList.add(blog);

        Mockito.when(blogRepository.querySpringBlogs())
                .thenReturn(blogList);
        List<Blog> blogs = blogService.getSpringBlogs();

        // 斷言驗證查詢到的資料.
        Assert.assertEquals(1, blogs.size());
        Assert.assertEquals("Spring Action", blog.getTitle());
    }

    /**
     * 測試根據部落格ID來修改該部落格的名稱成功時的情況.
     */
    @Test
    public void modifyTitileById() {
        // Mock 相關資料和類方法的行為.
        String id = "1";
        Mockito.when(blogRepository.findById(id))
                .thenReturn(Optional.of(new Blog()));
        Mockito.when(blogRepository.save(Mockito.any()))
                .thenReturn(new Blog());

        blogService.modifyTitileById(id, "算法導論");

        // 驗證 blogRepository.save(s) 方法被調用過一次.
        Mockito.verify(blogRepository).save(Mockito.any());
    }

    /**
     * 測試根據部落格ID來修改該部落格的名稱失敗時的情況.
     */
    @Test
    public void modifyTitileByIdWithFailure() {
        // Mock 未根據 ID 找到對應的部落格資訊的情況.
        String id = "1";
        Mockito.when(blogRepository.findById(id))
                .thenReturn(Optional.ofNullable(null));

        blogService.modifyTitileById(id, "算法導論");

        // 驗證 blogRepository.save(s) 方法并沒有被調用過.
        Mockito.verify(blogRepository, Mockito.never()).save(Mockito.any());
    }

}           

複制

注意:這裡的

@RunWith

采用的是 Mocktio 提供的

MockitoJUnitRunner

這樣就完成了 Service 層的單元測試,也是我們業務開發中需要重點關注和測試業務邏輯的一層。

Controller 層的單元測試

Controller 層測試的重點是測試接口是否能正常工作。可以用到 Spring Boot 中提供的

@WebMvcTest

注解來模拟 Web 層的單元測試。當然,也需要通過 Mock 的方式類隔離對 Service 層各個類的依賴影響。

建立 BlogController 類

package com.blinkfox.springbootsample.controller;

import com.blinkfox.springbootsample.pojo.Blog;
import com.blinkfox.springbootsample.service.BlogService;

import java.util.List;
import javax.annotation.Resource;

import lombok.extern.slf4j.Slf4j;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

/**
 * BlogController.
 *
 * @author blinkfox on 2019-02-28.
 */
@Slf4j
@RequestMapping("/blogs")
@RestController
public class BlogController {

    @Resource
    private BlogService blogService;

    /**
     * 擷取所有 Spring 相關的部落格資訊.
     *
     * @return Spring相關的部落格資訊
     */
    @GetMapping
    public ResponseEntity<List<Blog>> getSpringBlogs() {
        return ResponseEntity.ok(blogService.getSpringBlogs());
    }

    /**
     * 根據部落格ID修改部落格名稱.
     *
     * @param id 部落格ID
     * @param title 部落格标題
     * @return 空
     */
    @PatchMapping("/{id}")
    public ResponseEntity<Void> modifyTitileById(@PathVariable("id") String id,
            @RequestParam("title") String title) {
        try {
            blogService.modifyTitileById(id, title);
            log.info("修改部落格名稱成功.");
            return new ResponseEntity<>(HttpStatus.OK);
        } catch (Exception e) {
            log.error("修改部落格名稱出錯,id為【{}】.", id);
            return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
        }
    }

}           

複制

BlogController 的單元測試

通過

BlogController

可以生成和書寫出其對應的單元測試類和測試方法,代碼如下:

package com.blinkfox.springbootsample.controller;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import com.blinkfox.springbootsample.service.BlogService;

import java.util.ArrayList;
import javax.annotation.Resource;

import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mockito;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;

/**
 * BlogControllerTest.
 *
 * @author blinkfox on 2019-03-02.
 */
@RunWith(SpringRunner.class)
@WebMvcTest(BlogController.class)
public class BlogControllerTest {

    @Resource
    private MockMvc mockMvc;

    @MockBean
    private BlogService blogService;

    /**
     * 測試擷取所有 Spring 相關的部落格資訊.
     *
     * @throws Exception 異常
     */
    @Test
    public void getSpringBlogs() throws Exception {
        Mockito.when(blogService.getSpringBlogs())
                .thenReturn(new ArrayList<>());

        this.mockMvc.perform(get("/blogs"))
                .andExpect(status().isOk());
    }

    /**
     * 測試修改部落格标題成功時的情況.
     *
     * @throws Exception 異常
     */
    @Test
    public void modifyTitileById() throws Exception {
        Mockito.doNothing()
                .when(blogService).modifyTitileById(Mockito.anyString(), Mockito.anyString());
        this.mockMvc.perform(patch("/blogs/1?title=Spring實戰"))
                .andExpect(status().isOk());
    }

    /**
     * 測試修改部落格标題失敗時的情況.
     *
     * @throws Exception 異常
     */
    @Test
    public void modifyTitileByIdWithException() throws Exception {
        Mockito.doThrow(RuntimeException.class)
                .when(blogService).modifyTitileById(Mockito.anyString(), Mockito.anyString());
        this.mockMvc.perform(patch("/blogs/1?title=Spring實戰"))
                .andExpect(status().is5xxServerError());
    }

}           

複制

以上就完成了對 Controller 層的單元測試。

總結

在 Spring Boot 中做單元測試的将會非常容易。上面隻是 Spring Boot 中提供的部分方式,Spring Boot 文檔 中還有其他更多的測試場景和測試方法供你去參考和使用。