记一次系统测试实践

背景

其实并不是一次规范的系统测试,而是对一个已经有两年时间的工程的主要逻辑进行补充测试。由于之前一直都是靠开发者和测试者的人力纯手工测试~并没有单元测试和集成测试代码。随着不断迭代新需求,业务功能的回归测试压力也越来越大。于是,为了快速发现新功能的加入对旧逻辑的影响,决定做一个程序运行的测试方法,来补充人工测试。

这个应用程序是一款手机App的服务端程序,基于spring开发,手机端与服务器主要通过json、fb等数据格式交互。服务端有统一的处理方法,首先将前端请求转为RequestObject,然后经过业务逻辑构造ResponseObject,最后将ResponseObject转换后发给前端。

这次系统测试的思路就是使用终端程序发起各种请求,服务端录制RequestObject和ResponseObject,测试时通过回放RequestObject对比新旧ResponseObject的差异来发现程序改动。嗯~看上去还行,不是很复杂,然而从决定做的那一刻开始便走上了一条荆棘之路。

过程

1.既然要录制回放,那首先考虑的是哪些数据需要录制并在回放时需要使用?

  • 前面提到的RequestObject和ResponseObject是必然要录制的。
  • 相同的请求得到的响应结果与系统环境和参数配置息息相关,所以环境数据也是要录制的。主要来自mysql和properties文件。
  • 每个请求都是app用户操作自己的数据的过程,所以用户数据也需要录制。主要来自mongodb。

2.接下来考虑如何在不侵入源码的前提下,进行数据录制和回放?

说到不侵入就会想到面向方面编程(AOP)。spring是AOP的集大成者,而此工程恰好基于spring框架开发,只是版本低了点,3.0.5,但足够了。通过查阅资料选择spring + aspectj来实现。

  • spring的aop配置。
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns:aop="http://www.springframework.org/schema/aop"
       http://www.springframework.org/schema/aop
       http://www.springframework.org/schema/aop/spring-aop-3.0.xsd">

    ......

    <aop:aspectj-autoproxy />

</beans>
  • 源工程中的ActionDispatcher类用于请求分发和返回响应,因此在这里切入录制RequestObject和ResponseObject。
    private static Object lock = new Object();
    
    public static int index = -1;    //一次请求的序号
    
    public static int subindex = 0;  //一次请求中的mongodb访问序号
    
    private boolean begin = false;
     
    @Pointcut("execution(public * com.mfp.common.action.ActionDispatcher.dispatch(..))")
    public void action_dispatch() {};

    @Around("action_dispatch()")
    public Object around_action_dispatch(ProceedingJoinPoint pjp) throws Throwable {
        //让请求依次处理,消除并发带来的错位录制
        synchronized (lock) {
            //初始化
            if(begin == false){
                index = 0;
                subindex = 0;
                begin = true;
            }
            
            //正式开始
            Object obj = null;
            try {
                obj = pjp.proceed();
                
                //暂存req和resp到当前环境
                AspectDataCtl.addRecord(index, "req", (pjp.getArgs()[0]).toString());
                AspectDataCtl.addRecord(index, "resp", obj.toString());
                index ++;
                subindex = 0;
            } catch (Throwable e) {
                e.printStackTrace();
            }
            return obj;
        }
    }
  • 源工程中使用mybatise访问mysql,并且全部通过SqlSessionTemplate类操作。因此通过继承SqlSessionTemplate重写相关方法来录制和回放来自mysql的数据。
    @Pointcut("execution(public * com.mfp.common.dao.SqlSessionAdapter.getSession(..))")
    public void mysql_session() {};

    @Around("mysql_session()")
    public Object around_mysql_session(ProceedingJoinPoint pjp) throws Throwable {
        Object obj = pjp.proceed();
        MfpSqlSessionAdapter adapter = new MfpSqlSessionAdapter(((SqlSessionTemplate)obj).getSqlSessionFactory());
        return adapter;
    }
public class MfpSqlSessionAdapter extends SqlSessionTemplate {
    
    public static Map<String, Object[]> fakeList = new HashMap<>();
    
    public static int mode = 0;  //0从db读,1从fakelist读

    public MfpSqlSessionAdapter(SqlSessionFactory sqlSessionFactory) {
        super(sqlSessionFactory);
    }

