用 DbUnit 和 Anthill 控制测试环境
 

2009-03-16 作者:Philippe Girolami 来源:developerWorks

 

极限编程方法的兴起将测试驱动开发和持续集成带入了主流 Java 开发实践。如果没有采用正确的工具,在 Java 服务器端开发中使用这些技术很快会成为一场噩梦。在本文中,软件开发人员 Philippe Girolami 描述了如何处理持续集成,以及如何联合使用 DbUnit 和 JUnit,以便在每次测试之前通过设置数据库状态来端到端地控制测试环境。

软件开发中最重要的一种做法就是测试。通过推荐测试优先的开发和持续集成,极限编程(Extreme Programming,XP)将这一逻辑推到了极限,在这里测试是尽可能频繁地自动进行的。不过,大多数非 XP 开发都进行了某种形式的测试,也许称为非回归测试、黑箱测试、功能测试或者其他的名字。很多项目使用关系数据库存储数据,因而所有测试策略都需要考虑在每次测试过程中数据库中所发生的事情:如果测试使测试数据库处于不一致状态,那么后面的所有测试都可能失败!一种避免这种情况的方法是在每次测试之前将数据库状态设为一个已知的相关状态。在本文中,我将介绍我们的小组是如何结合 JUnit 使用 DbUnit 做到这一点的,以及如何用 Anthill 自动生成测试报告。尽管设置看起来很费功夫,但是实际上并不是这样,并且它已经证明自己是一个有用的工具。

表示数据库内容

DbUnit 扩展了 JUnit,它使数据库在测试之间处于一种已知状态,帮助避免造成后面的测试失败或者给出错误结果的问题,如果测试会破坏数据库就会出现这些问题。它可以读取表的内容并用 FlatXmlDataSet 将它在存储为 XML,如清单 1 所示:

清单 1. FlatXmlDataSet 示例

<dataset>
 <OPERATOR
   ID='APC (Washington/Baltimore)'
   CODE='ABC5APC'
   ENCODED_STRING='aabbcc'/>
 <OPERATOR
   ID='ASA Ritabell'
   CODE='ABC6ASA R'
   ENCODED_STRING='bbccdd'/>
 <OPERATOR
   ID='Advanced Info. Service PLC'
   CODE='ABC1Adva'
   ENCODED_STRING='ccddee'/>
 <OPE_OPERATOR
   ID='Aerial Communications Inc.'
   CODE='ABC2Aeri'
   ENCODED_STRING='ddeeff'/>
</dataset>

清单 1. FlatXmlDataSet 示例

<dataset>
 <OPERATOR
   ID='APC (Washington/Baltimore)'
   CODE='ABC5APC'
   ENCODED_STRING='aabbcc'/>
 <OPERATOR
   ID='ASA Ritabell'
   CODE='ABC6ASA R'
   ENCODED_STRING='bbccdd'/>
 <OPERATOR
   ID='Advanced Info. Service PLC'
   CODE='ABC1Adva'
   ENCODED_STRING='ccddee'/>
 <OPE_OPERATOR
   ID='Aerial Communications Inc.'
   CODE='ABC2Aeri'
   ENCODED_STRING='ddeeff'/>
</dataset>

这个数据集表示名为 OPE_OPERATOR 的数据库表中的三列,如表 1 中最后三行所描述的:

表 1. 清单 1 中数据的表定义

OPE_OPERATOR
ID INT
CODE VARCHAR
ENCODED_STRING VARCHAR

每个 XML 实体标识数据库中的一个表,而每个属性表示一列的值。

查询表的内容

DbUnit 使您可以容易地执行 JDBC 查询并获取它们的值。使用 DbUnit JDBC 包装器而不是纯粹的 JDBC 有几个理由:

可以用 SQL 查询创建一个 Dataset ,并使用 DbUnit 的 assertion(断言)方法(在后面描述)。

