动机
曾经使用许多方法在数据库和目标代码之间传输数据。从手动编码的SQL到JDO,然后再到EJB,我从未找到一种特别喜欢的方法。自从采用测试驱动开发(TDD)作为指导原则以来,这种不满情绪变得更加强烈。
单元测试的障碍应尽可能少。在关系数据库中,障碍的范围从外部依赖(数据库在运行吗?)到保持关系模型和对象模型同步的速度。由于这些原因,保持数据库访问代码与核心对象模型分离且无需涉及真实数据库而进行尽可能多的测试是很重要的。
通常这会导致我们进入下面两种模式之一。第一种是具体化所有访问域对象的数据以及数据与单独类或接口之间的关系。这就是典型的能够检索、编辑、删除和添加域实体的数据存储对象。这在单元测试中是最容易模拟出来的,但趋向于把域模型对象作为不带有任何关系行为的纯数据对象。直接从父对象访问子记录是最理想的,而不是将父对象处理为第三方类来决定子记录。
其他方法已经使访问接口的域对象进入数据映射层(一种la Martin Fowler的数据映象模式)。这具有推动域模型中的对象关系的优点,在域模型中,对象关系型接口只需表达一次即可。使用域模型的类不支持持久性机制,因为它本身内在化到域模型中。这使代码集中在设法解决的业务问题,而很少关注对象关系型映射机制。
我的当前项目涉及到处理大量的棒球统计数据,并使用这些数据进行模拟。因为数据已经在关系数据库中,所以对于我来说,有机会开发 Hibernate对象关系型映射系统。我曾对Hibernate有很深刻的印象,但我遇到的一个问题是,在使用Hibernate进行单元测试的数据映射时,设法插入一个间接层。该附加层非常脆弱,编写起来感到非常困难。实际部署版本简单地通过了特定于Hibernate的实现。更坏的情况是,模拟版本比真正的“产品级”版本更复杂,只因为模拟版本里没有基本对象存储器和带有Hibernate的映射。
我也使用很多复杂的Hibernate查询,想要对应用程序的重要部分进行单元测试。然而,对活动的数据库进行测试不是好主意,因为这几乎总是产生维护问题。另外,由于测试最好互相独立,在测试上下文数据中使用相同的主键意味着必须在每次测试前创建代码来清理数据库,当涉及到大量关系时就成为一个实际问题。
通过使用HSQLDB和Hibernate强大的模式生成工具,能够对应用程序映射层进行单元测试,并在对象查询中找到不计其数的bug,这在以前手工测试时是做不到的。利用下面的技术概述,可以在开发过程中对整个应用程序进行测试,并且在测试有效区域内没有损害。
设置HSQLDB
以前使用HSQLDB 1.7.3.0 版。为了使用数据库的内存版本,需要激活org.hsqldb.jdbcDriver的静态加载程序。当获得JDBC连接时,就可以使用JDBC url例如jdbc:hspldb:mem:yourdb,这里’yourdb’就是想要使用的内存数据库的名称。
因为使用Hibernate (3.0 beta 4),所以我几乎无需接触实际活动的JDBC对象。相反,我可以让Hibernate完成很多繁重的任务,包括从Hibernate映射文件中自动创建数据库模式。因为Hibernate创建自身专有的连接池,所以它会基于TestSchema类中的配置代码自动加载HSQLDB JDBC驱动程序。下面就是该类的静态的初始化程序。
public class TestSchema { static { Configuration config = new Configuration(). setProperty("hibernate.dialect", "org.hibernate.dialect.HSQLDialect"). setProperty("hibernate.connection.driver_class", "org.hsqldb.jdbcDriver"). setProperty("hibernate.connection.url", "jdbc:hsqldb:mem:baseball"). setProperty("hibernate.connection.username", "sa"). setProperty("hibernate.connection.password", ""). setProperty("hibernate.connection.pool_size", "1"). setProperty("hibernate.connection.autocommit", "true"). setProperty("hibernate.cache.provider_class", "org.hibernate.cache.HashtableCacheProvider"). setProperty("hibernate.hbm2ddl.auto", "create-drop"). setProperty("hibernate.show_sql", "true"). addClass(Player.class). addClass(BattingStint.class). addClass(FieldingStint.class). addClass(PitchingStint.class); HibernateUtil.setSessionFactory(config.buildSessionFactory()); }
Hibernate提供了许多不同的方式来配置该框架,包括程序方面的配置。上述代码设置了连接池。注意,使用HSQLDB的内存数据库需要用户名'sa’。还样要确保指定一个空格作为口令。为了启动Hibernate的自动模式生成功能,需设置hibernate.hbm2ddl.auto属性为’creat-drop’。
实际测试我的项目是处理将大量的棒球数据,所以我添加了四个进行映射的类(Player、PintchingStint、,BattingSint和 FieldStint)。最后创建Hibernate的会话工厂,并将其插入HibernateUtil类,该类只为Hibernate会话的整个应用程序提供一个访问方法。HibernateUtil的代码如下:
import org.hibernate.*; import org.hibernate.cfg.Configuration; public class HibernateUtil { private static SessionFactory factory; public static synchronized Session getSession() { if (factory == null) { factory = new Configuration().configure().buildSessionFactory(); } return factory.openSession(); } public static void setSessionFactory(SessionFactory factory) { HibernateUtil.factory = factory; } }
因为所有代码(经过单元测试的产品级代码)都是从HibernateUtil获取Hibernate会话,所以能在同一个位置对其进行配置。为了对代码的第一位进行单元测试而访问TestSchema类将会激活静态初始化程序,该程序将安装Hibernate并且将测试 SessionFactory插入到HibernateUtil中。对于产品级代码,可以使用标准hibernate.cfg.xml配置机制来初始化 SessionFactory。
那么单元测试中的外部特征是什么?下面的测试代码片段是用来检查逻辑的,决定运动员在棒球联盟比赛中是哪个位置的人选:
public void testGetEligiblePositions() throws Exception { Player player = new Player("playerId"); TestSchema.addPlayer(player); FieldingStint stint1 = new FieldingStint("playerId", 2004, "SEA", Position.CATCHER); stint1.setGames(20); TestSchema.addFieldingStint(stint1); Set<Position> positions = player.getEligiblePositions(2004); assertEquals(1, positions.size()); assertTrue(positions.contains(Position.CATCHER)); }
第一次创建新Player实例并通过addPlayer()方法添加到TestSchema中。必须首先完成此步骤,因为 FidldStint类和Player类之间有外键关系。如果不首先添加该实例,在设法添加FieldingStint时将会出现外键约束违例。一旦测试上下文就位,就可以测试getEligiblePositions()方法来检索校正数据。下面是在TsetSchema中addPlayer()方法的代码。您将注意到使用Hibernate而不是bare-metal JDBC代码:
public static void addPlayer(Player player) { if (player.getPlayerId() == null) { throw new IllegalArgumentException("No primary key specified"); } Session session = HibernateUtil.getSession(); Transaction transaction = session.beginTransaction(); try { session.save(player, player.getPlayerId()); transaction.commit(); } finally { session.close(); } }
在单元测试中最重要的就是要保持测试实例是独立的。因为该方法仍然涉及数据库,所以需要一种方法在每个测试实例之前清理数据库。在我的数据库架构中有四个表,所以我在TestSchemaz上编写了reset()方法,该方法从使用JDBC的表中删除所有行。注意,因为HSQLDB能识别外键,删除表的顺序是很重要的,下面是代码:
public static void reset() throws SchemaException { Session session = HibernateUtil.getSession(); try { Connection connection = session.connection(); try { Statement statement = connection.createStatement(); try { statement.executeUpdate("delete from Batting"); statement.executeUpdate("delete from Fielding"); statement.executeUpdate("delete from Pitching"); statement.executeUpdate("delete from Player"); connection.commit(); } finally { statement.close(); } } catch (HibernateException e) { connection.rollback(); throw new SchemaException(e); } catch (SQLException e) { connection.rollback(); throw new SchemaException(e); } } catch (SQLException e) { throw new SchemaException(e); } finally { session.close(); } }
当确定在Hibernate 3.0中进行大量删除操作时,应该能从应用程序中删除直接JDBC的最后一位。到此时为止,必须获取数据库连接并向数据库直接提交SQL。在确保没有关闭连接的情况下,为了释放资源,只关闭会话就足够了。出于手工编写许多JCBC代码来进行开发的习惯,第一个版本关闭了JDBC连接。因为通过配置Hibernate创建的连接池只带有一个链接,在第一个之后就完全破坏了测试。一定要注意这种情况!既然在测试类运行时(设想运行所有的测试实例)不能确定数据库的状态,应该在setUp()方法中包含数据库清除,如下所示:
public void setUp() throws Exception { TestSchema.reset(); }结束语
在使用像Hibernate这种复杂的O/R映射程序时,必须能够测试实际存在(real-live)的RDBMS,而不会发生任何针对已部署数据库的争论。虽然Hibernate有内置模式生成工具,让此类测试特别简单,但是在这里展示的例子并不排除Hibernate,并且可能与JDO或TopLink一起运行。使用上面描述的设置,您不必离开舒适的IDE环境,但仍然可以对代码进行大量测试。
April 2005
Discussion
The Motivation
I've used lots of methods to transform data between databases and object code. From hand-coded SQL to JDO to EJB. I've never found a method I liked particularly well. This distaste has become especially acute since adopting test-driven development (TDD) as a guiding philosophy.
Unit-testing should have as few barriers as possible. For relational databases those barriers range from external dependencies (is the database running?) to speed to keeping the relational schema synchronized with your object model. For these reasons it is vital to keep database access code away from the core object model and to test as much as possible without touching a real database.
This has often led me to one of two patterns. The first is externalizing all data access to domain objects and their relationships to separate classes or interfaces. These are typically data store objects that can retrieve, edit, delete and add domain entities. This is the easiest to mock-out for unit-testing, but tends to leave your domain model objects as data-only objects with little or no related behavior. Ideally access to child records would be directly from the parent object rather than handing the parent object to some third-party class to determine the children.
The other method has been to have the domain objects have access to an interface into the data-mapping layer a la Martin Fowler’s Data Mapper pattern. This has the advantage of pushing object relationships inside the domain model where the object-relational interface can be expressed once. Classes that use the domain model are unaware of the persistence mechanism because it is internalized into the domain model itself. This keeps your code focused on the business problem you are trying to solve and less about the object-relational mapping mechanism.
My current project involves crunching a number of baseball statistics and running simulations with the data. Since the data was already in a relational database it was a chance for me to explore the Hibernate object-relational mapping system. I have been very impressed with Hibernate, but I ran into the problem was trying to insert a layer of indirection while using Hibernate as my data mapper for unit-testing. The extra layer was so flimsy that it felt embarrassing to write it. The real deployed version was simply a pass-through to a Hibernate-specific implementation. Even worse, the mock versions had more complexity in them than the real "production" version simply because they didn't have some of the basic object storage and mapping that came with Hibernate.
I also had enough complex Hibernate query usage that I wanted to unit-test this significat portion of the application. However, testing against a ‘live’ database is a bad idea, because it almost invariably introduces a maintenance nightmare. In addition, since tests are best when they are independent from each other, using the same obvious primary keys in test fixture data means you have to create code to clean the database before each test case, which is a real problem when lots of relationships are involved
By using HSQLDB and Hibernate's powerful schema-generation tool I was able to unit-test the mapping layer of the application and find numerous bugs in my object queries I would not have found as easily by manual testing. With the techniques outlines below I was able unit-test my entire application during development with no compromises in test coverage.
Setting up HSQLDB
I used version 1.7.3.0 of HSQLDB. To use an in-memory version of the database you need to invoke the static loader for the org.hsqldb.jdbcDriver. Then when you get a JDBC connection you use JDBC url such as jdbc:hsqldb:mem:yourdb where 'yourdb' is the name of the in-memory database you want to use.
Since I'm using Hibernate (3.0 beta 4), I hardly ever need to touch real-live JDBC objects. Instead I can let Hibernate do the heavy lifting for me--including automatically creating the database schema from my Hibernate mapping files. Since Hibernate creates its own connection pool it will automatically load the HSQLDB JDBC driver based on the configuration code lives in a class called TestSchema. Below is the static initializer for the class.
public class TestSchema { static { Configuration config = new Configuration(). setProperty("hibernate.dialect", "org.hibernate.dialect.HSQLDialect"). setProperty("hibernate.connection.driver_class", "org.hsqldb.jdbcDriver"). setProperty("hibernate.connection.url", "jdbc:hsqldb:mem:baseball"). setProperty("hibernate.connection.username", "sa"). setProperty("hibernate.connection.password", ""). setProperty("hibernate.connection.pool_size", "1"). setProperty("hibernate.connection.autocommit", "true"). setProperty("hibernate.cache.provider_class", "org.hibernate.cache.HashtableCacheProvider"). setProperty("hibernate.hbm2ddl.auto", "create-drop"). setProperty("hibernate.show_sql", "true"). addClass(Player.class). addClass(BattingStint.class). addClass(FieldingStint.class). addClass(PitchingStint.class); HibernateUtil.setSessionFactory(config.buildSessionFactory()); }
Hibernate provides a number of different ways to configure the framework, including programmatic configuration. The code above sets up the connection pool. Note that the user name 'sa' is required to use HSQLDB's in-memory database. Also be sure to specify a blank as the password. To enable Hibernate's automatic schema generation set the hibernate.hbm2ddl.auto property to 'create-drop'.
Testing In Practice
My project is crunching a bunch of baseball statistics so I add the four classes that I'm mapping ( Player, PitchingStint, BattingStint and FieldingStint). Finally I create a Hibernate SessionFactory and insert it into the HibernateUtil class which simply provides a single access method for my entire application for Hibernate sessions. The code for the HibernateUtil is below:
import org.hibernate.*; import org.hibernate.cfg.Configuration; public class HibernateUtil { private static SessionFactory factory; public static synchronized Session getSession() { if (factory == null) { factory = new Configuration().configure().buildSessionFactory(); } return factory.openSession(); } public static void setSessionFactory(SessionFactory factory) { HibernateUtil.factory = factory; } }
Since all of my code (production code as well as unit-tests) get their Hibernate sessions from the HibernateUtil I can configure it in one place. For unit-tests the first bit of code to access the TestSchema class will invoke the static initializer which will setup Hibernate and inject the test SessionFactory into the HibernateUtil. For production code the SessionFactory will be initialized lazily using the standard hibernate.cfg.xml configuration mechanism.
So what does this look like in the unit-tests? Below is a snippet of a test that checks the the logic for determining what positions a player is eligible to play at for a fantasy baseball league:
public void testGetEligiblePositions() throws Exception { Player player = new Player("playerId"); TestSchema.addPlayer(player); FieldingStint stint1 = new FieldingStint("playerId", 2004, "SEA", Position.CATCHER); stint1.setGames(20); TestSchema.addFieldingStint(stint1); Set<Position> positions = player.getEligiblePositions(2004); assertEquals(1, positions.size()); assertTrue(positions.contains(Position.CATCHER)); }
I first create a new Player instance and add it to the TestSchema via the addPlayer() method. This step must occur first because the FieldingStint class has a foreign-key relationship to the Player class. If I didn't add this instance first I would get a foreign-key constraint violation when I try to add the FieldingStint. Once the test-fixture is in place I can test the getEligiblePositions() method to see that it retrieves the correct data. Below is the code for the addPlayer() method in the TestSchema. You will notice that Hibernate is used instead of bare-metal JDBC code:
}
public static void addPlayer(Player player) { if (player.getPlayerId() == null) { throw new IllegalArgumentException("No primary key specified"); } Session session = HibernateUtil.getSession(); Transaction transaction = session.beginTransaction(); try { session.save(player, player.getPlayerId()); transaction.commit(); } finally { session.close(); } }
One of the most important things in unit-testing is to keep your test-cases isolated. Since this method still involves a database, you need a way to clean your database prior to each test case. I have four tables in my schema so I wrote a reset() method on the TestSchema that removes all rows from the tables using JDBC. Note because HSQLDB knows about foreign keys, the order in which the tables are deleted is important. Here is the code:
public static void reset() throws SchemaException { Session session = HibernateUtil.getSession(); try { Connection connection = session.connection(); try { Statement statement = connection.createStatement(); try { statement.executeUpdate("delete from Batting"); statement.executeUpdate("delete from Fielding"); statement.executeUpdate("delete from Pitching"); statement.executeUpdate("delete from Player"); connection.commit(); } finally { statement.close(); } } catch (HibernateException e) { connection.rollback(); throw new SchemaException(e); } catch (SQLException e) { connection.rollback(); throw new SchemaException(e); } } catch (SQLException e) { throw new SchemaException(e); } finally { session.close(); } }
When bulk deletes become finalized in Hibernate 3.0 we should able to remove this last bit of direct JDBC from our application. Until then we have to get a Connection and issue direct SQL to the database.
Be sure not to close your Connection, closing the Session is sufficient for resource cleanup. Out of habits developed from writing lots of hand-crafted JDBC code, the first version closed the JDBC Connection. Since I configured Hibernate to create a connection pool with only one Connection I completely torpedoed any tests after the first one.Be sure to watch out for this!
Since you can never be sure what state the database may be in when your test class is running (imagine running all of your test cases), you should include database cleanup in your setUp() methods like so:
public void setUp() throws Exception { TestSchema.reset(); }
Conclusion
Being able to test against a real-live RDBMS without all of the hassles of trying to run tests against your deployed database is essential, even when working with sophisticated O/R mappers like Hibernate. The example I showed here is not exclusive to Hibernate and could probably be made to work with JDO or TopLink, though Hibernate makes this kind of testing particularly easy since it has a built-in schema generation tool. With a setup like the one described above you don't ever have to leave the comfort of your IDE and still have extensive test coverage over your code.