JUnit + Mockito 單元測試(三)
這裡假設我們沒有 Tomcat(雖然不太可能,假設吧!),那就使用 Mockito 模擬一個看看怎麼樣。本文結合 RESTful 介面來進行迴歸測試的目的。
模擬 ServletContextListener
Listener 是啟動 App 的第一個模組,相當於執行整個 Web 專案的初始化工作,所以也必須先模擬 ServletContextListener 物件。通過初始化的工作是安排好專案的相關配置工作和先快取一些底層的類(作為 static 成員儲存在記憶體中)。
package ajaxjs.test; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.*; import java.io.IOException; import javax.servlet.ServletContext; import javax.servlet.ServletContextEvent; import javax.servlet.ServletException; import ajaxjs.config.Application; import org.junit.Before; import org.junit.Test; import ajaxjs.Constant; public class TestApplication { private Application app; private ServletContext sc; @Before public void setUp() throws Exception { sc = mock(ServletContext.class); // 指定類似 Tomcat 的虛擬目錄,若設定為 "" 表示 Root 根目錄 when(sc.getContextPath()).thenReturn("/zjtv"); // 設定專案真實的目錄,當前是 返回 一個特定的 目錄,你可以不執行該步 when(sc.getRealPath(anyString())).thenReturn("C:\\project\\zjtv\\WebContent" + Constant.ServerSide_JS_folder); // 設定 /META-INF 目錄,當前使用該目錄來儲存 配置 when(sc.getRealPath("/META-INF")).thenReturn("C:\\project\\zjtv\\WebContent\\META-INF"); app = new Application(); } @Test public void testContextInitialized() throws IOException, ServletException { ServletContextEvent sce = mock(ServletContextEvent.class); when(sce.getServletContext()).thenReturn(sc); app.contextInitialized(sce); assertNotNull(sce); assertTrue("App started OK!", Application.isConfig_Ready); } }
上述程式碼中 Application app 是 javax.servlet.ServletContextListener 的實現。你可通過修改 setUp() 裡面的相關配置,應適應你的測試。
模擬 Servlet
背景簡介:由於這是 JSON RESTful 介面的原因,所以我使用同一個 Servlet 來處理,即 BaseServlet,為 HttpServlet 的子類,而且採用 Servlet 3.0 的註解方式定義 URL Mapping,而非配置 web.xml 的方式,程式碼組織更緊湊。——從而形成針對最終業務的 zjtvServlet 類,為 BaseServlet 的子類,如下,
package zjtv; import javax.servlet.annotation.WebServlet; import javax.servlet.annotation.WebInitParam; import ajaxjs.service.BaseServlet; @WebServlet( urlPatterns = {"/service/*", "/admin_service/*"}, initParams = { @WebInitParam (name = "news", value = "ajaxjs.data.service.News"), @WebInitParam (name = "img", value = "ajaxjs.data.service.subObject.Img"), @WebInitParam (name = "catalog", value = "zjtv.SectionService"), @WebInitParam (name = "live", value = "ajaxjs.data.ext.LiveService"), @WebInitParam (name = "vod", value = "ajaxjs.data.ext.VodService"), @WebInitParam (name = "compere", value = "zjtv.CompereService"), @WebInitParam (name = "misc", value = "zjtv.MiscService"), @WebInitParam (name = "user", value = "ajaxjs.data.user.UserService"), } ) public class zjtvServlet extends BaseServlet{ private static final long serialVersionUID = 1L; }
其中我們注意到,
urlPatterns = {"/service/*", "/admin_service/*"},
就是定義介面 URL 起始路徑,因為使用了通貝符 *,所以可以允許我們 /service/news/、/service/product/200 形成各種各樣的 REST 介面。
但是,我們不對 zjtvServlet 直接進行測試,而是其父類 BaseServlet 即可。箇中原因是我們模擬像 WebServlet 這樣的註解比較不方便。雖然是註解,但最終還是通過某種形式的轉化,形成 ServletConfig 物件被送入到 HttpServlet.init 例項方法中去。於是我們採用後一種方法。
我們試觀察 BaseServlet.init(ServletConfig config) 方法,還有每次請求都會執行的 doAction(),發現這兩步所執行過程中需要用到的物件,及其方法是這樣的,
/**
* 初始化所有 JSON 介面
* 為了方便測試,可以每次請求載入一次 js 檔案,於是過載了一個子方法 private void init(String Rhino_Path)
*/
public void init(ServletConfig config) throws ServletException {
init(Application.Rhino_Path);
// 遍歷註解的配置,需要什麼類,收集起來,放到一個 hash 之中
Enumeration<String> initParams = config.getInitParameterNames();
while (initParams.hasMoreElements()) {
String initParamName = initParams.nextElement(),
initParamValue = config.getInitParameter(initParamName);
System.out.println("initParamName:" + initParamName + ", initParamValue:" + initParamValue);
initParamsMap.put(initParamName, initParamValue);
}
}
……
private void doAction(HttpServletRequest request, HttpServletResponse response){
// 為避免重啟伺服器,除錯模式下再載入 js
if(Application.isDebug)init(Application.Rhino_Path);
ajaxjs.net.Request.setUTF8(request, response);
response.setContentType("application/json");
// System.out.println(ajaxjs.net.Request.getCurrentPage_url(request));/
Connection jdbcConn = DAO.getConn(getConnStr());
try {
Object obj = Application.jsRuntime.call("bf_controller_init", request, jdbcConn);
if(obj != null)
response.getWriter().println(obj.toString());
} catch (Exception e) {
e.printStackTrace();
ajaxjs.Util.catchException(e, "呼叫 bf.controller.init(); 失敗!");
}
output(request, response);
try {
jdbcConn.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
於是,我們遵循“依賴什麼,模擬什麼”的原則,讓 Mockito 為我們生成模擬的物件,以假亂真。
首先,我們不能忘記這是一個 Web 專案,因此開頭講的那個 Listener 類也要首當其衝被初始化,才能有 Servlet 正確執行。於是,在 JUnit 單元測試的起始工作中,執行,
@Before
public void setUp() throws Exception {
TestApplication app = new TestApplication();
app.setUp();
app.testContextInitialized();
}
同時也把 setUp()、testContextInitialized() 手動執行一遍,因為之前的時候,我們是讓 JUnit 或者 Tomcat 自動執行的。執行這一步之後,我們就初始化完畢偵聽器 Listener 了。
這裡所涉及的物件和方法比較多,下面我們逐一分解。
模擬 ServletConfig 物件
接著,怎麼通過“模擬註解”來初始化 Servlet 配置呢?這裡涉及到一個 Enumeration 物件的模擬,——其實也挺好辦,方法如下,
/**
* 初始化 Servlet 配置,這裡是模擬 註解
* @return
*/
private ServletConfig initServletConfig(){
ServletConfig servletConfig = mock(ServletConfig.class);
// 模擬註解
Vector<String> v = new Vector<String>();
v.addElement("news");
when(servletConfig.getInitParameter("news")).thenReturn("ajaxjs.data.service.News");
v.addElement("img");
when(servletConfig.getInitParameter("img")).thenReturn("ajaxjs.data.service.subObject.Img");
v.addElement("catalog");
when(servletConfig.getInitParameter("catalog")).thenReturn("zjtv.SectionService");
v.addElement("user");
when(servletConfig.getInitParameter("user")).thenReturn("ajaxjs.data.user.UserService");
Enumeration<String> e = v.elements();
when(servletConfig.getInitParameterNames()).thenReturn(e);
return servletConfig;
}
你可以定義更多業務物件,就像註解那樣,結果無異。
模擬 Request 物件
下面所有虛擬的 Request 方法都可以按照你的專案配置進行修改
/**
* 請求物件
* @return
*/
private HttpServletRequest initRequest(){
HttpServletRequest request = mock(HttpServletRequest.class);
when(request.getPathInfo()).thenReturn("/zjtv/service/news");
when(request.getRequestURI()).thenReturn("/zjtv/service/news");
when(request.getContextPath()).thenReturn("/zjtv");
// when(request.getSession()).thenReturn("/zjtv");
when(request.getMethod()).thenReturn("GET");
// 設定引數
when(request.getParameter("a")).thenReturn("aaa");
final Map<String, Object> hash = new HashMap<String, Object>();
Answer<String> aswser = new Answer<String>() {
public String answer(InvocationOnMock invocation) {
Object[] args = invocation.getArguments();
return hash.get(args[0].toString()).toString();
}
};
when(request.getAttribute("isRawOutput")).thenReturn(true);
when(request.getAttribute("errMsg")).thenAnswer(aswser);
when(request.getAttribute("msg")).thenAnswer(aswser);
// doThrow(new Exception()).when(request).setAttribute(anyString(), anyString());
doAnswer(new Answer<Object>() {
public Object answer(InvocationOnMock invocation) {
Object[] args = invocation.getArguments();
// Object mock = invocation.getMock();
System.out.println(args[1]);
hash.put(args[0].toString(), args[1]);
return "called with arguments: " + args;
}
}).when(request).setAttribute(anyString(), anyString());
return request;
}
其中比較麻煩的 request.getAttribute() / setAttribute() 方法。鑑於 HttpServlet 是介面的緣故,我們必須實現一遍 getAttribute() / setAttribute() 的內部實現。此次我們只是簡單地利用一個 map 來儲存 reuqest.setAttribute() 的資訊。然後使用 Mockito 的 Answer 介面獲取真實的引數如何,從而讓 request.getAttribute() 返回具體的值。
最初看到的做法是這樣,
class StubServletOutputStream extends ServletOutputStream {
public ByteArrayOutputStream baos = new ByteArrayOutputStream();
public void write(int i) throws IOException {
baos.write(i);
}
public String getContent() {
return baos.toString();
}
}
上述是個內部類,例項化如下,
StubServletOutputStream servletOutputStream = new StubServletOutputStream();
when(response.getOutputStream()).thenReturn(servletOutputStream);
……doPost(request, response);
byte[] data = servletOutputStream.baos.toByteArray();
System.out.println("servletOutputStream.getContent:" + servletOutputStream.baos.toString());
我不太懂 Steam 就沒深入了,再 Google 下其他思路,結果有人提到把響應結果儲存到磁碟中,我覺得不是太實用,直接返回 String 到當前測試上下文,那樣就好了。
// http://stackoverflow.com/questions/5434419/how-to-test-my-servlet-using-junit
HttpServletResponse response = mock(HttpServletResponse.class);
StubServletOutputStream servletOutputStream = new StubServletOutputStream();
when(response.getOutputStream()).thenReturn(servletOutputStream);
// 儲存到磁碟檔案 需要在 bs.doPost(request, response); 之後 writer.flush();
// PrintWriter writer = new PrintWriter("d:\\somefile.txt");
StringWriter writer = new StringWriter();
when(response.getWriter()).thenReturn(new PrintWriter(writer));
測試後,用 writer.toString() 返回服務端響應的結果。
模擬資料庫
怎麼模擬資料庫連線?可以想象,模擬資料庫的工作量比較大,乾脆搭建一個真實的資料庫得了。所以有人想到的辦法是用 Mockito 繞過 DAO 層直接去測試 Service 層,對 POJO 充血。參見:Java Mocking入門—使用Mockito。
不過我當前的方法,還是直接連資料庫。因為是使用 Tomcat 連線池的,所以必須模擬 META-INF/context.xml 的配置,其實質是 Java Naming 服務。模擬方法如下,
/**
* 模擬資料庫 連結 的配置
* @throws NamingException
*/
private void initDBConnection() throws NamingException{
// Create initial context
System.setProperty(Context.INITIAL_CONTEXT_FACTORY, "org.apache.naming.java.javaURLContextFactory");
System.setProperty(Context.URL_PKG_PREFIXES, "org.apache.naming");
// 需要加入tomcat-juli.jar這個包,tomcat7此包位於tomcat根目錄的bin下。
InitialContext ic = new InitialContext();
ic.createSubcontext("java:");
ic.createSubcontext("java:/comp");
ic.createSubcontext("java:/comp/env");
ic.createSubcontext("java:/comp/env/jdbc");
// Construct DataSource
try {
SQLiteJDBCLoader.initialize();
} catch (Exception e1) {
e1.printStackTrace();
}
SQLiteDataSource dataSource = new SQLiteDataSource();
dataSource.setUrl("jdbc:sqlite:c:\\project\\zjtv\\WebContent\\META-INF\\zjtv.sqlite");
ic.bind("java:/comp/env/jdbc/sqlite", dataSource);
}
至此,我們就可以模擬一次 HTTP 請求,對介面進行測試了!