1. 程式人生 > >【JUnit實戰】為應用程式Controller設計單元測試

【JUnit實戰】為應用程式Controller設計單元測試

在本章中,我們為一個簡單但完整的應用程式controller建立了一個測試用例。測試用例並不是測試單個的元件,而是檢驗多個組例,如何一起工作。我們從一個可以用於任何類的簡單測試用例開始.然後把新的測試逐個新增到測試用例中,直到所有初始的元件都被測試到。由於斷言變得越來越複雜、因此我們通過Hamcrest匹配器找到了一種簡化斷言的方法。我們預期這個包會日益增長,所以我們為測試類建立了另一個原始碼目錄。因為測試原始碼和領域原始碼的目錄都位於同一個包中,所以我們仍然可以測試受保護成員和預設的包成員。

1.被測部分:Controller模式

1.1 Controller簡介

一般而言, Controller可以處理以下事務
- 接受請求
- 根據請求執行任意常用極端
- 選擇 一個合適的請求處理器
- 路由請求,以便處理器可以執行相關的業務邏輯
- 可能提供一個頂層處理器來處理錯誤和異常

1.2 設計介面

Controller模式中涉及四個角色:
- Request
- Response
- RequestHandler
- Controller

Controller接受一個Request,分發給一個RequestHandler,並返回一個Response物件。

//首先,定義一個 Request 介面,這個介面只有一個返問請求的唯一名稱的getName方法
public interface Request{
    String getName();
}
//其次指定一個空介面。要開始編寫程式碼,你只需要返回一個 Response物件即可。Response 物件所封裝的是你可以稍後處理的內容。
public interface Response{ } //接下來,定義一個能夠處理 Request 並返回 Response 的 RequestHandle,RequestHandle是一個輔助元件,被設計用來處理大部分的“骯髒工作”。它可以呼叫各種類,這些類可能丟擲任意型別的異常。Exception就是由process萬法丟擲的。 public interface RequestHandler{ Response procees(Request request) throws Exception; } //定義一個頂層方法來處理收到的請求。在接受請求之後, controller將請求分發給相應的RequestHandler 。
public interface Controller{ Response process(Request request); //add Handler 方法允許你擴充套件Controller,而無須修改 Java原始碼。 void addHandler(Request request,RequestHandler requestHandler) }

controller的目的是處理一個請求並返回一個響應。但是,在你處理一個請求之前,設計要求新增一個RequestHandler來做這個處理。

package com.JUnittTest.mastery;

import java.util.HashMap;
import java.util.Map;


public class DefaultController {
//  請求處理器登錄檔,對每一個request註冊對應的requestHandler
    private Map requestHandlers=new HashMap();
//  宣告一個受保護的方法,為接受的請求獲取RequestHandler
    protected RequestHandler getHandler(Request request){
        if(!this.requestHandlers.containsKey(request.getName())){
            String message="Cannot find handler for request name "+"["+request.getName()+"]";
            throw new RuntimeException(message);
        }
//      向呼叫者返回相應的requestHandler
        return (RequestHandler)this.requestHandlers.get(request.getName());
    }

//  是Controller類的核心,把response分派給相應的requestHandler,並傳回requestHandler的response
    public Response processRequest(Request request){
        Response response;
        try {
//          getHandler(request)返回一個RequestHandler介面型別的物件
//          RequestHandler介面定義了process(request)方法,返回一個response物件
            response=getHandler(request).process(request);
        } catch (Exception exception) {
            response=new ErrorResponse(request,exception);
        }
        return response;
    }
//  檢查requestHandler是否已經被註冊
    public void addHandler(Request request,RequestHandler requestHandler){
//      如果被註冊了就丟擲一個異常
        if(this.requestHandlers.containsKey(request.getName())){
            throw new RuntimeException("A request handler has "+"already been registered for request name "+"["+request.getName()+"]");
        }else{
            this.requestHandlers.put(request.getName(), requestHandler);
        }
    }
}

我們還需要額外再定義一個ErrorPesponse介面,不同於posponse介面,ErrorPesponse介面返回的是錯誤的posponse。

package com.JUnittTest.mastery;

public interface Response {
    String getName();
}

2. 設計單元測試

2.1 測試前部署 @Before @BeforeClass

@Before @After 註釋方法會在每個@Test方法前後執行

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Before {
}

@BeforeClass @AfterClass 註釋方法會在只會在所有@Test方法前後執行一次

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface BeforeClass {
}

從原始碼分析,@Before @BeforeClass 註釋非常簡單,沒有內部屬性,只是起到對程式碼執行順序的引導。(@After @AfterClass也是一樣的)

一般在@BeforeClass @AfterClass註釋方法中的程式碼完成測試環境的部署和拆除。
在@Before @After註釋方法中的程式碼是對公共屬性和物件的宣告,一般是從@Test註釋方法程式碼中重構得來的。

2.2 單元測試 @Test

分析@Test原始碼可知,該註釋擁有兩個屬性expected(),timeout()
expected是單元測試預期丟擲異常的類物件,帶有該屬性的單元測試在丟擲預期的異常後測試才會通過。timeout是用來測試單元執行時間的屬性,單位為毫秒。要求單元執行時間不能超過設定值,否則測試失敗。

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
public @interface Test {

