2024年7月24日 星期三

使用 DBUnit 進行 Database 測試

這篇紀錄使用 DBUnit 進行跟 Database 有關的 Unit Test 的方法,
在這裡我使用的是 JUnit 5 版本 + JDK 11。

DBUnit 是一個可以幫助我們對專案資料庫 (Database)  部份進行單元測試的 (Unit Test) 的工具,
在實務上我們通常會希望能以真實的資料庫連線來進行測試 DAO Class,不不是只是用 Mock 的方式來模擬資料庫的輸入輸出。

DBUnit 的中心思想就是在做測試之前把當下的 Database 資料輸出成 XML 或 XLS 檔備份起來 (可以用程式輸出、也可以自己手寫編輯製作),
並準備一份測試用的資料檔案 (跟備份的一樣,只是是另一個檔案,當然你要用一樣的也行),
然後在每個測試要執行之前,都先把測試資料寫入 Database 中 (預設通常是會先把 Database 相關的 Table 資料刪掉,可以設定其他行為) 再進行測試,
等最後全部的測試案例都測試完畢後,再把備份的資料寫回 Database 以復原資料。

雖然可以用備份資料復原 Database 的資料,但並不保證會跟之前完全一模一樣,例如可能地增值沒設對、外鍵設定跑掉、資料重寫後有問題跟之前不一樣、資料回復失敗丟失資料等,所以不建議在正式線上環境下使用,只在測試環境下用就好。

以下直接展示使用的測試程式範例:

Maven Dependency 設定範例:

<project xmlns="http://maven.apache.org/POM/4.0.0"
	xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<groupId>hugo</groupId>
	<artifactId>test</artifactId>
	<version>0.0.1-SNAPSHOT</version>

	<properties>
	    <maven.compiler.target>11</maven.compiler.target>
	    <maven.compiler.source>11</maven.compiler.source>
	</properties>

	<dependencyManagement>
		<dependencies>
			<dependency>
				<groupId>org.junit</groupId>
				<artifactId>junit-bom</artifactId>
				<version>5.10.3</version>
				<type>pom</type>
				<scope>import</scope>
			</dependency>
		</dependencies>
	</dependencyManagement>

	<dependencies>
		
		<!-- https://mvnrepository.com/artifact/org.junit.jupiter/junit-jupiter -->
		<dependency>
			<groupId>org.junit.jupiter</groupId>
			<artifactId>junit-jupiter</artifactId>
			<scope>test</scope>
		</dependency>
	
		<!-- https://mvnrepository.com/artifact/org.dbunit/dbunit -->
		<dependency>
		    <groupId>org.dbunit</groupId>
		    <artifactId>dbunit</artifactId>
		    <version>2.8.0</version>
		    <scope>test</scope>
		</dependency>
		
		<!-- DBUnit 輸出 XLS Dataset 需要 4.1.1 (4.1.2 好像也可以) 版本的 poi -->
		<!-- https://mvnrepository.com/artifact/org.apache.poi/poi -->
		<dependency>
		    <groupId>org.apache.poi</groupId>
		    <artifactId>poi</artifactId>
		    <version>4.1.1</version>
		</dependency>
		
		<!-- 此例以 PostgreSQL 為例,所以有使用 PostgreSQL Driver 的 Dependency -->
		<!-- https://mvnrepository.com/artifact/org.postgresql/postgresql -->
		<dependency>
		    <groupId>org.postgresql</groupId>
		    <artifactId>postgresql</artifactId>
		    <version>42.7.1</version>
		</dependency>

	</dependencies>
</project>

單元測試主程式 :

package test;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.net.MalformedURLException;
import java.net.URISyntaxException;
import java.net.URL;
import java.nio.file.Paths;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;

import org.dbunit.DatabaseUnitException;
import org.dbunit.database.AmbiguousTableNameException;
import org.dbunit.database.DatabaseConfig;
import org.dbunit.database.DatabaseConnection;
import org.dbunit.database.IDatabaseConnection;
import org.dbunit.database.QueryDataSet;
import org.dbunit.database.search.TablesDependencyHelper;
import org.dbunit.dataset.DataSetException;
import org.dbunit.dataset.IDataSet;
import org.dbunit.dataset.excel.XlsDataSet;
import org.dbunit.dataset.xml.FlatXmlDataSet;
import org.dbunit.dataset.xml.FlatXmlDataSetBuilder;
import org.dbunit.operation.DatabaseOperation;
import org.dbunit.util.search.SearchException;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

