stone2083

IBatis下DAO单元测试另类思路

在说另类思路之前,先说下传统的测试方法:
0.准备一个干净的测试数据库环境
  这个是前提
1.测试数据准备
  使用文本,excel,或者wiki等,准备测试sql以及测试数据
  利用dbfit,dbutil等工具将准备的测试数据导入到数据库中
2.执行dao方法
  执行被测试的dao方法
3.测试结果断言
  利用dbfit,dbutil等工具,断言测试结果数据和预计是否一致
4.所有数据回滚

其实,对于这个流程来说,目前的dao测试框架,支持的已经比较完美了
但是此类测试方法,也有明显的缺点(或者不能叫缺点,叫使用比较麻烦的地方)
如下:
1.背上了一个数据库环境.
  不轻量
  这是一个共享环境,谁也无法确保环境数据是否真正的干净
2.测试数据准备是一件麻烦的事情
  新表,10几个字段毫不为奇;老表,50几个字段甚至百来个字段,也偶有可见;无论是使用文本,excel,wiki,准备工作量,都是巨大的.
  准备的数据,部分字段内容可以是无意义的,部分字段内容又是需要符合测试意图(testcase设计目的),部分字段还是其他表的关联字段.从而导致后续维护人员无法了解准备数据意图.
  (实践中,也出现过,一同事在维护他人单元测试时,由于无法了解测试数据准备意图,宁可重新删除,自己准备一份)
3.预计结果数据准备也是一件麻烦的事情
  理由如上

所以,理论上是完美的测试方案,在实践过程中,却是一件麻烦的事情.导致DAO单元测试维护困难.


分析了现状,我们再来分析下,IBatis下DAO,程序员主要做了哪些编码:
1. 写了一份sqlmap.xml配置文件
2. 通过getSqlMapClientTemplate.doSomething($sqlID,$param), 执行语句
(当然,没有使用spring的同学,也是使用了类似sqlMapClient.doSomething($sqlID,$param)方法)

而步骤2其实是框架替我们做了的事情,按照MOCK的思想,其实这部分代码可以被MOCK的,那么我们是否可以做如下假设:
只要sqlmap.xml中配置信息(主要包括resultmap和statement)是正确的,那么执行结果也应该是正确的.

而我所谓的另类思路,就是基于这个假设,得出的:
IBatis下,DAO单元测试,我们抛弃背负的数据库环境,只要根据不同的条件,断言不同的sql即可.

于是乎,封装了一个IbatisSqlTester,可以根据sqlmap中的statement和传入的条件参数,生成sql语句.
那么,DAO单元测试就简单了,脱离下数据库环境:
public class ScoreDAOTest extends TestCase {
 
    @SpringBeanByName
    
private IbatisSqlTester ibatisSqlTester;  //通过spring配置,需要注入sqlmapclient对象
 
    @Test
    
public void testListTpScores() {
        Map
<String, Object> param = new HashMap<String, Object>(1);
        param.put(
"memberIds"new String[] { "stone""stone2083" });
        SqlStatement sql 
= ibatisSqlTester.test("MS-LIST-SCORES", param);
        
// sql全部匹配
        SqlAssert.isEqual("select * from score where member_id in ('stone','stone2083')", sql.toString());
        
// sql包含member_id,athena2002,stone关键词
        SqlAssert.keyWith(sql.toString(), "member_id""stone""stone2083");
        
// sql符合某个 正则
        SqlAssert.regexWith(".* where member_id in .*", sql.toString());
        
        
//其中,SqlAssert也可以换 成want.string()中的方法.
    }
}

优势:
  脱离了数据库环境
  脱离了表结构数据准备
  脱离了预计结果数据准备
  让单元测试变成sql的断言,编写相对更简单
缺点:
 
row mapper过程无法被测试