    /**
     * Default empty exception
     */
    static class None extends Throwable {
        private static final long serialVersionUID= 1L;     
        private None() {
        }
    }

    /**
     * Optionally specify <code>expected</code>, a Throwable, to cause a test method to succeed iff 
     * an exception of the specified class is thrown by the method.
     */
    Class<? extends Throwable> expected() default None.class;

    /** 
     * Optionally specify <code>timeout</code> in milliseconds to cause a test method to fail if it
     * takes longer than that number of milliseconds.*/
    long timeout() default 0L; 
}

2.3 測試類

測試類存於何處:
- 放在包中作為公有類
- 作為測試用例類的內部類(類很簡單,且不會有後續的改變)

本例中,我們採用第二種方式,被測程式碼有四個角色
- Request
- Response
- RequestHandler
- Controller

因此我們建立前三個角色為Controller的內部類,實現原有的方法:

public class TestDefaultController {
    ...
    private class SampleRequest implements Request{
        public String getName(){
            return "Test"
        }
    }
    private class SampleHandler implements RequestHandler{
        public Response process(Request request) throws Exception{
            return new SampleResponse();
        }
    }
    private class SampleResponse implements Response{
        ...
    }

}

2.4 兩種測試物件

要建立一個單元測試,需要建立兩種型別的物件:
- Domain Object: 領域物件(被測物件)
- Test Object:測試物件(與被測物件互動的物件)
在一個@Test註釋方法中,只測試一個物件(Domain Object),其他物件(Test Object)與被測物件互動完成單元測試

2.5 單元用例設計

2.5.1 單元測試編寫模式:

  1. 通過把環境設直成已知狀態(如建立物件,獲取資源)來建立測試. 測試前的狀態通常稱為 Test Fixture.
  2. 呼叫待測試的方法。
  3. 確認測試結呆,通常通過呼叫一個或更多的assert方法來實現
    主幹+分支

2.5.2 單元用例覆蓋:主幹+分支+可擴充套件性

先從程式的正向主幹邏輯出發,覆蓋最基本的邏輯單元。
然後通過分析條件語句,try/catch語句,找到分支路徑進行逐一覆蓋。
可擴充套件性主要通過測試單元執行時間是否達到要求來給出評估。

主幹用例

測試點1:是否能新增一個RequestHandler
- 新增一個RequestHandler,引用一個Request
- 獲取一個RequestHandler並傳遞同一個Request
- 檢查獲得的RequestHandler是否就是新增的那一個