class DBUnitTest {
	
	//此例以 PostgreSQL 為例
	static String dbUrl = "jdbc:postgresql://localhost:5432/xxxDatabase";
	static String dbUsername = "username";
	static String dbPasswordString = "password";
	
	static String jdbcDriverName = "org.postgresql.Driver";
	static String testResourceFolderPath = "";
	//此例以 XLS 做為 Dataset file 為例,因為 XML 有特殊字完 \v (\u00B), Surrogate Pair Emoji 等無法處理正確的問題
	static String backupDatasetResourcePath = "/backupDataset.xls";
	static String testDatasetResourcePath = "/testDataset.xls";
	static String backupDatasetResourceFilePath;
	static String testDatasetResourceFilePath;

	static IDatabaseConnection getIDatabaseConnection() {
		IDatabaseConnection iDatabaseConnection = null;
		
		try {
			Class.forName(jdbcDriverName);
			Connection jdbcConnection = DriverManager.getConnection(dbUrl, dbUsername, dbPasswordString);
			iDatabaseConnection = new DatabaseConnection(jdbcConnection);
			
			//這邊我多設定了一條 PROPERTY_ESCAPE_PATTERN 的設定,
	        //它可以讓 DBUnit 在 select database table column 的名字時,用你自訂的 pattern 來做 select,
	        //例如我希望用雙引號包住 column name,例如 desc 是 sql 關鍵字,當成 column name 做 select 是要用雙引號包住,
	        //例子: select "desc" from xxxTable,
	        //這時就可以設定 PROPERTY_ESCAPE_PATTERN 值為 "\"?\""
	        DatabaseConfig databaseConfig = iDatabaseConnection.getConfig();
			databaseConfig.setProperty(DatabaseConfig.PROPERTY_ESCAPE_PATTERN, "\"?\"");
			databaseConfig.setProperty(DatabaseConfig.FEATURE_ALLOW_EMPTY_FIELDS, "true");
		} catch (ClassNotFoundException | SQLException | DatabaseUnitException e) {
			e.printStackTrace();
		}
		
		return iDatabaseConnection;
	}
	
	@BeforeAll
	static void beforeAll() {
		//在所有 Test Case 執行之前先備份當前要用到的 Database Tables,
		//例如 export 成 XLS 檔案
		try {
			URL testDatasetResourceFolderUrl = DBUnitTest.class.getClassLoader().getResource(testResourceFolderPath);
			File testDatasetResourceFolder = Paths.get(testDatasetResourceFolderUrl.toURI()).toFile();
			backupDatasetResourceFilePath = testDatasetResourceFolder.getAbsolutePath() + backupDatasetResourcePath.replace("/", File.separator);
			testDatasetResourceFilePath = testDatasetResourceFolder.getAbsolutePath() + testDatasetResourcePath.replace("/", File.separator);
			
			IDatabaseConnection iDatabaseConnection = getIDatabaseConnection();
			
			// partial database export, 只 export 部份 Table
	        QueryDataSet partialDataSet = new QueryDataSet(iDatabaseConnection);
	        partialDataSet.addTable("xxxTable", "SELECT * FROM xxxTable");
	        //輸出 XLS (Excel 97-2003 的格式)格式的檔案,可以用 Excel 開起來
	        XlsDataSet.write(partialDataSet, new FileOutputStream(backupDatasetResourceFilePath));
	        //也可以 Export XML 格式的檔案
	        //FlatXmlDataSet.write(partialDataSet, new FileOutputStream(backupDatasetResourceFilePath));
	
	        // full database export, 全部 Table 都 export
	        //IDataSet fullDataSet = connection.createDataSet();
	        //XlsDataSet.write(fullDataSet, new FileOutputStream(backupDatasetResourceFilePath));
	        
	        // dependent tables database export: export table X and all tables that
	        // have a PK which is a FK on X, in the right order for insertion
	        // Export 一個 Table 加上所有有指向這 Table 欄位外鍵的其他 Table
	        //String[] depTableNames = TablesDependencyHelper.getAllDependentTables(connection, "xxxTable");
			//IDataSet depDataSet = connection.createDataSet(depTableNames);
	        //XlsDataSet.write(depDataSet, new FileOutputStream(backupDatasetResourceFilePath));
		} catch (DatabaseUnitException | IOException | URISyntaxException e) {
			// TODO Auto-generated catch block
			e.printStackTrace();
		}
	}
	
