Spring Boot的單元測試
單元/整合測試
軟體開發中,避免不了是需要寫些測試程式。而測試程式不一定都是由QA團隊來做
單元測試就是必須由開發者本身來寫出測試程式,能被測試的程式才能算是有品質的程式碼。
本文將會探討在Spring Boot中如何寫單元/整合測試。
單元測試
根據維基百科定義:單元測試(英語:Unit Testing)又稱為模組測試,是針對程式模組(軟體設計的最小單位)來進行正確性檢驗的測試工作。程式單元是應用的最小可測試部件。在程序化編程中,一個單元就是單個程式、函式、過程等;對於物件導向程式設計,最小單元就是方法,包括基礎類別(超類)、抽象類、或者衍生類別(子類別)中的方法。
整合測試
根據維基百科定義:整合測試又稱組裝測試,即對程序模塊採用一次性或增值方式組裝起來,對系統的接口進行正確性檢驗的測試工作。整合測試一般在單元測試之後、系統測試之前進行。實踐表明,有時模塊雖然可以單獨工作,但是並不能保證組裝起來也可以同時工作。
另外根據<<單元測試的藝術>>這本書提到:整合測試是對一個工作單元進行測試,而這個測試對被測試的單元並沒有完全的控制,而是使用該單元一個或多個真實依賴的相依物件,例如時間、網路、資料庫、執行緒或亂數產生器等等。
也就是整合測試必須實際上連結資料庫或第三方服務。
Spring Boot的單元/整合測試
本文章會提供一個Spring Boot的專案,並開發兩隻RESTFul API,分別是:
- POST /employee/{name} - 儲存員工名稱。
- GET /employee/{name} - 取得員工資料,回應格式如下:
{"id":1,"name":"randy"}
專案結構如下圖:
Controller
負責請求轉發,接受傳過來的參數,傳給Service處理,接到返回值傳送至前端呈現
本文利用Spring Boot方式開發,完整Controller程式碼如下:
package com.randy.company.controller;
import com.randy.company.dao.entity.Employee;
import com.randy.company.service.EmployeeService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/employee")
public class EmployeeController {
@Autowired
private EmployeeService employeeService;
@GetMapping("/{name}")
public Employee getName(@PathVariable String name){
Employee employee = employeeService.getEmployeeByName(name);
return employee;
}
@PostMapping("/{name}")
public void save(@PathVariable String name){
employeeService.saveEmployee(name);
}
}
EmployeeController提供兩個方法分別為"getName"及"save"用來取得跟儲存資料。
-Service:業務邏輯層
此層主要用來撰寫業務邏輯並以及對資料庫的操作,完整程式碼如下:
package com.randy.company.service.impl;
import com.randy.company.dao.entity.Employee;
import com.randy.company.dao.repositories.EmployeeRepository;
import com.randy.company.service.EmployeeService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class EmployeeServiceImpl implements EmployeeService {
@Autowired
private EmployeeRepository employeeRepository;
@Override
public Employee getEmployeeByName(String name) {
return employeeRepository.findByName(name);
}
@Override
public void saveEmployee(String name) {
Employee employee = new Employee(name);
employeeRepository.save(employee);
}
}
-getEmployeeByName:取得員工資料
-saveEmployee:儲存員工資料
-Dao:資料持久層,與資料庫做互動
Entity
entity類別對應至資料庫的table,類別中屬性分別對應資料庫中table的欄位。
package com.randy.company.dao.entity;
import javax.persistence.*;
import javax.validation.constraints.Size;
@Entity
@Table(name = "person")
public class Employee {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
@Size(min = 3, max = 20)
private String name;
public Employee() {
}
public Employee(@Size(min = 3, max = 20) String name) {
this.name = name;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
JPA Repository
Spring Data JPA提供相關Repository,只需繼承後即可有簡易的CRUD操作DB的功能。
package com.randy.company.dao.repositories;
import com.randy.company.dao.entity.Employee;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface EmployeeRepository extends JpaRepository<Employee, Long> {
public Employee findByName(String name);
}
DAO層的整合測試
DAO層主要是跟資料庫做互動,包含了SQL語法因此採用整合測試實際的去連結資料庫,但因為是測試程式可能會做一次至多次的測試,不可能每次都建置新的資料庫來做測試,本案例採用了H2 Database是一種Memory的DB,可以在應用程式啟動時在記憶體內建置資料庫,應用程式結束後即清除,完整測試案例如下
DaoConfig
package integration.com.randy.company.config;
import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
@Configuration
@EntityScan(basePackages ={"com.randy.company.dao.entity"})
@EnableJpaRepositories(basePackages = "com.randy.company.dao.repositories")
public class DaoConfig {
}
-@EntityScan用來掃描和發現指定package及其sub package中的Entity定義
-@EnableJpaRepositories用來掃描和發現指定package及其sub package中的Repository定義
package integration.com.randy.company.repositories;
import com.randy.company.dao.entity.Employee;
import com.randy.company.dao.repositories.EmployeeRepository;
import integration.com.randy.company.config.DaoConfig;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import static org.junit.Assert.assertEquals;
@RunWith(SpringJUnit4ClassRunner.class)
@DataJpaTest
@ContextConfiguration(classes = DaoConfig.class)
public class EmployeeRepositoryIntegrationTest {
@Autowired
private EmployeeRepository employeeRepository;
private Employee randy = new Employee("randy");
@Before
public void init(){
employeeRepository.save(randy);
}
// write test cases here
@Test
public void whenFindByName_thenReturnEmployee() {
Employee employee = employeeRepository.findByName("randy");
assertEquals("randy", employee.getName());
}
}
-@RunWith(SpringJUnit4ClassRunner.class):讓測試運行於Spring測試環境。
-@DataJpaTest使用的是Memory DB進行測試,在此用H2 Database。
-@ContextConfiguration:Spring整合JUnit4測試時,使用註解引入多個配置文件。
-32~36行即為測試案例,測試內容為從資料庫中取出Name為"randy"的員工資料,35行判斷取出資料是否正確。
Service層的單元測試
與DAO層不同的是Service並不直接與DB做實際上的溝通,因此會採用Mock(單元測試的Test Double)的方式去模擬出DAO的物件。
package unit.com.randy.company.service;
import com.randy.company.Application;
import com.randy.company.dao.entity.Employee;
import com.randy.company.dao.repositories.EmployeeRepository;
import com.randy.company.service.EmployeeService;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit4.SpringRunner;
import static org.junit.Assert.*;
import static org.mockito.Mockito.when;
@RunWith(SpringRunner.class)
@SpringBootTest(classes = Application.class)
public class EmployeeServiceImplTest {
@Autowired
private EmployeeService employeeService;
@MockBean
private EmployeeRepository employeeRepository;
@Test
public void getEmployeeByName() {
when(employeeRepository.findByName("randy")).thenReturn(new Employee("randy"));
Employee employee = employeeService.getEmployeeByName("randy");
assertEquals("randy", employee.getName());
}
}
-@SpringBootTest:獲取啟動主程式、加載配置,確定啟用Spring Boot
-24~25行利用mock技術模擬出對資料庫連結的物件
-30行表示當碰到employeeRepository.findByName並帶入"randy"參數時,則回傳帶有"randy"當建構子的Employee物件
-32行為EmployeeService的getEmployeeByName方法使用方式並回傳30行建立的Employee物件
Controller的單元測試
package unit.com.randy.company.controller;
import com.randy.company.Application;
import com.randy.company.controller.EmployeeController;
import com.randy.company.dao.entity.Employee;
import com.randy.company.service.EmployeeService;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import static org.hamcrest.Matchers.is;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup;
@RunWith(SpringRunner.class)
@SpringBootTest(classes = Application.class)
public class EmployeeControllerTest {
private MockMvc mockMvc;
@Autowired
private EmployeeController employeeController;
@MockBean
private EmployeeService employeeService;
private Employee employee;
@Before
public void setup() throws Exception {
this.mockMvc = standaloneSetup(this.employeeController).build();// Standalone context
employee = new Employee("randy");
}
@Test
public void testGetAPI() throws Exception {
//Mocking
when(this.employeeService.getEmployeeByName("randy")).thenReturn(employee);
ResultActions resultActions = mockMvc.perform(get("/employee/randy").contentType(MediaType.APPLICATION_JSON))
.andDo(print())
.andExpect(status().isOk())
.andExpect(jsonPath("name", is("randy")));
}
}
-30行:MockMvc實現了對Http請求的模擬,能夠直接使用網路的形式,轉換到Controller的呼叫,這樣可以使得測試速度快、不依賴網路環境,而且提供了一套驗證的工具,這樣可以使得請求的驗證統一而且很方便。
-40~45行: @Before表示在測試案例前的相關初始化,standaloneSetup表示通過參數指定一組控制器,這樣就不需要從上下文獲取了。
-50行:利用mock技術模擬出對Service的物件,當呼叫EmployeeServcie的getEmployeeByName時回傳自定義的Employee物件。
-52行:perform(request)此方法為要做一個請求的建立,這是一個模擬請求的方式,get(url)為要去request(請求)的連結
contentType設置請求的content type此處為json格式。
-53行:andDo添加一個結果處理器,比如此處使用MockMvcResultHandlers.print()輸出整個回應結果訊息。
-54~55:添加執行完成後的斷言。添加ResultMatcher驗證規則,驗證控制器執行完成後結果是否正確;54行為判斷回應的http狀態碼是否為200,
55行為判斷response body的json格式中的name欄位是否為"randy"。
結論
在本篇文章中,我們示範了如何在Spring Boot中如何有效地寫出測試程式,單元測試也是開發中的一門學問,開發人員也必須在此方面多加精進,以確保程式最初階的穩定性,當應用程式有問題發生時也可回頭來看測試程式做初步的判斷。