    @Test
//  測試ProcessRequest方法的主幹流程
    public void testProcessRequest(){
//      這部分程式碼被多個測試類複用,因此我們把它移動到@Before註釋的方法中
        /*Request request=new SampleRequest();
        RequestHandler handler=new SampleHandler();
        controller.addHandler(request, handler);*/
//      呼叫被測方法,然後驗證會不會返回方法指定的物件
//      被測方法有引數列表,有返回值,測試方法仍然是無參且無返回值的方法
        Response response=controller.processRequest(request);
        assertNotNull("Must not return a null response: ",response);
//      讓測試結果與預期結果的類進行比較,進一步確定返回值的正確性
//      assertEquals("Response should be of type SampleResponse",SampleResponse.class,response.getClass());
        assertEquals(new SampleResponse(),response);
    }

測試點2:

@Test
//  測試addHandler方法的主幹流程
    public void testAddHandler(){
//      這部分程式碼被多個測試類複用,因此我們把它移動到@Before註釋的方法中
        /*Request request=new SampleRequest();
        RequestHandler handler=new SampleHandler();
//      引用一個Request,新增一個RequestHandler,
        controller.addHandler(request, handler);*/
//      獲取一個RequestHandler並傳遞同一個Request
        RequestHandler handler2=controller.getHandler(request);
//      檢查獲得的RequestHandler是否就是新增的那一個
        assertSame("Handler we set in controller should be the same handler we got",handler2,handler);
    }
分支用例

測試點3:

@Test
//  測試ProcessRequest方法的異常處理流程
    public void testProcessRequestAnswersErrorResponse(){
//      使用SampleRequest帶一個引數的構造方法為物件例項request初始化fixture
        SampleRequest request=new SampleRequest("testError");
        SampleExceptionHandler handler=new SampleExceptionHandler();
        controller.addHandler(request, handler);
        Response response=controller.processRequest(request);
//      檢查被測方法是否得到返回值
        assertNotNull("Must not return a null response: ",response);
//      檢查返回值是不是預期的異常物件
        assertEquals(ErrorResponse.class,response.getClass());
    }

這裡還需要新增一個測試類,用於模擬processRequest(Request request)丟擲異常的情況。

//  用來測試processRequest(Request request)的異常處理流程建立的類
//  processRequest(Request request)方法中呼叫了介面RequestHandler的方法
//  直接丟擲一個異常看程式的try-catch語句塊能否如預期捕捉到
    private class SampleExceptionHandler implements RequestHandler{
        @Override
        public Response process(Request request) throws Exception {
            throw new Exception("error processing request");
        }

    }

測試點4:

@Test(expected=RuntimeException.class)
//  測試getHandler方法的異常處理流程,預期希望丟擲一個異常,則在@Test的屬性expected屬性中定義異常類,這樣這條測試就不會因為有異常而阻塞
    public void testGetHandlerNotDefined(){
        SampleRequest request=new SampleRequest("testGetHandlerNotDefined");
        //The following line is supposed to throw a RuntimeException 
        controller.getHandler(request);
    }

測試點5:

    @Test(expected=RuntimeException.class)
//  測試addHandler方法的異常處理流程,預期希望丟擲一個異常,則在@Test的屬性expected屬性中定義異常類,這樣這條測試就不會因為有異常而阻塞
    public void testAddRequestDuplicateName(){
        SampleRequest request=new SampleRequest();
        SampleHandler handler=new SampleHandler();
//      The following line is supposed to throw a RuntimeException
        controller.addHandler(request, handler);
    }
可擴充套件性測試

測試點6:


