Jsp測試技術
開發web應用程序最惱人的一點就是想要測試的話你就必須向將其部署好。當然,並不是所有部分都這樣。如果你是經過了精心的設計的話,你可以在Java程序中測試業務邏輯。你可以在應用服務器不運行的情況下測試數據訪問、接口以及存儲過程。不過如果是測試GUI的話(由Jsp所產生的HTMl),你就必須向將其部署,然後纔可能測試。
很多的團隊求助於Sellenium,Mercury或是其他的一些工具通過web server來測試GUI。然而,即使是頁面的內容不變但樣式變了得情況也會讓測試變得脆弱不堪。其他的團隊使用Cactus解決這種脆弱性,或是用HtmlUnit、HttpUnit這樣原始的工具來監測web應用程序所生成的HTML。對於這些問題,我會在另一系列的blog之中來談論。
本文之中我會介紹一種簡單易行的技術,它使用JUnit或是HtmlUnit來測試Jsp頁面,並且完全脫離容器。這項技術的優勢也在此。
你不必一定保持容器的運行,甚至存在。你可以在選擇特定的webserver之前就測試你的Jsp。
你不必在每次修改後重新部署,因而編輯/編譯/測試的過程會更迅速。
你可以使用測試優先開發的方式來持續的構建Jsp。
容器外測試Jsp技術之所以並不盛行是因爲Jsp在設計上就運行於容器內的。設計者從未過多的想過容器外運行的可能。因此由Jsp編譯器的所生成代碼往往依賴於容器所提供的諸多組件。即使是生成Jsp代碼的工具也假定了你已經有一個成功部署的web應用程序在運行。因此,爲了在容器外運行,你就要開發出相應的這些工具和組件。
依賴管理的抱怨
爲什麼這麼多框架和工具的設計者們總期望你生活在他們提供的狹小世界中?爲什麼我必須先構建出完整的web應用才能編譯Jsp?爲什麼這些東西一定要運行在容器中?信息隱藏早在10年前就已經是優秀軟件設計的基本信條了。我們這個行業何時才能認真對待它?
編譯Jsp
測試Jsp的第一步是將其編譯爲servlet。實現這一步,我們還需要先將Jsp轉換成Java格式。Apache提供了一個叫做Jasper的工具,我們調用Jasper爲MyPage.jsp創建一個Java格式的源文件MyPage_jsp.java。然後,你就可以使用你最喜歡的IDE編譯這個文件成Servlet。
可惜Jasper並非是設計用在命令行中使用的,或者說並不是完全這樣設計的。但Jasper確有一個main函數用來處理命令行參數,而且通過調用java org.apache.jasper.JspC就能夠輕易調用它了。不過,Jasper期望它所運行的環境與容器環境是保持一致的。你要確保classpath中有了很多apache的Jar文件,而且它要能找到web應用程序的web.xml。它還需要能夠找到包含web應用程序Jar以及TLD文件等的WEB-INF目錄。簡而言之,Jasper需要能找到一個完整的web應用程序。
如果事情更糟的話,除非是與TOMCAT的調用方式保持完全一致,否則某些特定的Jasper版本(我用的是tomcat 5.5.20)存在一些bug,它生成的代碼會有一些錯誤。
第一點要做的雖然繁瑣但還算簡單,你需要創建好正確的目錄以及文件結構,然後在Ant(Classpath更容易控制)中調用Jasper。第二點就需要一定的研究和測試才能讓它跑起來。以下就是能成功運行的ant文件。JspC的調用出現在最後一個任務中。
<project name="Library" default="compile" basedir=".">
<property environment="env"/>
<property name="build.home" value="${basedir}/build"/>
<property name="build.war.home" value="${build.home}/war"/>
<property name="build.classes.home" value="${build.home}/classes"/>
<property name="build.jar.home" value="${build.home}/jars"/>
<property name="catalina.home" value="${env.CATALINA_HOME}"/>
<property name="dist.home" value="${basedir}/dist"/>
<property name="web.home" value="${basedir}/web"/>
<path id="compile.classpath">
<fileset dir="lib">
<include name="*.jar"/>
</fileset>
<pathelement location="${catalina.home}/common/classes"/>
<fileset dir="${catalina.home}/common/endorsed">
<include name="*.jar"/>
</fileset>
<fileset dir="${catalina.home}/common/lib">
<include name="*.jar"/>
</fileset>
<pathelement location="${catalina.home}/shared/classes"/>
<fileset dir="${catalina.home}/shared/lib">
<include name="*.jar"/>
</fileset>
</path>
<target name="clean">
<delete dir="${build.home}"/>
<delete dir="${dist.home}"/>
</target>
<target name="compile">
<mkdir dir="${build.classes.home}"/>
<javac srcdir="${src.home}" destdir="${build.classes.home}" excludes="**/*Test.java">
<classpath refid="compile.classpath"/>
</javac>
</target>
<target name="jar" depends="compile">
<mkdir dir="${build.jar.home}"/>
<jar jarfile="${build.jar.home}/application.jar" basedir="${build.classes.home}" includes="**/application/**/*.class" />
</target>
<target name="dist" depends="jar">
<copy todir="${build.war.home}">
<fileset dir="${web.home}"/>
</copy>
<copy todir="${build.war.home}/WEB-INF/lib">
<fileset dir="${build.jar.home}" includes="*.jar"/>
</copy>
<mkdir dir="${dist.home}"/>
<jar jarfile="${dist.home}/${app.name}.war" basedir="${build.war.home}"/>
</target>
<target name="jsp" depends="dist">
<delete dir="${basedir}/testjsp"/>
<java classname="org.apache.jasper.JspC" fork="true">
<arg line="-v -d ${basedir}/testjsp -p com.objectmentor.library.jsp -mapped -compile -webapp ${build.war.home}"/>
<arg line="WEB-INF/pages/patrons/books/loanRecords.jsp"/>
<classpath>
<fileset dir="${catalina.home}/common/lib">
<include name="*.jar"/>
</fileset>
<fileset dir="${catalina.home}/server/lib">
<include name="*.jar"/>
</fileset>
<fileset dir="${catalina.home}/bin">
<include name="*.jar"/>
</fileset>
<fileset dir="${build.war.home}/WEB-INF/lib">
<include name="*.jar"/>
</fileset>
<pathelement location="/Developer/Java/Ant/lib/ant.jar"/>
</classpath>
</java>
<jar jarfile="${build.jar.home}/jsp.jar" basedir="${basedir}/testjsp"
includes="**/jsp/**/*.class"
/>
</target>
</project>
當然,你要讓所有標準文件以及目錄都在${build.war.home}之下以確保工作。如果你在你的Jsp之中使用了自定義tag的話,還要確保所有相應的TLD文件都在你的TLD目錄之中。
要注意的是,在ant文件中調用Jspc的命令行,而不是使用Tomcat所提供的JspC的Ant Task。因爲我發現當你有自定義tag的時候它無法正確運行。也許我犯了糊塗,或者JspC中確實有bug。不過我所發現的唯一能讓Jasper生成正確代碼的方式是從命令行調用它,並明確的傳遞Jsp文件路徑作爲命令行的參數!如果你依靠它的Ant Task或是使用命令行來搜索所有web應用中的Jsp進行編譯的話,它就會生成錯誤的代碼。(請參閱這篇blog)
現在我們有了Java文件,讓我們來分析一下它。首先,請看下面的Jsp文件。
<%@ page import="com.objectmentor.library.utils.DateUtil" %>
<%@ page import="com.objectmentor.library.web.controller.patrons.LoanRecord" %>
<%@ page import="java.util.List" %>
<%
List loanRecords = (List) request.getAttribute("loanRecords");
if (loanRecords.size() > 0) {
%>
<table class="list" id="loanRecords">
<tr>
<th>ID</th>
<th>Title</th>
<th>Due date</th>
<th>Fine</th>
</tr>
<%
for (int i = 0; i < loanRecords.size(); i++) {
LoanRecord loanRecord = (LoanRecord) loanRecords.get(i);
%>
<tr class="<%=i%2==0?"even":"odd"%>">
<td><%=loanRecord.id%>
</td>
<td><%=loanRecord.title%>
</td>
<td><%=DateUtil.dateToString(loanRecord.dueDate)%>
</td>
<td><%=loanRecord.fine.toString()%>
</td>
</tr>
<%
}
%>
</table>
<%
}
%>
下面則是Jasper所生成的代碼。
package com.objectmentor.library.jsp.WEB_002dINF.pages.patrons.books;
import javax.servlet.*;
import javax.servlet.http.*;
import javax.servlet.jsp.*;
import com.objectmentor.library.utils.DateUtil;
import com.objectmentor.library.web.controller.patrons.LoanRecord;
import java.util.List;
public final class loanRecords_jsp extends org.apache.jasper.runtime.HttpJspBase
implements org.apache.jasper.runtime.JspSourceDependent {
private static java.util.List _jspx_dependants;
public Object getDependants() {
return _jspx_dependants;
}
public void _jspService(HttpServletRequest request, HttpServletResponse response)
throws java.io.IOException, ServletException {
JspFactory _jspxFactory = null;
PageContext pageContext = null;
HttpSession session = null;
ServletContext application = null;
ServletConfig config = null;
JspWriter out = null;
Object page = this;
JspWriter _jspx_out = null;
PageContext _jspx_page_context = null;
try {
_jspxFactory = JspFactory.getDefaultFactory();
response.setContentType("text/html");
pageContext = _jspxFactory.getPageContext(this, request, response,
null, true, 8192, true);
_jspx_page_context = pageContext;
application = pageContext.getServletContext();
config = pageContext.getServletConfig();
session = pageContext.getSession();
out = pageContext.getOut();
_jspx_out = out;
out.write('/n');
out.write('/n');
out.write('/n');
List loanRecords = (List) request.getAttribute("loanRecords");
if (loanRecords.size() > 0) {
out.write("/n");
out.write("<table class=/"list/" id=/"loanRecords/">/n");
out.write(" <tr>/n");
out.write(" <th>ID</th>/n");
out.write(" <th>Title</th>/n");
out.write(" <th>Due date</th>/n");
out.write(" <th>Fine</th>/n");
out.write(" </tr>/n");
out.write(" ");
for (int i = 0; i < loanRecords.size(); i++) {
LoanRecord loanRecord = (LoanRecord) loanRecords.get(i);
out.write("/n");
out.write(" <tr class=/"");
out.print(i%2==0?"even":"odd");
out.write("/">/n");
out.write(" <td>");
out.print(loanRecord.id);
out.write("/n");
out.write(" </td>/n");
out.write(" <td>");
out.print(loanRecord.title);
out.write("/n");
out.write(" </td>/n");
out.write(" <td>");
out.print(DateUtil.dateToString(loanRecord.dueDate));
out.write("/n");
out.write(" </td>/n");
out.write(" <td>");
out.print(loanRecord.fine.toString());
out.write("/n");
out.write(" </td>/n");
out.write(" </tr>/n");
out.write(" ");
}
out.write("/n");
out.write("</table>/n");
}
} catch (Throwable t) {
if (!(t instanceof SkipPageException)){
out = _jspx_out;
if (out != null && out.getBufferSize() != 0)
out.clearBuffer();
if (_jspx_page_context != null) _jspx_page_context.handlePageException(t);
}
} finally {
if (_jspxFactory != null) _jspxFactory.releasePageContext(_jspx_page_context);
}
}
}
最後的抱怨
這個類爲什麼要聲明爲final呢?如果我想創建一個測試的stub派生類呢?爲什麼有人會覺得生成類如此不可冒犯以至於我都無法覆寫它。
仔細讀過這段代碼你就會發現,要想使用這個servlet的實例我們需要HttpServletRequest以及HttpServletResponse的實例。
更仔細研讀一下我們就會發現servlet將所有的HTML寫到JspWriter的實例中,而JspWriter是從PageContext中獲得的。如果我們能夠創建一個JspWriter的mock up的版本來保存所有的這些HTML,再爲PageContext創建一個mock up的版本來派送mock JspWriter,那麼我們就能在我們的測試中訪問這些HTML了。
幸運的是,Tomcat的設計人員把JspWriter的創建放入到了JspFactory的工廠類中。而這個工廠類是可以覆寫的!這就意味着我們可以在servlet之中獲得我們自己的JspWriter類而不用改變servlet。需要的就是下面這段代碼。
class MockJspFactory extends JspFactory {
public PageContext getPageContext(Servlet servlet, ServletRequest servletRequest, ServletResponse servletResponse, String string, boolean b, int i, boolean b1) {
return new MockPageContext(new MockJspWriter());
}
public void releasePageContext(PageContext pageContext) {
}
public JspEngineInfo getEngineInfo() {
return null;
}
}
現在,我們需要的是mock Jspwriter。爲了便於展示,我用了下面的:
MockJspWriter
package com.objectmentor.library.web.framework.mocks;
import javax.servlet.jsp.JspWriter;
import java.io.IOException;
public class MockJspWriter extends JspWriter {
private StringBuffer submittedContent;
public MockJspWriter(int bufferSize, boolean autoFlush) {
super(bufferSize, autoFlush);
submittedContent = new StringBuffer();
}
public String getContent() {
return submittedContent.toString();
}
public void print(String arg0) throws IOException {
submittedContent.append(arg0);
}
public void write(char[] arg0, int arg1, int arg2) throws IOException {
for (int i=0; i<arg2; i++)
submittedContent.append(String.valueOf(arg0[arg1++]));
}
public void write(String content) throws IOException {
submittedContent.append(content);
}
// lots of uninteresting methods elided. I just gave them
// degenerate implementations. (e.g. {})
}
無需關心那些我省略掉的未實現方法,我認爲只需要關心那些足夠使得我的測試得以運行的方法即可。對於剩下的,我只會使用其退化實現。
我的IDE對於創建這些mock類非常有幫助。它能夠自動化的構建方法原型,併爲那些接口或是抽象類所需要實現的方法給出退化的實現。
同樣的用類似方法創建出MockPageContext,MockHttpServletRequest以及MockHttpServletResponse類。
MockPageContext
package com.objectmentor.library.web.framework.mocks;
import javax.servlet.*;
import javax.servlet.http.*;
import javax.servlet.jsp.*;
import java.io.IOException;
import java.util.Enumeration;
public class MockPageContext extends PageContext {
private final JspWriter out;
private HttpServletRequest request;
public MockPageContext(JspWriter out) {
this.out = out;
request = new MockHttpServletRequest();
}
public JspWriter getOut() {
return out;
}
public ServletRequest getRequest() {
return request;
}
// lots of degenerate functions elided.
}
MockHttpServletRequest
package com.objectmentor.library.web.framework.mocks;
import javax.servlet.*;
import javax.servlet.http.*;
import java.io.*;
import java.security.Principal;
import java.util.*;
public class MockHttpServletRequest implements HttpServletRequest {
private String method;
private String contextPath;
private String requestURI;
private HttpSession session = new MockHttpSession();
private Map parameters = new HashMap();
private Map attributes = new HashMap();
public MockHttpServletRequest(String method, String contextPath,
String requestURI) {
super();
this.method = method;
this.contextPath = contextPath;
this.requestURI = requestURI;
}
public MockHttpServletRequest() {
this("GET");
}
public MockHttpServletRequest(String method) {
this(method, "/Library", "/Library/foo/bar.jsp");
}
public String getContextPath() {
return contextPath;
}
public String getMethod() {
return method;
}
public String getRequestURI() {
return requestURI;
}
public String getServletPath() {
return requestURI.substring(getContextPath().length());
}
public HttpSession getSession() {
return session;
}
public HttpSession getSession(boolean arg0) {
return session;
}
public Object getAttribute(String arg0) {
return attributes.get(arg0);
}
public String getParameter(String arg0) {
return (String) parameters.get(arg0);
}
public Map getParameterMap() {
return parameters;
}
public Enumeration getParameterNames() {
return null;
}
public void setSession(HttpSession session) {
this.session = session;
}
public void setParameter(String s, String s1) {
parameters.put(s, s1);
}
public void setAttribute(String name, Object value) {
attributes.put(name, value);
}
// Lots of degenerate methods elided.
}
MockHttpServletResponse
package com.objectmentor.library.web.framework.mocks;
import javax.servlet.ServletOutputStream;
import javax.servlet.http.*;
import java.io.*;
import java.util.Locale;
public class MockHttpServletResponse implements HttpServletResponse {
// all functions are implemented to be degenerate.
}
有了這些mock對象,現在我就可以創建一個loanRecords_jsp的servlet實例並且開始調用它!我的頭一個測試用例就像下面這樣:
public void testSimpleTest() throws Exception {
MockJspWriter jspWriter = new MockJspWriter();
MockPageContext pageContext = new MockPageContext(jspWriter);
JspFactory.setDefaultFactory(new MockJspFactory(pageContext));
HttpJspBase jspPage = new loanRecords_jsp();
HttpServletRequest request = new MockHttpServletRequest();
HttpServletResponse response = new MockHttpServletResponse();
jspPage._jspInit();
jspPage._jspService(request, response);
assertEquals("", jspWriter.getContent());
}
就像預期的一樣,測試失敗了。這是因爲還有些內容還沒補充上,不過所剩無多。如果你仔細的看過Jsp文件,你就會發現它調用了request.getAttribute(“loanRecords”)並且期望返回一個List。但因爲目前的測試並未爲這樣的屬性賦值,從而導致了代碼拋出了異常。
要想成功讓servlet輸出HTML,我們還需要加載這個屬性。然後,我們就可以使用HtmlUnit來解析此HTML並且編寫相應的單元測試。
HtmlUnit非常的容易使用,尤其是在測試所產生的像是本例這樣的web pages上。我這裏還有篇文章詳細的介紹了它。
下面就是最終測試加載屬性的測試,它通過htmlunit來檢測HTML,並且做出正確的判斷:
package com.objectmentor.library.jspTest.books.patrons.books;
import com.gargoylesoftware.htmlunit.*;
import com.gargoylesoftware.htmlunit.html.*;
import com.objectmentor.library.jsp.WEB_002dINF.pages.patrons.books.loanRecords_jsp;
import com.objectmentor.library.utils.*;
import com.objectmentor.library.web.controller.patrons.LoanRecord;
import com.objectmentor.library.web.framework.mocks.*;
import junit.framework.TestCase;
import org.apache.jasper.runtime.HttpJspBase;
import javax.servlet.*;
import javax.servlet.http.*;
import javax.servlet.jsp.*;
import java.util.*;
public class LoanRecordsJspTest extends TestCase {
private MockPageContext pageContext;
private MockJspWriter jspWriter;
private JspFactory mockFactory;
private MockHttpServletResponse response;
private MockHttpServletRequest request;
private WebClient webClient;
private TopLevelWindow dummyWindow;
protected void setUp() throws Exception {
jspWriter = new MockJspWriter();
pageContext = new MockPageContext(jspWriter);
mockFactory = new MockJspFactory(pageContext);
JspFactory.setDefaultFactory(mockFactory);
response = new MockHttpServletResponse();
request = new MockHttpServletRequest();
webClient = new WebClient();
webClient.setJavaScriptEnabled(false);
dummyWindow = new TopLevelWindow("", webClient);
}
public void testLoanRecordsPageGeneratesAppropriateTableRows() throws Exception {
HttpJspBase jspPage = new loanRecords_jsp();
jspPage._jspInit();
List<LoanRecord> loanRecords = new ArrayList<LoanRecord>();
addLoanRecord(loanRecords,
"99",
"Empire",
DateUtil.dateFromString("2/11/2007"),
new Money(4200));
addLoanRecord(loanRecords,
"98",
"Orbitsville",
DateUtil.dateFromString("2/12/2007"),
new Money(5200));
request.setAttribute("loanRecords", loanRecords);
jspPage._jspService(request, response);
StringWebResponse stringWebResponse = new StringWebResponse(jspWriter.getContent());
HtmlPage page = HTMLParser.parse(stringWebResponse, dummyWindow);
HtmlElement html = page.getDocumentElement();
HtmlTable table = (HtmlTable) html.getHtmlElementById("loanRecords");
List<HtmlTableRow> rows = table.getHtmlElementsByTagName("tr");
assertEquals(3, rows.size());
assertEquals("even", classOfElement(rows.get(1)));
assertEquals("odd", classOfElement(rows.get(2)));
List<HtmlTableDataCell> firstRowCells = rows.get(1).getCells();
assertEquals(4, firstRowCells.size());
List<HtmlTableDataCell> secondRowCells = rows.get(2).getCells();
assertEquals(4, secondRowCells.size());
assertLoanRecordRowEquals("99", "Empire", "02/11/2007", "$42.00", firstRowCells);
assertLoanRecordRowEquals("98", "Orbitsville", "02/12/2007", "$52.00", secondRowCells);
}
private String classOfElement(HtmlTableRow firstDataRow) {return firstDataRow.getAttributeValue("class");}
private void assertLoanRecordRowEquals(String id, String title, String dueDate, String fine, List<HtmlTableDataCell> rowCells) {
assertEquals(id, rowCells.get(0).asText());
assertEquals(title, rowCells.get(1).asText());
assertEquals(dueDate, rowCells.get(2).asText());
assertEquals(fine, rowCells.get(3).asText());
}
private void addLoanRecord(List<LoanRecord> loanRecords, String id, String title, Date dueDate, Money fine) {
LoanRecord loanRecord = new LoanRecord();
loanRecord.id = id;
loanRecord.title = title;
loanRecord.dueDate = dueDate;
loanRecord.fine = fine;
loanRecords.add(loanRecord);
}
private class MockJspFactory extends JspFactory {
private PageContext pageContext;
public MockJspFactory(PageContext pageContext) {
this.pageContext = pageContext;
}
public PageContext getPageContext(Servlet servlet, ServletRequest servletRequest, ServletResponse servletResponse, String string, boolean b, int i, boolean b1) {
return pageContext;
}
public void releasePageContext(PageContext pageContext) {
}
public JspEngineInfo getEngineInfo() {
return null;
}
}
}
上述的測試確保了所生成的HTML中表格中的每一行都具有正確的內容。這項測試確實能夠測出是否存在這樣的表格,並且判斷出是否表格的每一行是按照正確的順序來展現的。同時,它也確保了每一行的相應style。測試忽略了此外的表單以及語法部分。
結論
這篇發表在此的技術能夠用來測試幾乎所有目前我們所見過的web頁面,並且脫離容器,也無需web server的運行。相對來說,它也比較容易去設置,並且非常易於擴展。有了它,你就可以快速的進行編輯、編譯、測試的週期性迭代,並且你也能遵循測試驅動開發的原則了。
(原文鏈接網址: http://blog.objectmentor.com/articles/category/testing-guis; Robert C. Martin的英文blog網址: http://blog.objectmentor.com/)
作者簡介:Robert C. Martin是Object Mentor公司總裁,面向對象設計、模式、UML、敏捷方法學和極限編程領域內的資深顧問。他不僅是Jolt獲獎圖書《敏捷軟件開發:原則、模式與實踐》(中文版)(《敏捷軟件開發》(英文影印版))的作者,還是暢銷書Designing Object-Oriented C++ Applications Using the Booch Method的作者。Martin是Pattern Languages of Program Design 3和More C++ Gems的主編,並與James Newkirk合著了XP in Practice。他是國際程序員大會上著名的發言人,並在C++ Report雜誌擔任過4年的編輯。