java單元測試之junit之實戰
1 編寫該文章的起因
博主是一枚小新,經常挖坑填坑。最近在工作中遇到了這樣一種情況。某天小夥伴說博主寫得一個方法有問題,並且相應的測試類也跑不通。博主一直秉著磨刀不誤砍柴工的思想,測試類都是寫得好好地並且能槓槓執行的!懷著好奇,經過一番debug,發現某句程式碼丟擲了空指標,如下
String url = linkedService.getUrlById(id);
getUrlById是通過id去查詢資料庫中的某條資料。問題到這裡已經暴露無遺了,博主在測試該方法時將id寫死,並且在資料庫中能查出該條資料。而小夥伴的資料庫中並沒有這條資料,就導致了這個bug。最終,博主將sql語句發給小夥伴,測試通過了,問題似乎得到了“解決”
然而,過了幾天,博主在又遇到了相同的問題。
UserInfo user = userInfoService.getUser(id);
okok,這次博主一下子就定位到了問題的關鍵處,添加了相應的資料就解決了問題。但此時博主心裡已經產生了一個疑問,並且在第二天例會時提了出來。
博主:“因為環境的改變(資料不同:每個同事維護自己的資料庫,並沒有使用共同的資料庫),造成測試用例有時通過,有時不通過,這應該怎麼有效的解決?”
孫大大:(博主的同事,喜歡專研問題並解決問題):“環境改變可能包括資料庫,網路等其他因素,而你們遇到的這個問題,是測試用例寫得不夠自動化、專業化
博主的好奇心一下子被吸引住了,如何解決這種問題,什麼才能叫做寫得好的測試用例?於是博主專門花了三天的時間閱讀了 David Thomas 和Andrew Hunt 寫的《單元測試之道Java版:使用JUnit》。這本書總共只有170多頁,內容不多,沒有啃大部頭的那種挫敗感,算是一本入門書籍,讓我在短時間內瞭解如何使用JUnit編寫單元測試。
2 如何編寫好的測試類
2.1 運用好斷言
一個單元測試是程式設計師寫的一段程式碼,用於執行另一段程式碼並判斷程式碼的行為是否與期望值一致。在實際中,為了驗證行為和期望值是否一致,需要使用到assertion
import static org.junit.Assert.*;
比如方法assertTrue將會檢查給定的二元條件是否為真,如果條件非真,則該斷言將會失敗。具體的實現如下面所示:
public void assertTrue(boolean condition){
if(!condition){
abort();
}
}
我們可以利用該斷言來檢查兩個數字是否相等:
assertTrue(a == 2);
如果由於某種原因,當呼叫assertTrue()的時候,a並不等於2,那麼上面的程式將會中止並報錯。
2.2 少用輸出語句
輸出語句大家用的都不少,譬如現在要看一個pojo,在重寫了它的toString()
方法後,利用如下方式輸出
System.out.println(pojo);
logger.info("pojo={}",pojo);
接著就在滿是日誌的控制檯裡查詢我們需要的資訊。這種方法並不是不可取,但是效率低。如果你已經知道了期望值,那麼最好使用斷言來判斷結果。
2.3 注重有效的單元測試
本小節內容引用自《單元測試之道Java版:使用JUnit》
2.3.1 明確測試目的
我如何知道程式碼執行是否正確呢?
我要如何對它進行測試?
還有哪些方面可能會發生錯誤?
這個問題是否會在其他的地方出現呢?
2.3.2 一般原則
測試任務可能失敗的地方。
測試任何已經失敗的地方。
對於新加的程式碼,在被證明正確之前,都可能是有問題的。
至少編寫和產品程式碼一樣多的測試程式碼。
針對每次編譯都做區域性測試。
簽入程式碼之前做全域性測試。
2.3.3 使用你的RIGHT-BICEP
結果是否正確(Right)?
邊界(boundary)條件是否正確?
是否可以檢查反向(inverse)關聯?
是否可以使用其他方法來跨檢查(cross-check)結果?
錯誤條件(error condition)是否可以重新?
效能方面是否滿足條件?
2.3.4 好的測試是一個TRIP
Automatic(自動的)。
Thorough(全面的)。
Repetable(可重複的)。
Independent(獨立的)。
Professional(專業的)。
2.3.5 CORRECT邊界條件
一致性(Conformance)——值是否符合預期的格式?
有序性(Ordering)——一組值是該有序的,還是無序的?
區間性(Range)——值是否在一個合理的最大值和最小值的範圍之內?
引用、耦合性(Reference)——程式碼是否引用了一些不受程式碼本身直接控制的外部因素?
存在性(Existence)——值是否存在(例如,非null,非零,包含於某個集合等)
基數性(Cardinality)——是否恰好有足夠的值?
時間性,絕對的或者相對的(Time)——所有事情是否都是按順序發生的?是否在正確的時間?是否及時?
3 快速入門
本章的主要目標是在Spring+SpringMVC+MyBatis的基礎架構上,從傳統的Dao、Service、Controller,由下往上針對這三層完成一次完整的測試。通過這個例子,希望大家能夠更加了解測試如何編寫。
3.1 環境要求
本文采用JAVA 1.8.0_92
、Spring Framework 4.3.9.RELEASE
、 Junit 4.12
、mysql-5.6.32
通過測試,使用maven
構建專案、idea
作為編譯器。
3.2 專案結構解析
src/main/java/qingtian/example
程式的主要程式碼src/main/resources
config
配置檔案mapper
mybatis對映檔案sql
資料庫指令碼logback.xml
日誌配置檔案
src/test
測試類
3.3 程式碼解析
BaseTest
在src/test/
下建立一個測試的基類,在這裡設定了事務回滾,測試資料不會汙染資料庫。當然,並不是完全不對資料庫造成影響。如果主鍵被設定為自動增長時,會發現ID
是不連續的,且在不斷增長,所以這並不是真正意義上的無汙染。
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration({"classpath:config/spring/spring-dao.xml",
"classpath:config/spring/spring-service.xml",
"classpath:config/spring/spring-web.xml"})
@Transactional
@Rollback
public class BaseTest{
}
@RunWith(SpringJUnit4ClassRunner.class)
讓測試在Spring容器環境下執行ContextConfiguration
載入所需的配置檔案(可以以字元陣列的形式載入)@Transactional
開啟事務:已經配置了註解式事務@Rollback
設定測試後回滾,預設屬性為true,即回滾
PeopleDao
在src/main/java/
下建立dao層,實現了最簡單的增刪查改分頁操作。
package com.qingtian.example.web.dao;
import com.qingtian.example.web.entity.People;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* @Author qingtian
* @Description
* @Date Created in 2018/3/15
*/
public interface PeopleDao {
/**
* 返回people全部資料(分頁)
* @param offset
* @param limit
* @return
*/
List<People> listAll(@Param("offset") int offset, @Param("limit") int limit );
/**
* 查詢
* @param id
* @return
*/
People getPeople(long id);
/**
* 插入一條資料
* @param people
* @return
*/
long insertPeople(People people);
/**
* 更新一條資料
* @param people
* @return
*/
long updatePeople(People people);
/**
* 刪除一條資料
* @param id
* @return
*/
long deletePeople(long id);
}
People
People實體類非常簡單,只有兩個欄位,id和name
private long id ;
private String name;
//省略get、set方法
Peolple-mapper
people類的對映檔案,注意一點,呼叫insertPeople
時通過設定屬性useGeneratedKeys="true"
、keyProperty="id"
可以返回新增資料的主鍵。
useGeneratedKeys="true"
設定是否使用JDBC的getGenereatedKeys方法獲取主鍵並賦值到keyProperty設定的領域模型屬性中keyProperty="id"
設定繫結返回的屬性為id
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.qingtian.example.web.dao.PeopleDao">
<select id="listAll" resultType="People">
select id,name from people limit #{offset},#{limit}
</select>
<select id="getPeople" resultType="People">
select id,name from people where id = #{id}
</select>
<insert id="insertPeople" useGeneratedKeys="true" keyProperty="id">
insert ignore into people(name) VALUES (#{name})
</insert>
<update id="updatePeople" parameterType="People">
update people set name = #{name} where id = #{id}
</update>
<delete id="deletePeople" >
delete from people where id = #{id}
</delete>
</mapper>
PeopleDaoTest
在src/test/
下建立PeopleDaoTest測試類。該測試類要驗證(有序)
- 插入十條資料,測試
listAll()
方法能否查到十條資料 - 測試
getPeople()
方法查到資料是否剛插入的資料 - 呼叫
updatePeople()
修改name屬性,查詢剛修改的資料並驗證是否修改 - 呼叫
deletePeople()
刪除最後一條資料,查詢資料庫判斷資料是否已經不存在
以上測試均使用Assert
斷言的方式來驗證方法的正確性
package com.qingtian.example.web.dao;
import com.qingtian.example.core.BaseTest;
import com.qingtian.example.web.entity.People;
import org.junit.Test;
import org.springframework.beans.factory.annotation.Autowired;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
/**
* @Author qingtian
* @Description
* @Date Created in 2018/3/15
*/
public class PeopleDaoTest extends BaseTest{
@Autowired
private PeopleDao peopleDao;
@Test
public void testPeopleDao(){
//初始化資料
People entity = new People();
entity.setName("peopleDao測試");
//插入10條資料
int count = 10;
while(count != 0){
peopleDao.insertPeople(entity);
count--;
}
//查詢db中的列表
int offset = 0;
int limit = 10;
//正常查詢
//offset = 0, limit =10
List<People> list = peopleDao.listAll(offset, limit);
//驗證是否有10條資料
assertEquals(list.size(),10);
//查詢剛才插入的資料
People people = peopleDao.getPeople(entity.getId());
//驗證資料是否一致
assertEquals(people.getName(),entity.getName());
//修改插入的資料
String name = "peopleDao測試修改資料";
people.setName(name);
peopleDao.updatePeople(people);
//查詢剛才的資料
people = peopleDao.getPeople(people.getId());
assertEquals(name,people.getName());
//刪除一條資料
peopleDao.deletePeople(people.getId());
//再查已經不存在了
people = peopleDao.getPeople(people.getId());
assertNull(people);
}
}
測試都是由下而上,遵循dao -> service -> controller
,接下來看得是service層的測試。
BaseService
在src/main/java/
下建立service層的通用介面,定義了增刪查改分頁5個抽象方法,方便拓展。
package com.qingtian.example.web.service.core;
import java.util.List;
/**
* @Author qingtian
* @Description
* @Date Created in 2018/3/16
*/
public interface BaseService<T> {
List<T> listAll(int offset,int limit);
T getById(long id);
T update(T entity);
T deleteById(long id);
T add(T entity);
}
PeopleService
在src/main/java
下建立PeopleService介面,並繼承BaseService
package com.qingtian.example.web.service;
import com.qingtian.example.web.entity.People;
import com.qingtian.example.web.service.core.BaseService;
/**
* @Author qingtian
* @Description
* @Date Created in 2018/3/16
*/
public interface PeopleService extends BaseService<People> {
}
PeopleServiceImpl
在src/main/java/impl
下建立實現類
package com.qingtian.example.web.service.impl;
import com.qingtian.example.web.dao.PeopleDao;
import com.qingtian.example.web.entity.People;
import com.qingtian.example.web.service.PeopleService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
* @Author qingtian
* @Description
* @Date Created in 2018/3/15
*/
@Service("peopleService")
public class PeopleServiceImpl implements PeopleService{
@Autowired
private PeopleDao dao;
public List<People> listAll(int offset, int limit) {
return dao.listAll(offset,limit);
}
public People getById(long id) {
return dao.getPeople(id);
}
@Transactional(rollbackFor = Exception.class)
public People update(People entity) {
dao.updatePeople(entity);
return dao.getPeople(entity.getId());
}
@Transactional(rollbackFor = Exception.class)
public People deleteById(long id) {
People entity = dao.getPeople(id);
dao.deletePeople(id);
return entity;
}
@Transactional(rollbackFor = Exception.class)
public People add(People entity) {
dao.insertPeople(entity);
return dao.getPeople(entity.getId());
}
}
PeopleServiceTest
在src/test/
下建立peopleService的測試類,測試方案同peopleDao測試類
package com.qingtian.example.web.service;
import com.qingtian.example.core.BaseTest;
import com.qingtian.example.web.entity.People;
import org.junit.Test;
import org.springframework.beans.factory.annotation.Autowired;
import java.util.List;
import static org.junit.Assert.*;
/**
* @Author qingtian
* @Description
* @Date Created in 2018/3/16
*/
public class PeopleServiceTest extends BaseTest{
@Autowired
private PeopleService peopleService;
@Test
public void testService(){
//初始化資料
People entity = new People();
String name = "peopleService測試";
entity.setName(name);
//迴圈插入資料
int count = 10;
while(count != 0){
entity = peopleService.add(entity);
count--;
}
//查詢資料列表
int offset = 0;
int limit = 10;
List<People> list = peopleService.listAll(offset, limit);
//驗證是否有10條資料
assertEquals(list.size(),10);
//獲取插入的最後一條資料
People people = peopleService.getById(entity.getId());
//驗證name是否一致
assertEquals(people.getName(),name);
//修改最後一條資料
name = "修改測試資料";
people.setName(name);
people = peopleService.update(people);
assertEquals(people.getName(),name);
//刪除一條資料
people = peopleService.deleteById(people.getId());
//此時再去查,該條資料已不存在
people = peopleService.getById(people.getId());
assertNull(people);
}
}
controller層的測試比較複雜,使用用了測試框架Mockito
,本文重點講如何編寫測試類,Mockito
如何使用請參考官網。
PeopleController
在src/main/java
建立 peopleController
JsonUtils
將指定資料轉換成Json格式@RequestMapping
路徑規劃參照RESTful API
package com.qingtian.example.web.controller;
import com.qingtian.example.ext.common.constant.HttpCode;
import com.qingtian.example.ext.utils.JsonUtils;
import com.qingtian.example.web.entity.People;
import com.qingtian.example.web.service.PeopleService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* @Author qingtian
* @Description
* @Date Created in 2018/3/15
*/
@RestController
@RequestMapping("/peoples")
public class PeopleController {
public static final Logger logger = LoggerFactory.getLogger(PeopleController.class);
@Autowired
private PeopleService peopleService;
/**
* 列舉所有people的列表
*
* @return
*/
@RequestMapping(value = "/", method = RequestMethod.GET)
public String listAll(@RequestParam(value = "offset", required = false) Integer offset,
@RequestParam(value = "limit", required = false) Integer limit) {
//設定offset引數
if (offset == null || offset < 1) {
offset = 0;
}
//設定limit引數
if (limit == null || limit < 1) {
limit = 10;
}
//查詢列表
List<People> peopleList = peopleService.listAll(offset, limit);
return JsonUtils.genInfoJsonStr(HttpCode.HTTP_OK, "獲取列表資料成功", peopleList);
}
/**
* 新增一條資料
* @param name
* @return
*/
@RequestMapping(value = "/",method = RequestMethod.POST)
public String save(@RequestParam(value = "name") String name) {
//設定引數
People entity = new People();
entity.setName(name);
//新增資料
try {
entity = peopleService.add(entity);
return JsonUtils.genInfoJsonStr(HttpCode.HTTP_CREATE,"新增資料成功",entity);
} catch (Exception e) {
logger.error("class PeopleController method save execute exception [" + e.getMessage() + "]");
return JsonUtils.genInfoJsonStr(HttpCode.HTTP_INVALID_REQUEST,"新增資料失敗",e.getMessage());
}
}
/**
* 獲取單條資料
* @param id
* @return
*/
@RequestMapping(value = "/{id}",method = RequestMethod.GET)
public String getPeople(@PathVariable("id")Long id){
People entity = peopleService.getById(id);
return JsonUtils.genInfoJsonStr(HttpCode.HTTP_OK,"獲取資料成功",entity);
}
/**
* 更新資料
* @param id
* @param name
* @return
*/
@RequestMapping(value = "/{id}",method = RequestMethod.PUT)
public String update(@PathVariable("id")Long id,
@RequestParam(value = "name")String name){
People entity = new People();
entity.setId(id);
entity.setName(name);
try {
entity = peopleService.update(entity);
return JsonUtils.genInfoJsonStr(HttpCode.HTTP_CREATE,"修改資訊成功",entity);
} catch (Exception e) {
logger.error("class PeopleController method update execute exception [" + e.getMessage() + "]");
return JsonUtils.genInfoJsonStr(HttpCode.HTTP_INVALID_REQUEST,"修改資訊失敗",e.getMessage());
}
}
/**
* 刪除資訊成功
* @param id
* @return
*/
@RequestMapping(value = "/{id}",method = RequestMethod.DELETE)
public String delete(@PathVariable("id")Long id){
try {
People entity = peopleService.deleteById(id);
return JsonUtils.genInfoJsonStr(HttpCode.HTTP_NO_CONTENT,"刪除資訊成功",entity);
} catch (Exception e) {
logger.error("class PeopleController method delete execute exception [" + e.getMessage() + "]");
return JsonUtils.genInfoJsonStr(HttpCode.HTTP_INVALID_REQUEST,"刪除資訊失敗",e.getMessage());
}
}
}
PeopleControllerTest
在src/test
下建立 PeopleControllerTest
測試類,
Mockito
一個Mocking測試框架,能夠使用簡潔的API做測試
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-all</artifactId>
<version>1.9.5</version>
<scope>test</scope>
</dependency>
JsonPath
解析字元型別的Json資料
<dependency>
<groupId>com.jayway.jsonpath</groupId>
<artifactId>json-path</artifactId>
<version>2.2.0</version>
</dependency>
<dependency>
<groupId>com.jayway.jsonpath</groupId>
<artifactId>json-path-assert</artifactId>
<version>2.2.0</version>
<scope>test</scope>
</dependency>
@WebAppConfiguration
:表示測試環境使用的ApplicationContext
是WebApplicationContext
型別的- 通過
@Autowired WebApplicationContext wac
:注入web環境的ApplicationContext
容器 - 通過
MockMvcBuilders.webAppContextSetup(wac).build()
建立一個MockMvc
進行測試
測試方案如下(假設資料庫中沒有任何資料):
- 呼叫
save()
方法新增一條資料,通過JsonPath
解析返回的Json資料,獲取新增的id
、name
- 根據
id
呼叫getPeople()
方法查詢,獲得pojo
,驗證是否為剛插入的資料 - 呼叫
listAll()
方法查詢資料,分別測試無分頁資料,分頁資料為負數以及分頁資料存在這三種情況 - 呼叫
update()
方法修改資料,並測試是否修改成功 - 呼叫
delete()
方法刪除資料,並測試資料是否已經不存在
package com.qingtian.example.web.controller;
import com.jayway.jsonpath.JsonPath;
import com.qingtian.example.core.BaseTest;
import org.junit.Before;
import org.junit.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.web.WebAppConfiguration;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.RequestBuilder;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
import static org.hamcrest.Matchers.is;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* @Author qingtian
* @Description
* @Date Created in 2018/03/17
*/
@WebAppConfiguration
public class PeopleControllerTest extends BaseTest{
@Autowired
protected WebApplicationContext wac;
private MockMvc mvc;
@Before
public void setUp() throws Exception {
mvc = MockMvcBuilders.webAppContextSetup(wac).build(); //初始化MockMvc物件
}
@Test
public void testPeopleController() throws Exception{
RequestBuilder request = null;
String name = "controller測試新增";
//post提交一個people
request = post("/peoples/")
.param("name",name);
String json = mvc.perform(request)
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.name",is(name)))
.andReturn().getResponse().getContentAsString();
//獲取插入的記錄的id和name
Object pId = JsonPath.read(json, "$.data.id");
Object pName = JsonPath.read(json,"$.data.name");
//get方法獲取剛插入的資料
request = get("/peoples/" + pId);
mvc.perform(request)
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.name",is(pName)));
//測試listAll方法
//1:不傳offset和limit
request = get("/peoples/");
mvc.perform(request)
.andExpect(status().isOk());
//2:傳負參
request = get("/peoples/")
.param("offset","-1")
.param("limit","-1");
mvc.perform(request)
.andExpect(status().isOk());
//3:傳完整的引數
request = get("/peoples/")
.param("offset","0")
.param("limit","1");
mvc.perform(request)
.andExpect(status().isOk());
//修改請求
name = "controller測試修改";
request = put("/peoples/" + pId)
.param("name",name);
mvc.perform(request)
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.name",is(name)));
//刪除請求
request = delete("/peoples/" + pId);
mvc.perform(request)
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.id",is(pId)));
}
}
以上基本完成了一個測試單元,當然測試覆蓋也不夠完整,存在很多不足之處,博主會再次完善,謝謝大家觀看!