    @Test(timeout=120)
    @Ignore(value="Skip for now")//@Ignore將會對本單元測試跳過執行 value宣告跳過的原因
        public void testProcessMultipleRequestsTimeout(){
            Request request;
            Response response=new SampleResponse();
            RequestHandler handler=new SampleHandler();
            for(int i=0;i<99999;i++){
                request=new SampleRequest(String.valueOf(i));
                controller.addHandler(request, handler);
                response=controller.processRequest(request);
                assertNotNull(response);
                assertNotSame(ErrorResponse.class,response.getClass());
            }
        }
完整的單元測試程式碼
package com.JUnittTest.mastery;

import static org.junit.Assert.*;

import org.junit.Before;
import org.junit.Ignore;
import org.junit.Test;


public class TestDefaultController {
    private DefaultController controller;
    private Request request;
    private RequestHandler handler;
    @Before
//  例項化DefaultController
    public void instantiate() throws Exception{
        controller=new DefaultController();
        request=new SampleRequest();
        handler=new SampleHandler();
        controller.addHandler(request, handler);
    }

    /*@Test
//  對還沒有實現的測試程式碼丟擲一個異常
    public void testMethod(){
        throw new RuntimeException("implement me");
    }*/

    @Test
//  測試ProcessRequest方法的主幹流程
    public void testProcessRequest(){
//      這部分程式碼被多個測試類複用,因此我們把它移動到@Before註釋的方法中
        /*Request request=new SampleRequest();
        RequestHandler handler=new SampleHandler();
        controller.addHandler(request, handler);*/
//      呼叫被測方法,然後驗證會不會返回方法指定的物件
//      被測方法有引數列表,有返回值,測試方法仍然是無參且無返回值的方法
        Response response=controller.processRequest(request);
        assertNotNull("Must not return a null response: ",response);
//      讓測試結果與預期結果的類進行比較,進一步確定返回值的正確性
//      assertEquals("Response should be of type SampleResponse",SampleResponse.class,response.getClass());
        assertEquals(new SampleResponse(),response);
    }

    @Test
//  測試addHandler方法的主幹流程
    public void testAddHandler(){
//      這部分程式碼被多個測試類複用,因此我們把它移動到@Before註釋的方法中
        /*Request request=new SampleRequest();
        RequestHandler handler=new SampleHandler();
//      引用一個Request,新增一個RequestHandler,
        controller.addHandler(request, handler);*/
//      獲取一個RequestHandler並傳遞同一個Request
        RequestHandler handler2=controller.getHandler(request);
//      檢查獲得的RequestHandler是否就是新增的那一個
        assertSame("Handler we set in controller should be the same handler we got",handler2,handler);
    }
    @Test
//  測試ProcessRequest方法的異常處理流程
    public void testProcessRequestAnswersErrorResponse(){
//      使用SampleRequest帶一個引數的構造方法為物件例項request初始化fixture
        SampleRequest request=new SampleRequest("testError");
        SampleExceptionHandler handler=new SampleExceptionHandler();
        controller.addHandler(request, handler);
        Response response=controller.processRequest(request);
//      檢查被測方法是否得到返回值
        assertNotNull("Must not return a null response: ",response);
//      檢查返回值是不是預期的異常物件
        assertEquals(ErrorResponse.class,response.getClass());
    }

    @Test(expected=RuntimeException.class)
//  測試getHandler方法的異常處理流程,預期希望丟擲一個異常,則在@Test的屬性expected屬性中定義異常類,這樣這條測試就不會因為有異常而阻塞
    public void testGetHandlerNotDefined(){
        SampleRequest request=new SampleRequest("testGetHandlerNotDefined");
        //The following line is supposed to throw a RuntimeException 
        controller.getHandler(request);
    }

    @Test(expected=RuntimeException.class)
//  測試addHandler方法的異常處理流程,預期希望丟擲一個異常,則在@Test的屬性expected屬性中定義異常類,這樣這條測試就不會因為有異常而阻塞
    public void testAddRequestDuplicateName(){
        SampleRequest request=new SampleRequest();
        SampleHandler handler=new SampleHandler();
//      The following line is supposed to throw a RuntimeException
        controller.addHandler(request, handler);
    }

