【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 單元測試編寫模式:
- 通過把環境設直成已知狀態(如建立物件,獲取資源)來建立測試. 測試前的狀態通常稱為 Test Fixture.
- 呼叫待測試的方法。
- 確認測試結呆,通常通過呼叫一個或更多的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最佳實踐
- 一次只能單元測試一個物件
- 選擇有意義的測試方法名字
- 在assert呼叫中解釋失敗的原因
- 一個單元測試等於一個@Test方法
- 測試任何可能失敗的事物
- 讓測試改善程式碼
- 是異常測試更易於閱讀
- 總是為跳過的測試說明原因
- 相同的包,分離的目錄