18 November 2006

摘要:探討 hibernate unit test 的四種可能作法,分析其利弊,結論是建議使用第三種作法 -- 實際的 db 搭配 transaction rollback。 後半則分享 Hibernate 常見的三種 case: save、query及 lazyInitializationException 的測試方法和 建立 fixture 的技巧。

今天討論的是硬題目,Hibernate 的 Unit Test。UI 的 Unit Test 難度是最高的相信這個大家都同意。 但 persistent 這個環結的測試也算是數一數二難的了,原因在於準備 fixture (註一)非常的困難, 而測試結束後還要刪除所有 fixture,更是會讓人抓狂。所以有一些hibernate 測試的方法都是為了解決 fixture 的問題而想出來的。目前看到的四種作法是:

  1. 不接觸 db,直接測 hibernate 的語法和設定
  2. 測試時使用 in-memory db,快速且不用特別去刪用完的 fixture
  3. 使用實際的 db。在測試其間開啟單一 transaction,測試完成後藉 transaction rollback 清除 fixture。
  4. 使用實際的 db。fixture 的建立與刪除全部靠手寫的 SQL 來處理。

上面四種方法由上而下,integration 的程度越高,測試相對來講也越完整,但也代表測試越來越難寫。 第一種不接觸 db 的做法是直接針對 hbm.xml 這個 xml 做 Unit Test,或是針對 HQL 的語法寫測試。 這種做法不需準備 fixture,所以很快也很好寫,但缺點是沒什麼用!。hbm.xml、HQL 設定和語法寫對了, 跟實際下法 query/insert... 得到結果根本是兩回事,尤其是 hibernate 有些地方是環環相扣的 (cascade/inverse=true... 之類),單一個 hbm.xml 寫對了不代表任何意義。

第二種是依賴 HSQLDB 之類的 in-memory db。即然是 in-memory,測試一跑完資料就可丟了, 所以難的地方只剩另一半 -- 建立 fixture。聽起來是個不錯的作法,但有一個致命的缺點 -- 它不是針對 production db 作測試的; 這種作法最常遇到的問題是 unit test 都跑的好好的,上線後出現一些難以捉摸的問題, 追到後來才知道原來某某 db 在某某狀況下會怎麼怎麼樣。原本 db 間的相容性就不高了, 在加上一層 abstract 的 jdbc driver 更是讓問題雪上加霜。很多不可預期的錯誤都是某版的 db + 某版的 jdbc driver 上才會出現 (對,我就是在說 oracle)。我們花了大把的時間建立 fixture、寫 persistent 的 unit test, 最後還是沒事先幫我們捉到問題,這花的功夫可真是浪費了。

第三、四種都是使用實際的 db,所以可克服第二種作法的缺點。第三種作法藉 transaction rollback 的功能自動去除 fixture。 換言之,跟第二種方法一樣,只需煩惱 fixture 的建立即可,唯一的缺點是無法測試跨 transaction 的功能。 不過這是小問題,因為絕大多數的 DAO 實作都不控管 transaction,而是將它交由上一層的 service facade 來處理, DAO 本身通常在同一 transaction 裡操作。如果真有那種需求,可搭配第四種方法來測試。第四種是全手動的, 所以可以支援所有狀況的測試,但也最費時費力,非到必要時不用。

好了,終於要回到正題了,我們來看看第三種的 hibernate unit test 該怎麼寫。首先你必需準備一個測試用的 db, 而且最好能與實際上線的 db 版本一致,這個測試的 db 可以整組人共用,也可以每個人自己架一個自用。 很多書上都建議最好每個人自備 test db,但我們 team 一直都是共用同一個,開發起來到也沒什麼問題, 而且共用 db等於是一直處在整合的狀態,可以及早發現問題。

以下探討的架構是常見 Dao 配 hibernateTemplate 這樣的做法,spring 的細部設定就略過不提了, 書上都有寫。測試的 TestCase 採用 spring-mock.jar 裡的 AbstractTransactionalSpringContextTests, 下面是一個 UserDao 的 test case:

public class UserDaoTest extends
        AbstractTransactionalSpringContextTests {

    //這個測試使用的 spring 設定檔,如果沒有特別的需求,直接拿 production 的來用即可。
    @Override
    protected String[] getConfigLocations() {
        return new String[] { "classpath:/applicationContext*.xml" };
    }

    // inject hibernateTemplate,方便做一些細部控制
    private HibernateTemplate hibernateTemplate;

    // class under test
    private UserDao dao ;

    // this will be auto-injected.
    public void setHibernateTemplate(HibernateTemplate hibernateTemplate) {
        this.hibernateTemplate = hibernateTemplate;
    }

    // this will be auto-injected.
    public void setUserDao(UserDao userDao) {
        this.dao = userDao;
    }

    //取代原來的 setUp(),這個 method 在 transaction 內。
    @Override
    protected void onSetUpInTransaction() throws Exception {
    }

    //取代原來的 tearDown(),這個 method 在 transaction 內。
    @Override
    protected void onTearDownInTransaction() throws Exception {
    }

    //真正的測試 method    
    public void testSaveUser() throws Exception {
        //test here...
    }
}