可以用 SQL 查询创建一个 Dataset ,并将它保存为一个 FlatXmlDataSet 。可以在以后将它重新装载到数据库中。

可以容易地从任何行中获取列的内容,无需进行迭代。

清单 2 中的代码创建一个结果 ITable,它包含了查询的结果。首先检查行计数是否为 1,然后检查第一行(从 0 开始计)中, FK_OTHER_ID 列包含数字 1234。

清单 2. DbUnit 的查询功能

String query = "SELECT * FROM MEDIA WHERE ID= "+id;
ITable databaseData =
dbConnection.createQueryTable("EXPECTED_DATA",query);
assertEquals(1, databaseData.getRowCount());
BigDecimal foreignKey = (BigDecimal) databaseData.getValue(0,
"FK_OTHER_ID");
assertEquals(new BigDecimal(1234)), foreignKey);

使用 assert 方法检查数据库内容

DbUnit 有断言方法,如清单 3 所示的那些,可以用于比较表的两组数据或者表的两个表示。如果需要在运行一次测试而不是多次查询后检查表的确切内容,一般会用它们。

清单 3. DbUnit 的附加断言方法

public static void assertEquals(ITable expected, ITable actual);
public static void assertEquals(IDataSet expected, IDataSet actual);

创建数据

根据数据库的大小、架构的稳定性如何以及开发的进展情况,可能要从头开始创建或者从生产数据库中拷贝测试数据。清单 4 显示了从已经存在的数据库中提取内容的例子(可以在 清单 6中找到 getConnection() 方法):

清单 4. 用现有的数据库创建 FlatXmlDataSets

public void extractTables(String targetDirectory,String[] tableNames)
  throws Exception {
  IDatabaseConnection connection = getConnection();
  for (int i = 0; i < tableNames.length; i++) {
    String tableName = tableNames[i];
    IDataSet partialDataSet = connection.createDataSet
                  (new String[] { tableName });
    FlatXmlDataSet.write
      (partialDataSet, new FileOutputStream
       (targetDirectory + "/" + tableName + ".xml"));
  }
}

如果导出一个完整的生产数据库,可能必须要删除过多的行--或者像在这里一样,用一个查询而不是直接用连接创建一个数据集。提取本身对于很大的表来说可能是个问题--我们的小组只能用查询提取某些表的一部分。从表中删除行也有些问题,主要涉及到浏览所有外键并保证数据的一致性的困难。

添加测试数据

添加测试数据有时可能乏味的。我们的经验是度过正确添加数据的最初困难阶段后,就可以达到这样一个层次,不仅添加数据变得容易了,而且对数据库结构的理解也有了极大提高。

即使使用 Enterprise JavaBeans (EJB) 技术隐藏数据库,这种第一手知识仍然非常有用。因为开发人员对数据库有了更好的理解,因而可以更快地检查其内容,从而使调试更容易了。这在重构代码和数据库时又会给予我们极大的帮助。

用 DbUnit 和 JUnit 创建基类

好的 JUnit 实践鼓励开发人员扩展基类 TestCase 以获得特化(specialization)行为。DbUnit 提供了自己的特化-- DatabaseTestCase ,通过它可以特化行为以满足自己的需要。

首先,创建一个名为 ProjectDatabaseTestCase 的基本测试用例,并向它添加实用工具方法,如清单 5 所示。然后,重新定义 setUp() 和 teardown() ,以使它们能够创建和销毁通过 DbUnit 到数据库的连接。

清单 5. 基类定义和设置数据库的基本方法

public class ProjectDatabaseTestCase extends DatabaseTestCase
{    
   /** Use this connection to perform database setup */  
   protected IDatabaseConnection connection;    
   public DatabaseTestCase (String s)
   {    
     super(s);     
   }  
  
   protected void setUp() throws Exception
   {    
     super.setUp();    
     connection = getDbUnitConnection();  
   }  
   protected void tearDown() throws Exception
   {    
     connection.close();    
     super.tearDown();  
   }
}

