Spring Boot unit test

Spring Boot的單元測試

潘力豪 2019/07/07 22:52:03
137

單元/整合測試

軟體開發中,避免不了是需要寫些測試程式。而測試程式不一定都是由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中如何有效地寫出測試程式,單元測試也是開發中的一門學問,開發人員也必須在此方面多加精進,以確保程式最初階的穩定性,當應用程式有問題發生時也可回頭來看測試程式做初步的判斷

潘力豪