    @Test(timeout=120)
    @Ignore(value="Skip for now")//@Ignore將會對本單元測試跳過執行 value宣告跳過的原因
        public void testProcessMultipleRequestsTimeout(){
            Request request;
            Response response=new SampleResponse();
            RequestHandler handler=new SampleHandler();
            for(int i=0;i<99999;i++){
                request=new SampleRequest(String.valueOf(i));
                controller.addHandler(request, handler);
                response=controller.processRequest(request);
                assertNotNull(response);
                assertNotSame(ErrorResponse.class,response.getClass());
            }
        }


    private class SampleRequest implements Request{
//      SampleRequest類有一個預設屬性,作為其例項的fixture
        private static final String DEFAULT_NAME="Test";
        private String name;
//      無參的構造方法將預設屬性載入初始化
        public SampleRequest(){
            this(DEFAULT_NAME);
        }
//      帶String引數的構造方法可以自定義物件例項的屬性
        public SampleRequest(String name) {
            this.name = name;
        }
        public String getName(){
            return this.name;
        }
    }

    private class SampleHandler implements RequestHandler{

        public Response process(Request request) throws Exception{
            return new SampleResponse();
        }

    }
    private class SampleResponse implements Response{
        private static final String NAME="Test";
        public SampleResponse() {
            // TODO Auto-generated constructor stub
        }
        //給SampleResponse建立標識,主要是為了測試而設計
        public String getName(){
            return NAME;
        }
        public boolean equals(Object object){
            boolean result=false;
            if(object instanceof SampleResponse){
                result=((SampleResponse)object).getName().equals(getName());
            }
            return result;
        }
        public int hashCode(){
            return NAME.hashCode();
        }
    }
//  用來測試processRequest(Request request)的正常處理,建立的類
//  processRequest(Request request)方法中呼叫了介面RequestHandler的方法
//  直接丟擲一個異常看程式的try-catch語句塊能否如預期捕捉到
    private class SampleExceptionHandler implements RequestHandler{
        @Override
        public Response process(Request request) throws Exception {
            throw new Exception("error processing request");
        }

    }
}

3. 測試結果

這裡寫圖片描述

去掉testProcessMultipleRequestsTimeout的@Ignore註釋後的結果
這裡寫圖片描述

4. 引入Hamcrest匹配器

package com.JUnittTest.mastery;

import static org.junit.Assert.assertTrue;

import java.util.ArrayList;
import java.util.List;

import org.junit.Before;
import org.junit.Test;

import static org.junit.Assert.assertThat;
import static org.hamcrest.CoreMatchers.anyOf;
import static org.hamcrest.CoreMatchers.equalTo;
import static org.junit.matchers.JUnitMatchers.hasItem;

/**
 * A sample hamcrest test.
 * 
 * @version $Id: HamcrestTest.java 553 2010-03-06 12:29:58Z paranoid12 $
 */
public class HamcrestTest {

    private List<String> values;

    @Before
    public void setUpList() {
        values = new ArrayList<String>();
        values.add("x");
        values.add("y");
        values.add("z");
    }

    @Test
    public void testWithoutHamcrest() {
        assertTrue(values.contains("one") || values.contains("two")
                || values.contains("three"));
    }

    @Test
//  引入Hamcrest匹配器可以簡化測試斷言,使斷言更具有可讀性,Hamcrest語句是可以巢狀使用的
    public void testWithHamcrest() {
        assertThat(values, hasItem(anyOf(equalTo("one"), equalTo("two"),
            equalTo("three"))));
    }

}

引入Hamcrest匹配器之後,會顯示下面的結果,而沒有Hamcrest匹配器的話,只會顯示Failure Trace,可讀性比較差
這裡寫圖片描述

5. JUnit最佳實踐

  1. 一次只能單元測試一個物件
    1. 選擇有意義的測試方法名字
    2. 在assert呼叫中解釋失敗的原因
    3. 一個單元測試等於一個@Test方法
    4. 測試任何可能失敗的事物
    5. 讓測試改善程式碼
    6. 是異常測試更易於閱讀
    7. 總是為跳過的測試說明原因
    8. 相同的包,分離的目錄