AbstractTransactionalSpringContextTests 繼承自 AbstractDependencyInjectionSpringContextTests, 所以只要 test case 裡寫 setXXX(),它會自動從 spring container 裡 inject 同 type 的 bean。 上面的例子我們有兩個 setter,一是 setHibernateTemplate(...),二是 setUserDao(...),它們都會自動 inject 供測試使用。getConfigLocations() 則是 config 檔的位置,起動 spring container 時會用到。

這個 test case 特別的地方在於,每次執行 testXXX() 時,spring 會自動開啟一個 transaction, 直到 method 結束,到了 tearDown() 才會 rollback()。如果你需要寫 setUp()/tearDown(),可以用 onSetUpInTransaction() 和 onTearDownInTransaction() 替代,它們執行的時候也會在同一個 transaction 內。 好,開始寫 testSaveUser():

public class UserDaoTest extends AbstractTransactionalSpringContextTests {    

    public void testSaveUser() {
        User user = new User("aName");
        dao.save(user);
        assertNotNull(user.getId());
        User found = hibernateTemplate.get(User.class, user.getId());
        assertEquals(user.getId(), found.getId());
        assertEquals(user.getName(), found.getName());
    }
}
    
public class UserDao {    
    public void save(User user) {
        hibernateTemplate.saveOrUpdate(user);
    }
}

一開始先用 dao save 一個 user,然後 assert 它有取得的 database id,接下來利用 hibernateTemplate.get() 確認資料庫是否真的已儲存了剛才的那一筆。如果你的 hibernate 設定和資料庫都正確的話,你應該會拿到一個綠燈 。測試完後,儲存的 user 資料會被 rollback,所以可以一直反覆執行這個測試。

初步的測試大概就是這樣寫... 還蠻好寫的,就寫些 java 程式就好了,也不用去管啥殺資料的索事了。 但是很不幸地,上面的測試是無效的。首先我們來看看 dao.save(user) 做了什麼? 如果是一般使用 sequence 產生 id 的資料庫,你會看到像是這樣的 query:

select next value for seq_user from dual_seq_user

然後就沒了,沒有 insert 的 statement 啊!那接下的 hibernateTemplate.get(User.class, user.getId()) 呢?哇咧,仔細一看居然也沒有下任何的 sql。搞了半天上面的測試只執行了一行 sequence 相關的 sql, 其他什麼都沒執行到!難怪會說這個測試是無效的。Why? Why? Why?

原因是 hibernate 的 first level cache 和 delay flush 兩個功能所引起的。Hibernate 執行 save()/saveOrUpdate() 時,當下只會去資料庫要個 id,還不會真的 insert (註二)。但經過這一個操作後, hibernate 便視 user 為 persistent,把該 instance 放在 1st level cache 裡。直到 flush() 才會真正的去執行 insert。一般來說 transaciton commit 時,就是 flush() 的時間點。可惜的是 AbstractTransactionalSpringContextTests 永遠不會做 commit 啊,它只會 rollback。 因此遇到這個情況,我們得手動 flush():

public class UserDaoTest extends AbstractTransactionalSpringContextTests {    

    public void testSaveUser() {
        User user = new User("aName");
        dao.save(user);

        //手動 flush,就會有 insert 了
        hibernateTemplate.flush();

        assertNotNull(user.getId());
        //.. 其他略...
    }
}

好,解決一半的問題,但還是沒看到 hibernateTemplate.get(User.class, user.getId()) 所執行的 sql 啊? 原因是 hibernate 的 get() 會先去 1st level cache 找找看,如果找到同個 class,同個 id 的,它就直接回傳 cache 裡的物件給你,而不會下額外的 query。解決的辦法是手動清除 1st level cache:

public class UserDaoTest extends AbstractTransactionalSpringContextTests {    

    public void testSaveUser() {
        User user = new User("aName");
        dao.save(user);
        //手動 flush,就會有 insert 了
        hibernateTemplate.flush();
        assertNotNull(user.getId());

        //清除 first level cache:
        hibernateTemplate.clear();
        
        //這一行就真的會下 query 到 db,實際去撈撈看有沒有該筆資料。
        User found = hibernateTemplate.get(User.class, user.getId());
        assertEquals(user.getId(), found.getId());
        assertEquals(user.getName(), found.getName());
    }
}