    @Override
    public List<?> selectList(String statement) {
        if(mode == 0){
            List<?> result = super.selectList(statement);
            if(result != null && result.size() != 0){
                fakeList.put(statement, new Object[]{result.get(0).getClass().getName(), result});
            }
            return result;
        }else if(mode == 1){
            return fakeList.get(statement) == null ? null : (List<?>)fakeList.get(statement)[1];
        }
        return super.selectList(statement);
    }

    ......
}
  • 源工程中使用的是mongodb-java-driver-2.x版本,所有访问mongodb的行为都要获取DBCollection实例进行。因此通过继承DBCollection实现一个封装真实DBCollection实例的代理类来录制和回放来自mongodb的数据。
    @Pointcut("execution(public * com.mfp.individual.core.segdao.*.getCollection(..))")
    public void mongo_collection() {};

    @Around("mongo_collection()")
    public Object around_mongo_collection(ProceedingJoinPoint pjp) throws Throwable {
        if(MfpDBCollectionAdapter.mode == 0){  //录制模式
            Object obj = pjp.proceed();
            MfpDBCollectionAdapter adapter = new MfpDBCollectionAdapter((DBCollection)obj);
            return adapter;
        }else if(MfpDBCollectionAdapter.mode == 1){  //回放模式
            DBCollection coll = new Mongo().getDB("test").getCollection((String)pjp.getArgs()[0]);
            MfpDBCollectionAdapter adapter = new MfpDBCollectionAdapter(coll);
            return adapter;
        }
        return pjp.proceed();
    }
package com.mongodb

import org.apache.commons.codec.digest.DigestUtils;
import net.sf.json.JSONArray;
import net.sf.json.JSONObject;

public class MfpDBCollectionAdapter extends DBCollection {
    
    public static int mode = 0;  //0从db读写,1从fake读或写比较
    
    // <1, <key, [input, output]>>
    public static final Map<Integer, Map<String, Object[]>> fakeMap = new HashMap<>();
    
    DBCollection dbCollection;
    
    public MfpDBCollectionAdapter(DBCollection dbCollection){
        super(dbCollection._db, dbCollection.getName());
        this.dbCollection = dbCollection;
    }

    @Override
    Iterator<DBObject> __find( DBObject ref , DBObject fields , int numToSkip , int batchSize, int limit , int options, ReadPreference readPref, DBDecoder decoder )
        throws MongoException {
        //计算key,忽略{a:1,b:0}和{a:0,b:1}这样错位相同的情况,将对象转为json字符串,再将其取字符数组,元素排序后转回字符串
        char[] chs = JSONArray.fromObject(new DBObject[]{ref, fields}).toString().toCharArray();
        Arrays.sort(chs);
        String outkey = dbCollection.getName() + "|" + "|find|out" + Arrays.toString(chs) + numToSkip + batchSize + limit + options;
        String md5outkey = DigestUtils.md5Hex(outkey) + "_" + JellyAspect.subindex;
        JellyAspect.subindex ++;
        
        if(mode == 0){
            Iterator<DBObject> it = dbCollection.__find(ref, fields, numToSkip, batchSize, limit, options, readPref, decoder);
            
            List<DBObject> result = null;
            if(it != null){
                result = new ArrayList<>();
                while(it.hasNext()){
                    result.add(it.next());
                }
            }
            
            //按序号暂存查询结果
            AspectDataCtl.addRecord(JellyAspect.index, md5outkey, result);
            
            it = dbCollection.__find(ref, fields, numToSkip, batchSize, limit, options, readPref, decoder);
            
            return it;
        }else if(mode == 1){
            //按照key找到相关数据返回
            List<DBObject> result = (List<DBObject>)AspectDataCtl.getRecord(JellyAspect.index, md5outkey);
            
            if(result == null){
                return null;
            }
            
            return result.iterator();
        }
        
        return dbCollection.__find(ref, fields, numToSkip, batchSize, limit, options, readPref, decoder);
    }

    @Override
    public WriteResult update( DBObject query , DBObject o , boolean upsert , boolean multi , com.mongodb.WriteConcern concern, DBEncoder encoder )
        throws MongoException {
        //计算key
        char[] chs = JSONArray.fromObject(new DBObject[]{query, o}).toString().toCharArray();
        Arrays.sort(chs);
        String inkey = dbCollection.getName() + "|" + "|update|in" + Arrays.toString(chs) + upsert + multi;
        String md5inkey = DigestUtils.md5Hex(inkey) + "_" + JellyAspect.subindex;
        JellyAspect.subindex ++;
        
        if(mode == 0){
            //持久化均认为成功,不必记录结果,回放时只要key相同即为同样的保存操作
            AspectDataCtl.addRecord(JellyAspect.index, md5inkey, 1);
            
            WriteResult wr = dbCollection.update(query, o, upsert, multi, concern, encoder);
            
            return wr;
        }else if(mode == 1){
            //检查是否存在此操作,如果不存在则抛出异常
            AspectDataCtl.checkNotFoundRecord(JellyAspect.index, md5inkey, dbCollection.getName() + " | update | " + query.toString());
            
            return null;
        }
        
        return dbCollection.update(query, o, upsert, multi, concern, encoder);
    }