最后,附上两个核心的代码类(还未完成),供大家参考:
SqlStatement.java
/**
 * <pre>
 * SqlStatement:Sql语句对象.
 * 包含:
 *  1.sql语句,类似  select * from offer where id = ? and member_id = ?
 *  2.参数值,类似 [1,stone2083]
 *  
 *  toString方法,返回执行的sql语句,如:
 *  select * from offer where id = '1' and member_id = 'stone2083'
 * </pre>
 * 
 * 
@author Stone.J 2010-8-9 下午02:55:36
 
*/
public class SqlStatement {

    
//sql
    private String   sql;
    
//sql参数
    private Object[] param;

    
/**
     * <pre>
     * 输出最终执行的sql内容.
     * 将sql和param进行merge,产生最终执行的sql语句
     * </pre>
     
*/
    @Override
    
public String toString() {
        
return merge();
    }

    
/**
     * <pre>
     * 将sql进行格式化.
     * 
     * 目前只是简单进行格式化.去除前后空格,已经重复空格
     * TODO:请使用统一格式化标准规,建议使用SqlFormater类,进行处理
     * </pre>
     * 
     * 
@param sql
     * 
@return
     
*/
    
protected String format(String sql) {
        
if (sql == null) {
            
return null;
        }
        
return sql.toLowerCase().trim().replaceAll("\\s{1,}"" ");
    }

    
/**
     * <pre>
     * 将sql和param进行merge.
     * TODO:请严格按照SQL标准,进行merge sql内容
     * </pre>
     
*/
    
protected String merge() {
        
if (param == null || param.length == 0) {
            
return this.sql;
        }
        String ret 
= sql;
        
for (Object p : param) {
            ret 
= ret.replaceFirst("\\?""'" + p.toString() + "'");
        }
        
return ret;
    }

    
public String getSql() {
        
return sql;
    }

    
public void setSql(String sql) {
        
this.sql = format(sql);
    }

    
public Object[] getParam() {
        
return param;
    }

    
public void setParam(Object[] param) {
        
this.param = param;
    }
}

IbatisSqlTester.java
/**
 * <pre>
 * IBtatis SQL 测试
 * 一般IBatis DAO单元测试,主要就是在测试ibatis的配置文件.
 * IbatisSqlTester将根据提供的Sql Map Id 和 对应的参数,返回 {
@link SqlStatement}对象,提供最终执行的sql语句
 * 通过外部SqlAssert对象,将预计Sql和实际产生的Sql进行对比,判断是否正确
 * </pre>
 * 
 * 
@author Stone.J 2010-8-9 下午02:58:46
 
*/
public class IbatisSqlTester {

    
// sqlMapClient
    private ExtendedSqlMapClient sqlMapClient;

    
/**
     * 根据提供的SqlMap ID,得到 {
@link SqlStatement}对象
     * 
     * 
@param sqlId: sql map id
     * 
@return @see {@link SqlStatement}
     
*/
    
public SqlStatement test(String sqlId) {
        
//得到MappedStatement对象
        MappedStatement ms = sqlMapClient.getMappedStatement(sqlId);
        
if (ms == null) {
            
//TODO:建议封转自己的异常对象
            throw new RuntimeException("can't find MappedStatement.");
        }

        
//按照Ibatis代码,得到Sql和Param信息
        RequestScope request = new RequestScope();
        ms.initRequest(request);
        Sql sql 
= ms.getSql();
        String sqlValue 
= sql.getSql(request, null);

        
//组转返回对象
        SqlStatement ret = new SqlStatement();
        ret.setSql(sqlValue);
        
return ret;
    }

    
/**
     * 根据提供的SqlMap ID和对应的param信息,得到 {
@link SqlStatement}对象
     * 
     * 
@param sqlId: sql map id
     * 
@param param: 参数内容
     * 
@return @see {@link SqlStatement}
     
*/
    
public SqlStatement test(String sqlId, Object param) {
        
//得到MappedStatement对象
        MappedStatement ms = sqlMapClient.getMappedStatement(sqlId);
        
if (ms == null) {
            
//TODO:建议封转自己的异常对象
            throw new RuntimeException("can't find MappedStatement.");
        }

        
//按照Ibatis代码,得到Sql和Param信息
        RequestScope request = new RequestScope();
        ms.initRequest(request);
        Sql sql 
= ms.getSql();
        String sqlValue 
= sql.getSql(request, param);
        Object[] sqlParam 
= sql.getParameterMap(request, param).getParameterObjectValues(request, param);

        
//组转返回对象
        SqlStatement ret = new SqlStatement();
        ret.setSql(sqlValue);
        ret.setParam(sqlParam);
        
return ret;
    }

    
/**
     * 设置SqlMapClient对象
     
*/
    
public void setSqlMapClient(ExtendedSqlMapClient sqlMapClient) {
        
this.sqlMapClient = sqlMapClient;
    }

    
/**
     * <pre>
     * 不推荐使用
     * 推荐使用: {
@link IbatisSqlTester#setSqlMapClient(ExtendedSqlMapClient)}
     * TODO:请去除这个方法,或者增加初始化的方式
     * </pre>
     * 
     * 
@param sqlMapConfig sqlMapConfig xml文件
     
*/
    
public void setSqlMapConfig(String sqlMapConfig) {
        InputStream in 
= null;
        
try {
            File file 
= ResourceUtils.getFile(sqlMapConfig);
            in 
= new FileInputStream(file);
            
this.sqlMapClient = (ExtendedSqlMapClient) SqlMapClientBuilder.buildSqlMapClient(in);
        } 
catch (Exception e) {
            
throw new RuntimeException("sqlMapConfig init error.", e);
        } 
finally {
            
if (in != null) {
                
try {
                    in.close();
                } 
catch (IOException e) {
                }
            }
        }
    }

}


