开发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的第一步是将其编译为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;
<s