这里假设我们没有 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 请求,对接口进行测试了!