用 DbUnit 和 Anthill 控制測試環境
極限編程 方法的興起將 測試驅動開發 和持續集成帶入了主流 Java 開發實踐。如果沒有采用正確的工具,在 Java 服務器 端開發中使用這些技術很快會成為一場噩夢。在本文中,軟件開發人員 Philippe Girolami 描述了如何處理持續集成,以及如何聯合使用 DbUnit
極限編程方法的興起將測試驅動開發和持續集成帶入了主流 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='aabbclearcase/" target="_blank" >cc'/> <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
設置 DbUnit 很簡單。有關項目文件下載的信息,請參閱 參考資料的內容??梢詫⑺腥齻€ JAR 文件加到項目的編譯目標中以進行測試。
如果是一個多 schema 環境,那么要將 DbUnit.qualified.table.names 屬性設置為 true 。使用 Oracle 的開發團隊通常是這種情況:每一個用戶有自己的 schema。這可以使您免于在每個表名前面加上 schema 名稱的前綴,并可以在團隊中共享測試數據。 | |
查詢表的內容
DbUnit 使您可以容易地執行 JDBC 查詢并獲取它們的值。使用 DbUnit JDBC 包裝器而不是純粹的 JDBC 有幾個理由:
- 可以用 SQL 查詢創建一個
Dataset
,并使用 DbUnit 的 assertion(斷言)方法(在后面描述)。
- 可以用 SQL 查詢創建一個
Dataset
,并將它保存為一個 FlatXmlDataSet
??梢栽谝院髮⑺匦卵b載到數據庫中。
- 可以容易地從任何行中獲取列的內容,無需進行迭代。
清單 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 實現持續集成
Anthill 是一個免費的自動構建工具(請參閱 參考資料),它規劃您的構建并發布結果,幫助精通 XP 的小組使用持續集成。一次構建包含用 CVS 這樣的版本控制工具檢查源代碼、運行一個構建腳本、發布結果并通知用戶結果。它很好地與 ANT 集成,使您可以重用常用的構建腳本。 | |
在 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% 涉及設置數據庫狀態。我們每小時運行一次測試,并計劃很快以更短的周期運行它們。它們捕獲了很多未預料到的缺陷,這使它們成為不可缺少的工具。
原文轉自:http://www.kjueaiud.com