欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  IT编程

基于JUnit4扩展老项目的UT框架且自动DI

程序员文章站 2022-05-08 17:29:19

在公司维护的项目使用的框架很老(内部自研,基于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吗?