資料庫單元測試


假 設你有個DAO定義如下:
package cc.openhome.dao;

import cc.openhome.model.Bookmark;
import java.util.List;

public interface BookmarkDAO {
public List<Bookmark> get();
public void add(Bookmark bookmark);
}

現在你要實作一個BookmarkDAOImpl類 別:
package cc.openhome.dao;

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
import javax.sql.DataSource;
import cc.openhome.model.Bookmark;

public class BookmarkDAOImpl implements BookmarkDAO {
private DataSource dataSource;

public BookmarkDAOImpl(DataSource dataSource) {
this.dataSource = dataSource;
}

public List<Bookmark> get() {
String sql = "SELECT * FROM T_BOOKMARK";

List<Bookmark> list = new ArrayList<Bookmark>();
Connection conn = null;
PreparedStatement stmt = null;
RuntimeException reason = null;

try {
conn = dataSource.getConnection();
stmt = conn.prepareStatement(sql);
ResultSet rs = stmt.executeQuery();
while(rs.next()) {
Bookmark bookmark = new Bookmark(
rs.getString(2),
rs.getString(3),
rs.getString(4));
list.add(bookmark);
}
}
catch(SQLException ex) {
if(reason == null) {
reason = new RuntimeException(ex);
}
}
finally {
if(stmt != null) {
try {
stmt.close();
}
catch(SQLException ex) {
if(reason == null) {
reason = new RuntimeException(ex);
}
}
}
if(conn != null) {
try {
conn.close();
}
catch(SQLException ex) {
if(reason == null) {
reason = new RuntimeException(ex);
}
}
}
}
if(reason != null) {
throw reason;
}
return list;
}

public void add(Bookmark bookmark) {
String sql =
"INSERT INTO T_BOOKMARK(url,title,category) VALUES(?, ?, ?)";

Connection conn = null;
PreparedStatement stmt = null;
RuntimeException reason = null;

try {
conn = dataSource.getConnection();
stmt = conn.prepareStatement(sql);
stmt.setString(1, bookmark.getUrl());
stmt.setString(2, bookmark.getTitle());
stmt.setString(3, bookmark.getCategory());
stmt.executeUpdate();
}
catch(SQLException ex) {
if(reason == null) {
reason = new RuntimeException(ex);
}
}
finally {
if(stmt != null) {
try {
stmt.close();
}
catch(SQLException ex) {
if(reason == null) {
reason = new RuntimeException(ex);
}
}
}
if(conn != null) {
try {
conn.close();
}
catch(SQLException ex) {
if(reason == null) {
reason = new RuntimeException(ex);
}
}
}
}
if(reason != null) {
throw reason;
}
}
}

那麼你要怎麼測試這個DAO的實作功能正確?假設測試get()方法是否運作正 常好了,你該採用哪種策略?採用Mock物件的方式?如果你想測試的是get()中的這段程式碼是否正確...
            ....
            ResultSet rs = stmt.executeQuery();
            while(rs.next()) {
                Bookmark bookmark = new Bookmark(
                        rs.getString(2),
                        rs.getString(3),
                        rs.getString(4));
                list.add(bookmark);
            }
            ....

那麼採Mock物件的方式是可行的,這就比較貼近單元測試的範疇,因為你隔離了真正對資料庫的存取。

然而,如果你想測試的是get()方 法是否真正從資料庫中取得資料,採用Mock物件就不適合,在這個需求下,用Mock等於在模擬JDBC驅動程式,這沒有意義,因為你沒有真正連接資料 庫,就這個需求,你要作的其實是較貼近於整合測試(Integration test)。

再來看看add()方 法,會在資料庫中安插一筆資料,add()方 法中完全是在使用JDBC的標準介面,採用Mock就沒有意義,你想知道的,無非就是其是否真正在資料庫中安插資料。

比較好的方式,就是撰寫一個可信任的輔助類別來存取資料庫,如果是DAO的get()操作取得結果, 與輔助類別取得的結果相比較,看看兩者的結果是否正確,DAO的add()方法操作過後, 也用輔助類別來取得資料庫中的結果,並與預期的結果相比較。

另一方面,你可能多次測試add()方 法,每次都會在資料庫中安插資料,測試必須是可以重複的,每次測試會有前置狀態,測試過後的狀態,為了要能重複測試,輔助類別必須能在測試開始前回復前置 狀態。

測試資料庫的難處即在於此,你必須與實際的外部實體資源互動,並從該實體資源取得互動結果,方可供測試時比較,你必須可回溯外 部實體資源的狀態,以便每次重複測試,你希望測試時有一定的隔離性,但又必須與外部實體資源互動。

你可以手動撰寫上述所提及的輔助類別,來達到測試的目的:
package test.cc.openhome;

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
import javax.sql.DataSource;
import cc.openhome.model.Bookmark;