呼~ 總算讓這個測試真的做到事了。雖然多了兩個討厭的步驟,但怎麼樣也比手動 tearDown 殺資料好多了。 這點犧牲是小意思。由於 flush() 常常會配 clear(),那乾脆包成個 flushThenClearCache(),可以方便一點。

在寫 hibernate unit test 時,何時 flush, 何時 clear 是很重要的觀念和技巧,請小心使用。

上面第一個範例是小 case,接下來我們看一個 query 的例子:

public class UserDaoTest extends AbstractTransactionalSpringContextTests {    

    //共用的 fixture
    List<User> users ;
    @Override
    protected void onSetUpInTransaction() throws Exception {

        //手動 inject 到 UserOM, 如果嫌麻煩可以弄個 AbstractDaoTest 統一設定
        UserOM.hibernateTemplate = hibernateTemplate;

        //使用特別的 helper class 產生 fixture
        users = UserOM.savedUsers("foo", "userX", "foo", "bar" );
    }

    public void testFindUsersByName_simple() {

        //不要忘記清除 cache
        flushThenClearCache();

        //一般來講 query 的測試要從 0 筆開始測試。
        List<User> notFound = dao.findUsersByName("not exist name");
        assertEquals(0, notFound.size()):


        //測試一個符合
        List<User> barUsers = dao.findUsersByName("bar");
        assertEquals(1, barUsers.size()):
        //確認 id 就足夠了
        assertEquals(users.get(0).getId(), barUsers.get(0).getId());
    }

    public void testFindUsersByName_complex() {
        //不要忘記清除 cache
        flushThenClearCache();

        //測試要涵蓋到多個符合。
        List<User> fooUsers = dao.findUsersByName("foo");

        assertEquals(2, fooUsers.size()):
        assertEquals(users.get(0).getId(), fooUsers.get(0).getId());
        assertEquals(users.get(2).getId(), fooUsers.get(1).getId());
    }

    protected void flushThenClearCache() {
        hibernateTemplate.flush();
        hibernateTemplate.clear();
    }
}

//專門產生假資料的 helper class (OM 意指 ObjectMother)
public class UserOM {

    //manually inject in TestCase:
    public static HibernateTemplate hibernateTemplate; 
    
    public static List<User> savedUsers(String...names) {
        List<User> users = new ArrayList<User>();
        for(String name : names) {
            User user = new User(name);
            hibernateTemplate.saveOrUpdate(user);
            users.add(user);
        }
        return users;
    }

    //other methods
    public static User savedUserWithGroup() {//略......}
}

//真正的 production 程式:
public class UserDao {    
    public List<User> findUsersByName(String name) {
        String hql = " from User where name = ? " ;
        return (List<User>) hibernateTemplate.find(hql, name);
    }
}

這一段程式是查相同名字的使用者。真正的主程式才兩行,測試的程式卻快三十行,真是嘔死人。可是 db 就是這麼難測啊... 上面的 test case 我們寫了 simple 和 complex 兩個,simple 涵蓋 0~1 個的測試,complex 則是多個的。 你想要再包含更多的 case 那也由你,不過個人通常就寫到這樣就停了,因為這樣的測試已經可以讓我對 produciton 程式 有足夠的自信。而 UserOM 是很久之前提過的 ObjectMother pattern,我們會在一開始測試時給它 hibernateTemplate, 讓它可以在測試程式中自由的 insert 假資料。這裡的 UserOM 仍然使用 hibernate 來做假資料, 而不是用純 sql 或是 dbunit xml 的做法。個人是比較傾向這樣寫,因為用 hibernate 就只要操作 POJO 的屬性就好了 ,若有重覆的地方也可以做 refactoring (就 java 嘛) 久而久之 UserOM 裡 method 越來越多,重用性也越來越高, 建立 fixture 就不會像一開始這麼累了。

另一個地方我們可以使力的地方是:

assertEquals(2, fooUsers.size()):
    assertEquals(users.get(0).getId(), fooUsers.get(0).getId());
    assertEquals(users.get(2).getId(), fooUsers.get(1).getId());

看上面的 test case,這樣的程式碼一直重覆,我們該 extract 出來,變成一個 util:

public class UserDaoTest extends AbstractTransactionalSpringContextTests {    