	@BeforeEach
	void beforeEach() {
		//在每一次的 Test Case 執行之前把 Test Dataset 檔案內容匯入當下 Database 中
		try {
	        IDatabaseConnection iDatabaseConnection = getIDatabaseConnection();
	        
	        IDataSet dataSet = new XlsDataSet(new File(testDatasetResourceFilePath));
			//XML 是用 FlatXmlDataSetBuilder().build() 取得 IDataSet
			//IDataSet dataSet = new FlatXmlDataSetBuilder().build(new File(testDatasetResourceFilePath));
        
			//DatabaseOperation.CLEAN_INSERT 代表會先把 Database 相關 Table 清空再匯入 Test Dataset
			DatabaseOperation.CLEAN_INSERT.execute(iDatabaseConnection, dataSet);
			
			//你可以在這再執行一些需要的前置做業 Sql 語句,
			//例如使用 PostgreSQL 時,DatabaseOperation.CLEAN_INSERT.execute() 會把
			//遞增欄位的遞增值重設成 1,造成之後測試 insert 時發生問題 (id 重覆等之類的),
			//這時就會需要在這做例如如下 Sql 語句來設置正確的遞增值:
			//SELECT setval('xxxTable_xxxField_seq', (SELECT coalesce(MAX(xxxField), 0) + 1 FROM xxxTable), false)
		} catch (DatabaseUnitException | SQLException | IOException e) {
			e.printStackTrace();
		}
	}
	
	@AfterEach
	void afterEach() {
	}
	
	@AfterAll
	static void afterAll() {
		//在所有 Test Case 測試完後,用之前 Export 出來的 Backup Dataset 匯回去到 Database 中
		try {
			IDatabaseConnection iDatabaseConnection = getIDatabaseConnection();
	        
	        IDataSet dataSet = new XlsDataSet(new File(backupDatasetResourceFilePath));
        
			DatabaseOperation.CLEAN_INSERT.execute(iDatabaseConnection, dataSet);
			
			//你可以在這再執行一些需要的前置做業 Sql 語句
		} catch (DatabaseUnitException | SQLException | IOException e) {
			e.printStackTrace();
		}
	}
	
	@Test
	void test() {
		//做你想做的 Test
	}
}

我實測的情況是,目前 DBUnit 所產生的 XML Dataset 有一些問題,

例如如果 Dtabase 內容裡有紀錄包含對 XML 來說是 Invalid Character 的特殊字元的話,
例如:

  • \v :垂直定位(\u000B,在 NotePad++ 中可以用 \013 配合正規表示法的搜尋找到)
  • 一些使用到 Surrogate Pair 的 Emoji (可參考這篇 Code Point, Surrogate Pair 和 HTML Unicode 編碼),例如:👍 (Java 用 "\uD83D\uDC4D" 表示、HTML 用 &#128077;  表示)
這時 XML 就會沒辦法正常使用,也就是 DBUnit 沒辦法倒回 Database 中。
而實測 XLS似乎是目前最沒有問題的格式,雖然不能用記事本軟體打開,但可以用 Excel 打開,還可以接受),不過要導出 XLS 直接用 XlsDataSet.write() 取代 FlatXmlDataSet.write 的話會出現 java.lang.NoClassDefFoundError 錯誤,
參考到這篇文章 在使用dbunit导出数据到Excel中时遇到的问题 - xm3530 - 博客园 也有一樣的狀況,文章中說要自行導入缺失的 lib ,最後需要引入  4.1.1 (4.1.2 好像也可以)的 poi Dependency 解決。

題外話我有找到一個以 DBUnit 為基礎的 Database Rider (官網Github 專案) 專案,
它整合了 DBUnit 和 JUnit ,支援 JUnit 5,並且支持了產出其也格式的 Dataset,
例如: XML, XLS, XLSX, JSON, YML (實測 JSON 也會有例如 \v 特殊字元等的問題,而  YML 如果資料太大會有產出檔案大小的限制)。

我對 Database Rider 做了一些相關的使用紀錄,請參考這篇 使用 Dabase Rider 進行 Database 測試

參考資料:

  1. Getting Started
  2. Core Components
  3. dataset - import/export xml for dbunit - Stack Overflow
  4. 在使用dbunit导出数据到Excel中时遇到的问题 - xm3530 - 博客园

沒有留言 :

張貼留言