在公司维护的项目使用的框架很老(内部自研,基于Spring2实现的),单元测试框架使用的JUnit3。日常工作开发调试和自测两种办法:启动服务(weblogic,要打包启动,慢)、单元测试(较快,调试方便)。但老的写单测实在是很繁琐:先继承一个单元测试基类,覆盖其中获取配置文件方法(相当于配置context文件),再在另外两个配置文件中修改(与业务耦合的很紧),然后开始从context中getBean,然后你的准备工作终于做好了可以开始测试了。尤其对于新同事,有人指导还行,没有的话简直抓瞎(当然如果深入了解一下,也是能轻易搞定的,比如我哈哈哈)。思来想去决定:controller的单测,可以简化步骤(比如获取controller bean然后再调用对应方法这一步);加入自动依赖注入,就像使用
@Autowired
一样(当前项目中还是使用的全XML配置方式);将配置集中起来一个地方管理(使用注解);升级到JUnit4.12。
JUnit4的ClassRunner
基于JUnit4的扩展,主要是利用其提供的ClassRunner,JUnit4.12默认的是BlockJUnit4ClassRunner
,于是我们扩展该类,看看能在这里做点什么。
首先来看必须覆盖的构造器,构造参数clazz就是当前测试类的class。除了调用父类构造器,在此处还加了一步Pafa3TestContext.initContext
,初始化Ioc容器,以及保存一些测试时需要的上下文信息。
然后注意createTest
这个方法,事实上JUnit会根据测试class生成对应的实例。之前说过还实现了自动DI,那么很显然这一步在生成instance之后做再合适不过了,具体就是prepareAutoInject
方法,至此自动DI已经实现,在测试类里@AutoInject private SomeController controller
就可以直接获取到bean了,当然也提供了可以根据id获取bean。
public class Pafa3Junit4ClassRunner extends BlockJUnit4ClassRunner {
public Pafa3Junit4ClassRunner(Class<?> clazz) throws Exception {
super(clazz);
Pafa3TestContext.initContext(getTestClass().getJavaClass());
}
@Override
protected Object createTest() throws Exception {
Object instance = super.createTest();
prepareAutoInject(instance);
return instance;
}
private void prepareAutoInject(Object instance) throws IllegalAccessException {
TestClass testClass = getTestClass();
List<FrameworkField> frameworkFields = testClass.getAnnotatedFields(AutoInject.class);
for (FrameworkField frameworkField : frameworkFields) {
Object bean;
String beanName = frameworkField.getAnnotation(AutoInject.class).value();
if (!"".equals(beanName)) {
bean = Pafa3TestContext.getContext().getBean(beanName);
} else {
Class<?> beanType = frameworkField.getType();
Map beansOfType = Pafa3TestContext.getContext().getBeansOfType(beanType, true, true);
Iterator it = beansOfType.values().iterator();
if (it.hasNext()) {
bean = it.next();
} else {
throw new NoSuchBeanDefinitionException(beanType, "no bean type found");
}
}
Field field = frameworkField.getField();
field.setAccessible(true);
field.set(instance, bean);
}
}
}
public class Pafa3TestContext {
private static ApplicationContext context;
private static String[] contextLocations;
private static String[] sqlConfigLocations;
private static Class<?> clazz;
private Pafa3TestContext() {
}
public static void initContext(Class<?> clazz) {
Pafa3TestContext.clazz = clazz;
initConfigLocations();
initContext();
}
public static ApplicationContext getContext() {
return context;
}
public static String[] getContextLocations() {
return contextLocations;
}
public static String[] getSqlConfigLocations() {
return sqlConfigLocations;
}
private static void initConfigLocations() {
ContextLocations annotation = clazz.getAnnotation(ContextLocations.class);
if (annotation == null) {
throw new IllegalStateException("test class should be annotated with ContextLocations");
}
sqlConfigLocations = annotation.sqlMap();
String[] locations = annotation.context();
int len = locations.length;
// 业务定制的,为了少写俩,直接先写死吧
contextLocations = Arrays.copyOf(locations, len + 2);
contextLocations[len] = "classpath:biz-context.xml";
contextLocations[len + 1] = "classpath:common-context.xml";
}
private static void initContext() {
if (context == null) {
synchronized (Pafa3TestContext.class) {
if (context == null) {
context = new ClassPathXmlApplicationContext(getContextLocations());
}
}
}
}
}
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@Inherited
public @interface ContextLocations {
String[] context();
String[] sqlMap() default {};
}
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
@Inherited
public @interface AutoInject {
String value() default "";
}
MockMvc直接对接口发起请求
原来对controller的测试是要先获取这个controller的bean,然后调用接口实际对应的方法。这里其实复杂了,因为bean都是同一个类型的,获取哪一个并没有区别。如果有给定接口,实际已经得到了实际要调用的方法,这个对应关系,也是定义在一个MethodNameResolver
类型的bean里的,显然可以从我们的Pafa3TestContext
里获取到(因为这时候已经初始化好了)。
public class MockMvcResult {
private ModelAndView modelAndView;
private String content;
public MockMvcResult(ModelAndView modelAndView, String content) {
this.modelAndView = modelAndView;
this.content = content;
}
public Object getModel() {
return modelAndView == null ? null : modelAndView.getModel();
}
public Object getView() {
return modelAndView == null ? null : modelAndView.getView();
}
public String getContentAsString() {
return content;
}
}
public interface MockMvc {
MockMvcResult request() throws Exception;
}
public class StandaloneMockMvc implements MockMvc {
private final ApplicationContext context = Pafa3TestContext.getContext();
private final String url;
private final MockHttpServletRequest request;
private final MockHttpServletResponse response;
public StandaloneMockMvc(StandaloneMockMvcBuilder builder) {
this.url = builder.getUrl();
this.request = builder.getRequest();
this.response = builder.getResponse();
}
@Override
public MockMvcResult request() throws Exception {
Map beanMap = context.getBeansOfType(MethodNameResolver.class, true, true);
if (beanMap == null || beanMap.isEmpty()) {
throw new NoSuchBeanDefinitionException(MethodNameResolver.class, "ensure add the web context file");
}
String methodName = null;
Iterator it = beanMap.values().iterator();
while (it.hasNext() && methodName == null) {
MethodNameResolver resolver = (MethodNameResolver) it.next();
try {
methodName = resolver.getHandlerMethodName(request);
} catch (NoSuchRequestHandlingMethodException ignored) {
}
}
if (methodName == null) {
throw new NoSuchRequestHandlingMethodException(request);
}
Object controller = context.getBean(url);
return dispatchRequest(methodName, controller);
}
private MockMvcResult dispatchRequest(String methodName, Object controller) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException {
Method handleMethod = controller.getClass().getDeclaredMethod(methodName, HttpServletRequest.class, HttpServletResponse.class);
Object result = handleMethod.invoke(controller, request, response);
if (result == null) {
return new MockMvcResult(null, response.getContentAsString());
}
if (ModelAndView.class.isAssignableFrom(result.getClass())) {
return new MockMvcResult((ModelAndView) result, null);
}
return null;
}
}
public class StandaloneMockMvcBuilder {
private static final String SESSION_USER = "userinformation";
private final String url;
private final String method;
private final MockHttpServletRequest request;
private final MockHttpServletResponse response;
public StandaloneMockMvcBuilder(String url) {
this("GET", url);
}
public StandaloneMockMvcBuilder(String method, String url) {
this.url = url;
this.method = method;
this.request = new MockHttpServletRequest(null, this.method, this.url);
this.response = new MockHttpServletResponse();
}
public StandaloneMockMvcBuilder addParameter(String name, String value) {
request.addParameter(name, value);
return this;
}
public String getUrl() {
return url;
}
public String getMethod() {
return method;
}
public MockHttpServletRequest getRequest() {
return request;
}
public MockHttpServletResponse getResponse() {
return response;
}
public StandaloneMockMvcBuilder withUser(String uid) {
UserInformationVO user = new UserInformationVO();
user.setUID(uid);
return withUser(user);
}
public StandaloneMockMvcBuilder withUser(UserInformationVO user) {
request.getSession().setAttribute(SESSION_USER, user);
return this;
}
public StandaloneMockMvc build() {
return new StandaloneMockMvc(this);
}
}
至此,我们可以直接构造对应的URL以及相关参数,使用MockMvc
发起请求等待结果了。
桥接ibatis的bean
以上两点完成后,还差一个连接数据库的bean。项目中使用的是ibatis,读取的sqlmap是定义在一个sqlmap-config.xml里,该配置包含所有的sqlmap(按功能模块分的),然后由SqlMapClientFactoryBean
来读取sqlmap-config.xml。由于配置都集中管理在ContextLocations
注解里了,所以这里也需要重新实现,用了一个小聪明,直接根据配置的sqlMapConfig生成一个XML内容交给SqlMapClientFactoryBean
去读取。
public class SimpleSqlMapClientFactoryBean extends SqlMapClientFactoryBean {
@Override
public void afterPropertiesSet() throws IOException {
Resource configLocation = getSqlConfigResource();
super.setConfigLocation(configLocation);
super.afterPropertiesSet();
}
private Resource getSqlConfigResource() {
String[] configLocations = Pafa3TestContext.getSqlConfigLocations();
if (configLocations == null || configLocations.length == 0) {
return new ClassPathResource("sqlmap-config.xml");
}
return builtXMLResource(configLocations);
}
private Resource builtXMLResource(String[] configLocations) {
final String xmlAsString = buildSqlMapConfigContent(configLocations);
return new AbstractResource() {
@Override
public InputStream getInputStream() throws IOException {
return new ByteArrayInputStream(xmlAsString.getBytes("UTF-8"));
}
@Override
public String getDescription() {
return "XML built as string: " + xmlAsString;
}
};
}
private String buildSqlMapConfigContent(String[] configLocations) {
Document document = DocumentHelper.createDocument();
document.setXMLEncoding("UTF-8");
document.addDocType("sqlMapConfig", "-//iBATIS.com//DTD SQL Map Config 2.0//EN", "http://www.ibatis.com/dtd/sql-map-config-2.dtd");
Element sqlMapConfig = document.addElement("sqlMapConfig");
Element setting = sqlMapConfig.addElement("settings");
setting.addAttribute("cacheModelsEnabled", "true");
setting.addAttribute("enhancementEnabled", "false");
setting.addAttribute("lazyLoadingEnabled", "false");
setting.addAttribute("maxRequests", "3000");
setting.addAttribute("maxSessions", "3000");
setting.addAttribute("maxTransactions", "3000");
setting.addAttribute("useStatementNamespaces", "true");
for (String location : configLocations) {
Element sqlMap = sqlMapConfig.addElement("sqlMap");
sqlMap.addAttribute("resource", location);
}
return document.asXML();
}
}
Web到App的路由
项目是分层部署的,分为了Web(DMZ区)和App(内网)两层,前者就是controller所在,然后远程调用App层的Action(通过EJB)。在本地单元测试,显然不会去构造一个EJB容器环境,而是直接通过本地同一个JVM调用即可(项目中调用的bean的名字是写死的),于是实现一个本地的ApplicationController
。
public class AppControllerFactoryBean implements FactoryBean {
private ApplicationController proxy;
@Override
public Object getObject() throws Exception {
if (proxy == null) {
proxy = getProxy();
}
return proxy;
}
@Override
public Class getObjectType() {
return proxy != null ? proxy.getClass() : ApplicationController.class;
}
@Override
public boolean isSingleton() {
return true;
}
private ApplicationController getProxy() {
return (ApplicationController) Proxy.newProxyInstance(Thread.currentThread().getContextClassLoader(),
new Class[]{ApplicationController.class}, new LocalProxyAppControllerInvocationHandler());
}
}
public class LocalProxyAppControllerInvocationHandler implements InvocationHandler {
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
String methodName = method.getName();
Class<?>[] parameterTypes = method.getParameterTypes();
if (method.getDeclaringClass() == Object.class) {
throw new UnsupportedOperationException("unsupported method: " + method);
}
if ("toString".equals(methodName) && parameterTypes.length == 0) {
return "proxy of ApplicationController";
}
if ("hashCode".equals(methodName) && parameterTypes.length == 0) {
return 1;
}
if ("equals".equals(methodName) && parameterTypes.length == 1) {
return Boolean.FALSE;
}
if (args.length != 1 || !(args[0] instanceof ServiceRequest)) {
throw new IllegalArgumentException("arguments length not 1 or not type of ServiceRequest");
}
return invokeLocal((ServiceRequest) args[0]);
}
private Object invokeLocal(ServiceRequest request) throws BusinessServiceException {
String beanName = request.getRequestedServiceID();
Action action = (Action) Pafa3TestContext.getContext().getBean(beanName);
return action.perform(request);
}
}
写完之后发现,似乎不用动态代理,直接实现ApplicationController
就行了= =||。不过鉴于都写出来了,暂时先用着吧。主要是提醒看代码的同志,toString
, equals
, hashCode
三个方法,在动态代理时也是会被代理的。
后记
大功告成,现在写单元测试的效率比之前提高的简直不要太多。终于不用东配置一下西添加一下了(而且有两个还是重复的),对团队的提升自我感觉还是比较多的。但是有啥借鉴的么?我觉得没啥,都是被老项目老框架逼出来的轮子,毕竟新框架直接上Spring的test即可,功能强大好用。顺便吐槽一下公司:老项目难升级情有可原,但是2017年新启动的项目,还有必要继续jdk1.6 + weblogic + spring3.1吗?