一個 bug 被隐藏的時間越長,修複這個 bug 的代價就越大。
我曾經在 單元測試指南 一文中寫到過單元測試的必要性和 Java 單元測試相關的工具及方法。單元測試能幫助我們在早期就規避、發現和修複很多不易察覺的 bug 和漏洞,而且更能保障後期的需求變動和代碼重構時所帶來的隐患,減少測試成本和維護成本。在 SpringBoot2.x 內建和寫單元測試更加容易了。
建立 SpringBoot2.x 項目
在 start.spring.io 中建立一個自己的 SpringBoot2.x 項目,目前版本
2.1.3
。選出自己需要的一些元件生成項目即可,我這裡選了如下幾個:
-
: Web項目Web
-
: 資料庫持久層采用Spring Data JPA,友善實用JPA
-
: 可以通過注解大量減少Java中重複代碼的書寫Lombok
-
: 記憶體資料庫,用來對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());
}
}
複制
注意:這裡的采用的是 Mocktio 提供的
@RunWith
。
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 文檔 中還有其他更多的測試場景和測試方法供你去參考和使用。