1. 程式人生 > >java單元測試之junit之實戰

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_92Spring Framework 4.3.9.RELEASEJunit 4.12mysql-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測試類。該測試類要驗證(有序)

  1. 插入十條資料,測試listAll()方法能否查到十條資料
  2. 測試getPeople()方法查到資料是否剛插入的資料
  3. 呼叫updatePeople()修改name屬性,查詢剛修改的資料並驗證是否修改
  4. 呼叫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:表示測試環境使用的 ApplicationContextWebApplicationContext型別的
  • 通過@Autowired WebApplicationContext wac:注入web環境的ApplicationContext容器
  • 通過MockMvcBuilders.webAppContextSetup(wac).build()建立一個MockMvc 進行測試

測試方案如下(假設資料庫中沒有任何資料):

  1. 呼叫save()方法新增一條資料,通過JsonPath 解析返回的Json資料,獲取新增的idname
  2. 根據id呼叫 getPeople() 方法查詢,獲得pojo,驗證是否為剛插入的資料
  3. 呼叫listAll() 方法查詢資料,分別測試無分頁資料,分頁資料為負數以及分頁資料存在這三種情況
  4. 呼叫update() 方法修改資料,並測試是否修改成功
  5. 呼叫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)));
    }

}

以上基本完成了一個測試單元,當然測試覆蓋也不夠完整,存在很多不足之處,博主會再次完善,謝謝大家觀看!

資源下載

參考資料