清单 6 显示了前述方法使用的类中的不同方法:

清单 6. 不同的实用工具方法

/**  
* This method returns a DbUnit database connection  
* based on the schema name  
*/  
private IDatabaseConnection getDbUnitConnection() throws Exception 
{        
  IDatabaseConnection connection = new DatabaseConnection (getJDBCConnection(), getSchemaName());
  return connection;  
}
 
private IDataSet getFlatXmlDataSet(String tableName) throws Exception
{    
  URL url = DatabaseTestCase.class.getResource( "/"+ tableName + ".xml");    
  if (url == null)      
    throw new Exception("could not find file for " + tableName);
   
  File file = new File(url.getPath());
  return new FlatXmlDataSet(file);
}
 
/** Implement yourself */
private Connection getJDBCConnection() throws Exception
{
  /* Get your JDBC connection through a data source of JDBC itself */
}
* Implement yourself */
private Connection getSchemaName() throws Exception
{
}

上述代码中应当注意的一些内容:

没有显示 getJDBCConnection() 方法,因为它的实现取决于希望如何获得 JDBC 连接:当 DataSource 为 Serializable 时通过应用服务器的 JNDI 树,或者直接使用 JDBC。

getDbUnitConnection() 方法返回 DbUnit 的一个到数据库的连接。DbUnit 的 DatabaseConnection 构造函数可以带一个 schema 名作为参数。这样,就不必在所有表名前面加上 schema 名的前缀了。

getFlatXmlDataSet() 方法用位于类路径上的一个 XML 文件的内容创建 DbUnit 数据集。

最后,该实际将数据插入测试表中。DbUnit 可以有不同的数据库操作,我使用了其中的两种:

DELETE_ALL ,它删除表中所有行。

CLEAN_INSERT ,它删除表中所有行并插入数据集提供的行。

ProjectDatabaseTestCase 中的下面四个方法可以满足您的需要:

insertFileIntoDb() :在数据库中插入文件。

emptyTable() :清理数据库表。

insertAllFilesIntoDb() :插入项目的所有文件。

emptyAllTables() :清理项目的所有表。

清单 7 显示了这些方法的使用:

清单 7. 底层测试用来设置数据库的方法

/** A method to insert all tables into the database.  
* Specify all tables to be inserted  
*/  
protected void insertAllFilesIntoDb() throws Exception
{      
  insertFileIntoDb("PRODUCT");  
  (...)  
  insertFileIntoDb("ACCOUNT");    
}    
  
/**   
 * This method inserts the contents of a FlatXmlDataSet file  
 * into the connection  
 */  
 protected void insertFileIntoDb(String tableName) throws Exception
 {    
   DatabaseOperation.CLEAN_INSERT.execute(connection,getFlatXmlDataSet(tableName));  
 }     
  
 /** Empty a table */  
 protected void emptyTable(String tableName) throws Exception
 {    
   IDataSet dataSet = new DefaultDataSet(new DefaultTable(tableName));
   DatabaseOperation.DELETE_ALL.execute(connection, dataSet);  
 }  
  
 /** Empty all the tables from the database  */
 protected void emptyAllTables() throws Exception
 {    
   emptyTable("ACCOUNT");    
   (...)   
   emptyTable("PRODUCT");  
 }

合到一起

完成了基类后,用 DbUnit 干净地建立数据库,执行一个方法,并检查返回值是很容易的事,如清单 8 所示:

清单 8. 在实际的测试用例中合到一起

public void setUp() throws Exception  
{    
  super.setUp();    
  emptyAllTables();    
  service = Service.getInstance();  
}  
  