public class BookmarkDAOTester {
private DataSource dataSource;
private List<Bookmark> dataSet;
private enum Operation { CREATE, SELECT, DROP };

public BookmarkDAOTester(DataSource dataSource) {
this.dataSource = dataSource;
}

// 設定測試前的初始資料
public void setDataSet(List<Bookmark> dataSet) {
this.dataSet = dataSet;
}

// 在資料庫建立測試前的初始資料
public void create() {
process(Operation.CREATE);
}

// 取得資料庫結果
public List<Bookmark> getDataSet() {
process(Operation.SELECT);
return dataSet;
}

// 丟棄測試後的變更
public void drop() {
process(Operation.DROP);
}

private void process(Operation op) {
Connection conn = null;
PreparedStatement stmt = null;
RuntimeException reason = null;

try {
conn = dataSource.getConnection();
switch(op) {
case CREATE:
stmt = conn.prepareStatement(
"create table T_BOOKMARK (" +
" id int not null auto_increment primary key," +
" url varchar(255) not null," +
" title char(255) not null," +
" category char(255) not null)");
stmt.executeUpdate();
stmt.close();

stmt = conn.prepareStatement(
"INSERT INTO T_BOOKMARK(" +
"url,title,category) VALUES(?, ?, ?)");
for(Bookmark bookmark : dataSet) {
stmt.setString(1, bookmark.getUrl());
stmt.setString(2, bookmark.getTitle());
stmt.setString(3, bookmark.getCategory());
stmt.executeUpdate();
stmt.clearParameters();
}
stmt.close();
break;
case SELECT:
stmt = conn.prepareStatement("SELECT * FROM T_BOOKMARK");
dataSet = new ArrayList<Bookmark>();
ResultSet rs = stmt.executeQuery();
while(rs.next()) {
Bookmark bookmark = new Bookmark(
rs.getString(2),
rs.getString(3),
rs.getString(4));
dataSet.add(bookmark);
}
stmt.close();
break;
case DROP:
stmt = conn.prepareStatement("DROP TABLE T_BOOKMARK");
stmt.executeUpdate();
}
}
catch(SQLException ex) {
if(reason == null) {
reason = new RuntimeException(ex);
}
}
finally {
if(conn != null) {
try {
conn.close();
}
catch(SQLException ex) {
if(reason == null) {
reason = new RuntimeException(ex);
}
}
}
}
if(reason != null) {
throw reason;
}
}
}

這個輔助類別必須是可信任的,接著你可以使用它來撰寫測試:
package test.cc.openhome;

import static org.junit.Assert.assertEquals;
import java.util.ArrayList;
import java.util.List;
import org.junit.After;
import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.Test;
import cc.openhome.dao.BookmarkDAO;
import cc.openhome.dao.BookmarkDAOImpl;
import cc.openhome.model.Bookmark;
import com.mysql.jdbc.jdbc2.optional.MysqlDataSource;

public class BookmarkDAOImplTest {
private static MysqlDataSource dataSource;
private BookmarkDAOTester tester;
private List<Bookmark> rowDataSet;
private List<Bookmark> expected;
private BookmarkDAO dao;

@BeforeClass
public static void setUpClass() {
dataSource = new MysqlDataSource();
dataSource.setUrl("jdbc:mysql://localhost:3306/exercise");
dataSource.setUser("root");
dataSource.setPassword("123456");
}

@Before
public void setUp() {
tester = new BookmarkDAOTester(dataSource);
rowDataSet = new ArrayList<Bookmark>();
rowDataSet.add(new Bookmark("http://a", "a", "x"));
rowDataSet.add(new Bookmark("http://b", "b", "y"));
rowDataSet.add(new Bookmark("http://c", "c", "z"));
tester.setDataSet(rowDataSet);
tester.create();

expected = new ArrayList<Bookmark>();
expected.addAll(rowDataSet);

dao = new BookmarkDAOImpl(dataSource);
}

@After
public void tearDown() {
tester.drop();
}

@Test
public void testGet() {
List<Bookmark> result = dao.get();
assertEquals(expected, result);
}

@Test
public void testAdd() {
Bookmark bookmark = new Bookmark("http://d", "d", "w");
dao.add(bookmark);

List<Bookmark> dataSet = tester.getDataSet();
expected.add(bookmark);
assertEquals(expected, dataSet);
}
}

每次測試前皆會建立新表格,測試後刪除表格,測試時若需取得資料庫的資料相比較,皆使用預先準備好的資料或透過輔助類別取得的資料來比較。

然而這樣的輔助類別撰寫不易,且不容易撰寫的通用,在更多複雜測試的情況下,撰寫輔助類別會更加困難,如何確認輔助類別是可信任的也是一個問題,尋求現有 的測試框架,像是之後要介紹的 DbUnit,可減輕你撰寫這類測試類別 的負擔。