最后的最后附上所有代码(通过单元测试代码,可以看如何使用).欢迎大家的讨论.
sqltester
builder

posted on 2010-08-12 09:03 stone2083 阅读(3514) 评论(9)  编辑  收藏 所属分类: java

Feedback

# re: IBatis下DAO单元测试另类思路[未登录] 2010-08-12 11:12 kylin

可以看看DDStep
http://www.ddsteps.org/  回复  更多评论   

# re: IBatis下DAO单元测试另类思路 2010-08-12 12:18 sgz

lz 很有想法  回复  更多评论   

# re: IBatis下DAO单元测试另类思路 2010-08-12 16:58 stone2083

@kylin
简单地看了下ddsteps,它是一套集成测试工具.主要包括:
dbunit,selenium,mock web(http) server,easymock和spring.
它对DB的支持,也是采用传统的方式.(没猜错的话,是使用了,最多封装了dbunit)
并不能解决我现在遇到的问题:
1.背负数据库环境
2.准备测试数据
3.准备预计结果数据
这些麻烦的工作量.

而且在公司中,也已经有一套测试框架,我们要做的,并不是选择框架(替换框架),而是选择一种合适敏捷单元测试的思路和方案. 让框架支持敏捷的方案而已.  回复  更多评论   

# re: IBatis下DAO单元测试另类思路 2010-08-12 17:02 stone2083

@sgz
大多数时候,想法是被现实逼出来的 :)
在我们这边,dao几乎没有复杂的业务逻辑,仅仅是对SqlMapClientTemplate的使用而已.
但是在针对DB的单元测试时,代价又是如此的巨大(主要还是在数据准备上).
成本,收益比,不划算,开发们抱怨多.

分析现状和开发们实际需求(写dao,主要是担心sql写错)后,才萌生了这个想法.  回复  更多评论   

# re: IBatis下DAO单元测试另类思路 2010-08-13 07:51 蓝剑

@stone2083
既然业务逻辑不复杂,那么准备的数据就不会很复杂,那还在乎这点工作量?
sql既然都能测试了,还在乎这点数据准备?
数据映射都不测试,还用ibatis干什么,直接jdbc就是了  回复  更多评论   