public void testFindProductByPrimaryKey() throws Exception  
{    
  insertFileIntoDb("PRODUCT");        
  ProductDTO productDTO = service.findProductByPrimaryKey(new Integer(12));    
  assertNotNull(productDTO);
  assertEquals("product Name", productDTO.getName());
}
public void testCreateAProduct() throws Exception  
{    
  service.createProduct("newly created product name");
  String query = "SELECT * FROM PRODUCT";
  ITable databaseData = dbConnection.createQueryTable("EXPECTED_DATA",query);
  assertEquals(1, databaseData.getRowCount());
  String productName = (String) databaseData.getValue(0, "NAME");
  assertEquals("newly created product name", productName);
}

在这个测试中,我清空了数据库,插入一个表的内容,并通过检查它返回的元素是否有正确的属性来检查用主键查找产品的 finder 方法是否正常工作。然后测试对象创建工作,并用 DbUnit 的查询程序验证数据库的内容。

需要注意的一件重要事情是,清理数据库是在建立测试而不是结束时进行的。我不想依赖于每次测试都干净地结束。

在插入数据时要关注的事情

数据库完整性约束迫使您以给定的顺序插入或者删除数据。在编写 insertAllFiles() 和 emptyAllTables() 方法时,您会发现顺序并非是随意的,事实上它是由完整性约束所限定的。

另一个潜在的陷井是,一些列可能看来没有插入。几乎总是会出现这种情况,因为在 FlatXmlDataSet 中的第一行缺少一列。看来 DbUnit 不能识别所有其他行中的这一列。例如,插入清单 9 中定义的数据集会使表 ACC_ACCOUNT 包含两行,它们惟一的非空列是主键 PK_ACC_ID :

清单 9. NAME of ACCOUNT 列的第二行会缺失

<dataset>
  <ACCOUNT ID='1' /> 
  <ACCOUNT ID='2' NAME='first name' />
</dataset>

总是要保证第一行描述包含表中的所有列。如果需要插入一个 NULL 值,要使这一行成为第二行,如清单 10 所示:

清单 10. NAME of ACCOUNT 列的第二行将不会缺失

<dataset>
  <ACCOUNT ID='2' NAME='first name' />
  <ACCOUNT ID='1' />
</dataset>

组织测试数据

DbUnit 可以在文件中存储 XML 数据集。它甚至允许在一个文件中存储整个数据库。清单 11 显示了表 ACCOUNT 和 MEDIA 的内容:

清单 11. 两个表的 FlatXmlDataSet 示例

<dataset>
  <ACCOUNT NAME='first name' />
  <ACCOUNT NAME='second name' />
 
  <MEDIA ID='123' />
  <MEDIA ID='234' />
</dataset>

决定如何存储测试数据很重要。是将每一个表的内容存储到单独的文件中,还是将与系统主要实体有关的所有表的所有行存储到一个文件中?它们都不是完美的解决方案(silver bullet)。

对于第一种情况,保证跨表的数据一致更困难,但是用一个已经存在的数据库创建查询更容易。在第二种情况下,为每一个测试创建测试集更容易,但是事实上大多数系统不是围绕一个主要实体设计的,因此这使它不那么实用。我们的方法是每个表有一个文件。

在 Anthill 中运行测试包并报告结果

XP专家一直建议将持续集成作为确保减少集成错误一种方式:通过以足够高的频率集成所有代码,保证容易追溯到源代码中的问题。集成可能是非常耗时的任务--检查、构建和部署代码,然后运行验收试验。幸运的是,其中大多数可以用 Anthill 或者 CruiseControl 这样的工具自动化。如果还没有使构建过程自动化(例如用 Ant),那您应当这样做。如果构建过程是自动化的,应当在构建中加入一个测试部分。如果您是顽固的 XP 用户,这些应当是您的验收测试。如果您像我们一样,那么这些就是您要编写的所有测试--不管是单元、验收或者其他测试。