    public void testFindUsersByName_complex() {
        flushThenClearCache();
        List<User> fooUsers = dao.findUsersByName("foo");

        TestUtils.assertIdEquals(
            new User[]{users.get(0), users.get(2)}, fooUsers );
    }
}

public class TestUtils {
    public static assertIdEquals(Object[] expects, Collection<?> actuals) {
        // assert size, and all id equals
    }
}

extract 成 TestUtils 之後清爽多了。至於 assertIdEquals() 該怎麼寫就留給各位了。

好,我們要進入第三個課題 - Lazy Initialization Exception,這個是讓全世界 Hibernate developer 恨得牙癢癢的Exception,當然要測!下面是一個 findUserById 的 程式片段:

//Test
public class UserDaoTest extends
        AbstractTransactionalSpringContextTests {

    //一開始只測一點點
    public void testFindUserById() {
        User user = UserOM.savedUser();
        flushThenClearCache();

        User found = dao.findUser(user.getId());
        assertEquals(user.getId(), found.getId());
    }
}

//production 程式:
public class UserDao {    
    public User findUser(Long id ) {
        String hql = " from User where id = ? " ;
        return (User) hibernateTemplate.find(hql, id).get(0);
    }
}

現在假設 user 底下有多個 groups,是一對多、並且設 lazy=true。如果我們的網頁端的程式呼叫這個 findUser(id) 抽出的 User,而且還讓網頁顯示 user 所屬的 groups 時,就出現 LazyInitializationException 了,因為目前的 findUser(id) 並沒有把 groups 一起 query 出來。解決的方法之一是 HQL 加上 join fetch group。當然,本著 Test First 的精神,再修改 production 程式之前,我們要先把原來的測試改成紅燈:

public class UserDaoTest extends AbstractTransactionalSpringContextTests {    

    public void testFindUserById() {
        User user = UserOM.savedUserWith3Groups();
        flushThenClearCache();

        User found = dao.findUser(user.getId());
        assertEquals(user.getId(), found.getId());
        
        //這樣應該會 throw LazyInitializationException 讓測試變紅燈。
        List<Group> groups = found.getGroups() ;
        groups.get(0).getGroupName(); // 沒有LazyInitializationException!
    }
}

重跑一次測試,啊咧... 沒有紅燈啊?!還是綠燈耶... 又是個無效的測試了... 不是該丟 exception 嗎?仔細看一下產生的 sql,多了一行抽 group 的 sql :

select group0_.id ...中略... from Group group0_ where group0_.user_id= ?

啊!原來是 session 尚未關閉,所以 groups.get(0).getGroupName() 這一行讓 hibernate session 再下了一個 sql。 但實際網頁上的情況是 session 早就關了啊!要模擬 session 關閉的狀況,我們得用 endTransaction():

public class UserDaoTest extends AbstractTransactionalSpringContextTests {    

    public void testFindUserById() {
        User user = UserOM.savedUserWith3Groups();
        flushThenClearCache();

        User found = dao.findUser(user.getId());
        
        //加了這一行 transaction 就直接 rollback,session 也跟著一起關了
        endTransaction();
        
        List<Group> groups = found.getGroups() ;
        groups.get(0).getGroupName(); // 這次真的會 LazyInitializationException 了!
    }
}

endTransaction() 是 AbstractTransactionalSpringContextTests 提供的 method, 可以讓我們提前終止目前的 transaction,也代表關閉 hibernate session。這一次上面的測試就是紅燈了,耶! 現在開始改 production code,加上 join fetch,再跑一次測試就是綠燈了:

//Test
public class UserDaoTest extends
        AbstractTransactionalSpringContextTests {

    User user ;

    @Override
    protected void onSetUpInTransaction() throws Exception {
        UserOM.hibernateTemplate = hibernateTemplate;
        user = UserOM.savedUserWith3Groups();
    }

    public void testFindUserById() {
        flushThenClearCache();
        User found = dao.findUser(user.getId());
        TestUtils.assertIdEquals(user, found);
    }
    
    public void testFindUserById_WithGroups() {
        flushThenClearCache();
        User testGroupLazy = dao.findUser(user.getId());

        endTransaction();
        
        assertNotNull("group should be fetched", 
                testGroupLazy.getGroups().get(0).getGroupName());
    }
}

//production 程式:
public class UserDao {    
    public User findUser(Long id ) {
        //加上 left join fetch user.groups
        String hql = " from User user left join fetch user.groups where id = ? " ;
        return (User) hibernateTemplate.find(hql, id).get(0);
    }
}

總結:有了 Spring 提供的 AbstractTransactionalSpringContextTests , 讓我們寫第三種測試方法更為容易。由於 hibernate session 的特性使然,很容易寫出無效的空炮彈測試。 藉由 flush/clear/endTransaction 等等的處理技巧,可一一有效解決。

註一:fixture 即測試用的假資料。

註二:依據你的資料庫的不同,可能會有不同的結果,如果資料庫產生的 id 不是用 sequence, 而是用 increment column (如 MySQL),那 session.save() 當下就會 insert 一筆資料到 db,以取得 id。

本文中常提及的 "production" 字眼意指正式上線使用的程式碼。


回響

可以用 Tag <I>、<B>,程式碼請用 <PRE>