    ......
}

3.数据拿到了,那该用什么格式存储录制的数据?

经过尝试最终选择jackson作为对象和json格式字符串互转工具。考虑因素如下:

  • 对象转字符串时,值为null的属性转换后仍然为null,不会是其他的(如””、[]、{}等)。
  • 字符串转对象时,支持设置直接使用Field赋值,不用set方法(因为源工程中有的set方法不规范)。
  • 支持泛型和数组。
    //注意:以下是jackson1.6.4版本的写法
    public static <T> T jsonToObj(String jsonStr, Class<T> valueType) {
        ObjectMapper mapper = new ObjectMapper();
        mapper.setVisibilityChecker(mapper.getSerializationConfig().getDefaultVisibilityChecker()
                .withFieldVisibility(JsonAutoDetect.Visibility.ANY).withGetterVisibility(JsonAutoDetect.Visibility.NONE)
                .withSetterVisibility(JsonAutoDetect.Visibility.NONE)
                .withCreatorVisibility(JsonAutoDetect.Visibility.NONE));
        try {
            return mapper.readValue(jsonStr, valueType);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }
    
    public static <T> T jsonToObj(String jsonStr, Class<List> collectionType, Class<?> elementType) {
        ObjectMapper mapper = new ObjectMapper();
        mapper.setVisibilityChecker(mapper.getSerializationConfig().getDefaultVisibilityChecker()
                .withFieldVisibility(JsonAutoDetect.Visibility.ANY).withGetterVisibility(JsonAutoDetect.Visibility.NONE)
                .withSetterVisibility(JsonAutoDetect.Visibility.NONE)
                .withCreatorVisibility(JsonAutoDetect.Visibility.NONE));
        try {
            return mapper.readValue(jsonStr, TypeFactory.collectionType(collectionType, elementType));
        } catch (Exception e) {
            e.printStackTrace();
            System.out.println(jsonStr);
        }
        return null;
    }
    
    public static String objToJson(Object object) {
        ObjectMapper objectMapper = new ObjectMapper();
        try {
            return objectMapper.writeValueAsString(object);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }

4.至此似乎圆满了,跑一个试试……呵呵,发现一个严重问题,依赖本地时间的逻辑都错了,因为录制和回放时使用的本地时间是不一样的,如何解决?

为了使回放环境与录制环境一致,还需要记录下录制时的时间,回放时将本地时间改为录制时间。因为依赖本地时间调用的逻辑基本上都是日期敏感,所以为回放时修改时间来保证逻辑正确创造了可行条件。因此在录制结束后额外记录下了当时的时间(毫秒数:System.currentTimeMillis())。

那么接下来该怎么在回放时修改本地时间,显然这个操作要在spring框架初始化之前完成。直接修改系统时间,Test执行完再改回去?不可行~受限于操作系统权限。

其实所有用到本地时间调用的只有三个方法:

  • System.currentTimeMillis()
  • Calendar.getInstance()
  • new Date()

那么可不可以“复写”他们?其实如果使用jdk8的Clock类来代替上面三个方法,将很容易做到获取本地时间时是指定时间。所以以后开发中如果低于jdk8的版本就自己封装个工具类,如果是基于jdk8就使用Clock吧~

Clock.getInstance().getCalendarInstance()
Clock.getInstance().newDate()
Clock.getInstance().currentTimeMillis()

但现在做整体替换显然行不通,只能找找看有没有其他办法。经过查阅,找到一个可以mock他们的第三方工具:PowerMock。他可以mock很多类和方法,功能很强大。

        long tms = AspectDataCtl.tms;
        
        //System.currentTimeMillis()
        PowerMockito.mockStatic(System.class);
        PowerMockito.when(System.currentTimeMillis()).thenReturn(tms);

        //Calendar.getInstance()
        PowerMockito.mockStatic(Calendar.class);
        PowerMockito.when(Calendar.getInstance()).thenAnswer(new Answer<Calendar>(){
            @Override
            public Calendar answer(InvocationOnMock invocation) throws Throwable {
                Calendar c = new GregorianCalendar();
                c.setTimeInMillis(tms);
                return c;
            }
        });

        //new Date()
        try {
            PowerMockito.whenNew(Date.class).withNoArguments().thenAnswer(new Answer<Date>(){
                @Override
                public Date answer(InvocationOnMock invocation) throws Throwable {
                    Date d = new Date();
                    d.setTime(tms);
                    return d;
                }
            });
        } catch (Exception e) {
            e.printStackTrace();
        }

在执行TestCase时首先加载PowerMock,然后初始化spring,这样源工程中在调用上面三个获取本地时间的方法时会返回指定的时间。

5.其他小问题?

  • 除了时间外还有时区设定问题,如果录制环境与回放环境处于不同时区,那么json格式化Date类型时产生的字符串结果不同,所以要将回放环境的时区设置为与录制环境相同。
        if(AspectDataCtl.timezone != null){
            TimeZone.setDefault(TimeZone.getTimeZone(AspectDataCtl.timezone));
        }else{
            TimeZone.setDefault(TimeZone.getTimeZone("GMT+8"));
        }
  • 通常在JUnit测试用例中,PowerMock和Spring的加载都需要使用@RunWith注解,如何解决冲突?
@RunWith(PowerMockRunner.class)
@PowerMockRunnerDelegate(SpringJUnit4ClassRunner.class)
public class CaseSystemLoadTest{
    ......
}
  • 运行测试用例时报以下错误,Caused by: java.lang.LinkageError: loader constraint violation: loader (instance of org/powermock/core/classloader/MockClassLoader) previously initiated loading for a different type with name “javax/management/MBeanServer”
@PowerMockIgnore("javax.management.*")
public class CaseSystemLoadTest{
    ......
}
  • 只有在@PrepareForTest注解里配置的类在执行时才会使用mock的类,遇到需要配置大量类的时候如何使用包名加通配符的方式进行配置?
//@PrepareForTest({Class1.class,Class2.class})
@PrepareForTest(fullyQualifiedNames={
        "com.mfp.common.util.DateUtil", "com.mfp.jelly.action.*", "com.mfp.jelly.service.*", "com.mfp.jelly.vo.*"})
public class CaseSystemLoadTest{
    ......
}
  • Jacoco与PowerMock存在冲突,使用@RunWith(PowerMockRunner.class)时@PrepareForTest()里的类覆盖率为0,如何解决?
//@RunWith(PowerMockRunner.class)
public class CaseSystemLoadTest{

    @Rule
    public PowerMockRule rule = new PowerMockRule();

    ......
}

但是上面这种方式会使通配符失效,并且不能在spring初始化前执行PowerMock代码。所以推荐用Cobertura换掉Jacoco来统计代码覆盖率。

  • 如何比较两个ResponseObject是否相同?

不同功能的ResponseObject的结构有很大不同,其中的data字段有一定深度,如果按相同字段比较其值的方式进行相对复杂。我们采用一种不是很精准的办法,首先将其转换成json字符串,但由于key是无序的所有不能直接比较,然后将其转为字符数组,再对数组元素排序,排序后的字符数组如果相同就认为两个ResponseObject相同。这里忽略{a:1,b:0}和{a:0,b:1}这类错位相同情况,实际上这种情况可以认为不会发生。

            String str_s = resp_s.getData() == null ? "{}" : resp_s.getData().toString();
            char[] ch_str_s = str_s.toCharArray();
            Arrays.sort(ch_str_s);

            String str = resp.getData() == null ? "{}" : resp.getData().toString();
            char[] ch_str = str.toCharArray();
            Arrays.sort(ch_str);

            if(Arrays.toString(ch_str).equals(Arrays.toString(ch_str_s)) == false){
                ......
            }
  • JUnit测试用例在Eclipse中运行通过,但是用maven运行失败,如何解决?

pom.xml中的maven-surefire-plugin或maven-surefire-report-plugin的configuration增加如下配置

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-surefire-plugin</artifactId>
    <version>2.19.1</version>
    <configuration>
        <reuseForks>false</reuseForks>
        <forkCount>1</forkCount>
    </configuration>
</plugin>
Creative Commons License

本文基于署名-非商业性使用-相同方式共享 4.0许可协议发布,欢迎转载、使用、重新发布,但请保留文章署名wanghengbin(包含链接:https://wanghengbin.com),不得用于商业目的,基于本文修改后的作品请以相同的许可发布。

发表评论