我们的构建过程基于 Ant 并计划使用 Anthill。我们的主要挑战是让 Anthill 报告失败的测试并且仍然发布测试结果。Anthill 捕获的是:如果构建脚本失败,就不执行发布脚本,在这种情况下不能将测试报告提供给开发人员。我们的方法是让 Anthill 检查属性为 true 还是 false,而使它在发布脚本的最后才失败。

运行测试的目标

下面是关于运行测试的简要总结。我们使用的是最批量化的方法,但是任何方法都可以工作。要点有:

测试必须具有分支,以便在类路径包含 JDK 1.3 中的 XML 解析器时可以正常工作。

如果出现错误或者失败,则 testsuite.error 和 testsuite.failure 属性必须设置为 true。如果没有错误或者失败的话,则不改变它们。

清单 12 显示了运行特定模块的所有测试的例子:

清单 12. 运行一个模块的测试

<target name="test-common">
  <mkdir dir = "${project.reports}/common"/>
 
  <junit fork="true" errorproperty="testsuite.error" failureproperty="testsuite.failure">
   <classpath>
     <pathelement location="${out.classes.dir}"/>
     <fileset dir = "${shared.lib.dir}">
      <patternset refid="necessary.jars"/>
     </fileset>
   </classpath>
   
   <formatter type="xml"/>
   <batchtest todir="${project.reports}/common">
     <fileset dir="${out.src.dir}">
      <include name="**/Test*.java"/>  
     </fileset>
   </batchtest>
  </junit>
</target>

使测试结果可被发布脚本使用

清单 13 显示了如何在编译过程中运行所有测试:

清单 13. build.xml 的代码片段:运行所有的测试并设置结果

<target name = "all-tests" depends = "test-module1,test-module2">
  <property name="testsuite.error" value="false"/>
  <property name="testsuite.failure" value="false"/>
  <propertyfile file="${deployDir}/tests.results">
   <entry key="testsuite.error" value="${testsuite.error}"/>
   <entry key="testsuite.failure"
value="${testsuite.failure}"/>
  </propertyfile>
</target>

一个需要了解的重要的 Ant 技巧是,Ant 只在属性没有值时才设置属性的值。所以在依次运行每一个测试时, testsuite.error 和 testsuite.failure 属性只有当出现错误或者失败时才会是 true。

这里的困难是向主 Ant 脚本报告测试脚本的结果。不幸的是,这并不是一项简单的任务,因为在 Anthill 的过程中有两个不同的 Ant 构建文件,在 Ant 中不能在构建脚本之间传递这种参数。不过,有一个“简单”的解决方案:将测试的结果保存到文件中,之后发布脚本读取这个文件。

清单 13 使用了这种 Ant 技巧,它显示了如何使用 <property> 命令保证 testsuite.error 和 testsuite.failure 属性在测试脚本结束时总是有一个值,以及如何将它保存为文件。

如果测试失败,使发布脚本在结束时失败

用清单 14 让发布脚本在任何测试失败时均失败。这只不过是为了检查在构建脚本中保存的每一个属性是否为 true。

清单 14. 当有错误或者失败时使发布脚本失败

<condition property="must.fail"> 
 <or>  
  <istrue value="${testsuite.error}"/>
  <istrue value="${testsuite.failure}"/>
 </or>
</condition>
<fail message="Tests didn't run 100%. Check the log and make
necessary changes!" if="must.fail"/>

结束语

我们的小组成功地在 2003 年初引入了 DbUnit 和 Anthill。从那以后,我们编写并自动化了上千次测试--其中 75% 涉及设置数据库状态。我们每小时运行一次测试,并计划很快以更短的周期运行它们。它们捕获了很多未预料到的缺陷,这使它们成为不可缺少的工具。


火龙果软件/UML软件工程组织致力于提高您的软件工程实践能力,我们不断地吸取业界的宝贵经验,向您提供经过数百家企业验证的有效的工程技术实践经验,同时关注最新的理论进展,帮助您“领跑您所在行业的软件世界”。
资源网站: UML软件工程组织