# re: IBatis下DAO单元测试另类思路 2010-08-13 08:31 stone2083

@蓝剑
DAO业务逻辑不复杂只指:在DAO方法中,不会有复杂的分支流程,往往只会调用一条SqlID执行sql.但是这不意味sql不复杂.
打个比方,报表的生成,业务逻辑非常简单(根据什么样的条件,能看到什么样的数据),但是sql绝对的复杂. :)
dao方法,也只会有一句 getSqlMapClientTemplate.queryForList("....",param); // 简单吧 :)

数据准备的工作量低吗?维护成本低吗?至少在我实践的项目中,没有像sample那样低(dbfit,dbunit,dbutil等sample,都是单表的说明,单表字段往往少于5个字段). 实际情况是:
1.字段多
2.表关联 (尤其在tree结构的表,父节点的依赖,光是这样的准备,都非常容易写错)
3.对于查询的语句(尤其是分页),需要根据动态条件,准备好需要的数据
4.数据准备意图需要被传承
在我看来,并且实践过来,挺不容易的.

sql的assert,只要根据条件参数的不同,做不同预计sql的assert,成本绝对比结果数据校验,来得低.

至于最后一点.
是用ibatis,还是jdbc;不是单元测试成本(方案)决定的;而是需求,应用,架构设计,部门岗位情况等决定的.
我们有专业的dba,对所有sql要做review,总不能给一堆jdbc的文件给他们吧.ibatis就挺好了.
再说了,对于ibatis下dao编码,错误率是sql写错的高呢?还是row mapper错误高呢?所以如果因为这点,来否决全部,挺不公平的.  回复  更多评论   

# re: IBatis下DAO单元测试另类思路 2010-08-13 08:46 stone2083

提供这个思路,并不是说替代之前的方案,更不是对传统测试方案的否定. 仅仅是为了多一种选择.

  回复  更多评论   

# re: IBatis下DAO单元测试另类思路[未登录] 2010-08-13 11:10 kylin

说说我们的单元测试:
DAO的代码是工具自动生成的,后台是用iBtais实现的,sqlmap也是自动生成,所以不用单元测试,因为模式都是一样的,开发人员不必写SQL。
Service API调用DAO接口,完成业务逻辑,这部分是需要做单元测试的,使用DDStep框架,测试的框架代码也是自动生成,开发人员在框架代码的基础上需要做以下几件事:
1.准备测试用例(准备数据库结构,测试数据输入)
2.编写结果校验代码(结果数据输出)
其中:准备数据库结构,测试数据输入都是通过excel完成,容易修改,不用编代码,看代码(DDStep框架提供的机制)

比较费时的就是校验代码的编写。  回复  更多评论   

# re: IBatis下DAO单元测试另类思路 2010-08-13 11:54 stone2083

@kylin
明白你们这边的情况了.

Service层的测试相对还是容易的,我们这边也有一套测试框架(类似DDSteps,也是对一些业界测试工具的整合加改进).并且对Service依赖的外部环境,都做了隔离,主要包括:
1.Mock Dao impl
2.Mock Core Service impl (外部核心业务服务)
3.Mock Search Engine impl
4.Mock Cach impl
....
测试重心,主要集中在Service内部逻辑的测试上.
而公司使用的测试框架很好的支持了这些需求.


难点还在DAO的测试上.
数据准备的复杂度,取决于表设计的复杂度和sql的复杂度.尤其在ibatis支持dynamic语句下,要准备覆盖测试sql语句的数据.挺繁琐的.
这并不是说使用excel,还是wiki等的问题.而是数据内容的准备上.

每个测试数据的准备,都是为了一个特定的testcase设计目的的.而当字段多,并且表设计相对复杂的时候,这个准备意图,挺难被传承下去的.
随着项目,小需求的进行,我们这边,差不多几十人,都有可能修改同一个sql代码 :(  回复  更多评论   


只有注册用户登录后才能发表评论。


网站导航: