顯示具有 DBUnit 標籤的文章。 顯示所有文章
顯示具有 DBUnit 標籤的文章。 顯示所有文章

2024年12月31日 星期二

Java Database Unit Test - Spring + Database Rider

紀錄一下使用 Spring 搭配 Database Rider 的注意事項。

下面直接看範例,相關的說明也寫在注解上了。

最主要要注意的地方是,
如果不使用 Dabase Rider 的 Annotation 的話,跟之前這篇
使用 Dabase Rider 進行 Database 測試
的寫法是差不多的,只差在取得 Connection 的地方可以直接使用 Spring 幫我們裝配好的

JdbcTemplate 來取得 Datasource,再使用
DataSourceUtils.getConnection(dataSource) 取得 Connection。

如果想使用 Database Rider 的 Annotation 的話,需要使用
@DBRider(dataSourceBeanName = "test_DataSource")
這個 Annotaton 來設定 dataSourceBeanName,
@DBRider 本身就是 @ExtendWith(DBUnitExtension.class) 和 @Test 的組合,
但多提供了我們設定 dataSourceBeanName 的地方,
我們只要把設定在 Spring 中需要的 dataSourceBeanName 設定給 @DBRider,
之後 Database Rider 的 @DataSet, @ExpectedDataSet 這些 Annotation 就會直接用dataSourceBeanName 去找出 Spring 裝配好的 dataSource 進行 Database 連線,
所以不用再像這篇
使用 Dabase Rider 進行 Database 測試
一樣特別去設定 ConnectionHolder。

package test.dao;

import java.io.File;
import java.net.URISyntaxException;
import java.net.URL;
import java.nio.file.Paths;
import java.sql.Connection;
import java.sql.SQLException;

import javax.sql.DataSource;

import org.dbunit.DatabaseUnitException;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
import org.junit.jupiter.api.TestInstance.Lifecycle;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceUtils;
import org.springframework.test.context.junit.jupiter.web.SpringJUnitWebConfig;
import com.github.database.rider.core.api.configuration.DBUnit;
import com.github.database.rider.core.api.dataset.DataSet;
import com.github.database.rider.core.api.dataset.DataSetFormat;
import com.github.database.rider.core.api.dataset.ExpectedDataSet;
import com.github.database.rider.core.api.exporter.DataSetExportConfig;
import com.github.database.rider.core.configuration.DBUnitConfig;
import com.github.database.rider.core.configuration.DataSetConfig;
import com.github.database.rider.core.configuration.ExpectedDataSetConfig;
import com.github.database.rider.core.dsl.RiderDSL;
import com.github.database.rider.core.exporter.DataSetExporter;
import com.github.database.rider.junit5.api.DBRider;

//讀取配置檔讓 Spring 處理 Bean 的依賴裝配
@SpringJUnitWebConfig(locations = {"file:WebContent/WEB-INF/mvc-config.xml"})
//如果要使用 Database Rider 的 Annotation (@Dataset, @ExpectedDataSet 等)
//才需要使用 @DBRider,
//@DBRider 內包含 @ExtendWith(DBUnitExtension.class) 和 @Test,
//並且多了一個 dataSourceBeanName 屬性,用來指定要使用的 Spring DataSourceBean 名稱
@DBRider(dataSourceBeanName = "test_DataSource")
@DBUnit(cacheConnection = false,
		allowEmptyFields = true)
@TestInstance(Lifecycle.PER_CLASS)
class DBRiderTest {
	
	//如果不使用 Database Rider 的 Annotation,
	//可以自行依需要取得 JdbcTemplate,進而取得 Datasource 和 Jdbc Connection
	@Autowired
	@Qualifier("test_JdbcTemplate")
	JdbcTemplate test_JdbcTemplate;
	
	final String testResourceFolderPath = "";
	final String backupDatasetResourcePath = "/backupDataset.xlsx";
	final String testDatasetResourcePath = "/testDataset.xlsx";
	
	final String[] includeTableList = new String[] {"test_table"};
	
	//因為 @ExportDataSet 似乎無法使用在 @BeforeAll 及 @AfterAll 上,
	//所以這裡不使用 Annotation,直接使用 API 進行 backup dataset 的 export
	@BeforeAll
	void exportBackupDataset() throws SQLException, DatabaseUnitException, URISyntaxException {
		URL backupDatasetResourceFolderUrl = DBRiderTest.class.getClassLoader().getResource(testResourceFolderPath);
		File backupDatasetResourceFolder = Paths.get(backupDatasetResourceFolderUrl.toURI()).toFile();
		String backupDatasetResourceFilePath = backupDatasetResourceFolder.getAbsolutePath() + backupDatasetResourcePath.replace("/", File.separator);
		
		DataSource dataSource = test_JdbcTemplate.getDataSource();
		Connection conn = DataSourceUtils.getConnection(dataSource);
		DataSetExporter.getInstance().export(conn,
											 new DataSetExportConfig()
											 .dataSetFormat(DataSetFormat.XLSX)
											 .includeTables(includeTableList)
											 .queryList(new String[] {})
											 .outputFileName(backupDatasetResourceFilePath));
		
		DataSourceUtils.releaseConnection(conn, dataSource);
	}
	
	//因為 @DataSet 似乎無法使用在 @BeforeAll 及 @AfterAll 上,
	//所以這裡不使用 Annotation,直接使用 API 進行 backup dataset 的 import
	@AfterAll
	void importBackupDataset() {
		//直接利用 Spring Inject 裝配好的 JdbcTemplate,並取得 DataSource 和 Connection
		DataSource dataSource = test_JdbcTemplate.getDataSource();
		Connection conn = DataSourceUtils.getConnection(dataSource);
		RiderDSL.withConnection(conn)
				.withDataSetConfig(new DataSetConfig(testResourceFolderPath + backupDatasetResourcePath)
						           //依需求設定 import dataset 要執行的動作,
						           //例如: Sql Server (MS Sql) 要 Insert/Update 主鍵時需要 SET IDENTITY_INSERT test_table ON
						           .executeStatementsBefore("")) 
				.withDBUnitConfig(new DBUnitConfig()
						          .addDBUnitProperty("allowEmptyFields", true))
				.createDataSet();
	
		DataSourceUtils.releaseConnection(conn, dataSource);
		//do other things
	}
	
	//使用 @DataSet 來 import test dataset
	@BeforeEach
	@DataSet(value = "testDataset.xlsx",
			 //依需求設定 import dataset 要執行的動作,
	         //例如: Sql Server (MS Sql) 要 Insert/Update 主鍵時需要 SET IDENTITY_INSERT test_table ON
	         executeStatementsBefore = {""})
	void importTestDataset() {
	}
	
	@Test
	void test() {
		System.out.println("test");
		Assertions.assertTrue(true);
	}
	
	//使用 @ExpectedDataSet Annotation 比較 database table 的資料是否和 expected dataset 一致
	@Test
	@ExpectedDataSet(value = "expectedDataset.xlsx")
	void testExpectedDatasetByAnnotation() throws DatabaseUnitException, SQLException {
		//做你想做的 Test
		//例如以下是比較 Actual dataset 和 expected dataset 的範例 (請再自己準備一個 expected dataset file):
		//自己修改一下 database table 的資料
		test_JdbcTemplate.update("UPDATE test_table SET title = 'ABC' WHERE id = 2");
		
		//因為使用了 @ExpectedDataSet Annotation,所以這裡就不需要再多寫用 RiderDSL 等的程式碼了
	}
	
	//也可以不使用用 @ExpectedDataSet Annotation,直接使用 RiderDSL 來比較 database table 的資料是否和 expected dataset 一致
	@Test 
	void testExpectedDataset() throws DatabaseUnitException, SQLException {
		//做你想做的 Test		
		//例如以下是比較 Actual dataset 和 expected dataset 的範例 (請再自己準備一個 expected dataset file):
		//自己修改一下 database table 的資料
		test_JdbcTemplate.update("UPDATE test_table SET title = 'ABC' WHERE id = 2");
		
		//比較 database table 的資料是否和 expected dataset 一樣
		DataSource dataSource = test_JdbcTemplate.getDataSource();
		Connection conn = DataSourceUtils.getConnection(dataSource);
		RiderDSL.withConnection(conn)
			    .withDataSetConfig(new DataSetConfig("expectedDataset.xlsx"))
			    .withDBUnitConfig(new DBUnitConfig()
					              .addDBUnitProperty("escapePattern", "\"?\"")
					              .addDBUnitProperty("caseSensitiveTableNames", true)
					              .addDBUnitProperty("allowEmptyFields", true))
		        .expectDataSet(new ExpectedDataSetConfig());
		
		DataSourceUtils.releaseConnection(conn, dataSource);
	}
}

2024年7月29日 星期一

使用 JUnit5 Runner + TestNg + Dabase Rider 進行 Database 測試

這篇要來紀錄使用 JUnit5 Runner + TestNg + Dabase Rider 進行 Database 測試的方法。

首先,用 JUnit5 來跑 TestNg 的方法可以參考這篇 使用 JUnit5 同時跑 JUnit5 和 TestNg 的 Test Case,只要使用 testng-engine 就可以了。

再來,因為 Database Rider (可以先參考之前的 使用 DBUnit 進行 Database 測試 和 使用 Dabase Rider 進行 Database 測試 這兩篇) 的 Annotation (註解,例如 @DBUnit、@Dataset 這些) 是用 @ExtendWith(DBUnitExtension.class) 的方式以 Extension 來跟 JUnit5協作,
但 TestNg 是看不懂這些 Database Rider 的 Annotation 的,
所以我們要用如 DBUnit 最普通的方式那樣,
自己用程式碼執行的方式 (就是不用 Annotation 來進行 DataSet 的建立、寫資料進 Database 等操作。

下面直接示範程式碼:

Maven Dependency 的 pom.xml:

<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>junittest</artifactId>
	<version>0.0.1-SNAPSHOT</version>

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

	<build>
		<plugins>
			<!-- Maven 執行 test 的 plugin,surefire 多用於單元測試 (unit test), failsafe
			多用於整合測試 (integration test)  -->
			<plugin>
				<artifactId>maven-surefire-plugin</artifactId>
				<version>3.1.2</version>
			</plugin>
			<plugin>
				<artifactId>maven-failsafe-plugin</artifactId>
				<version>3.1.2</version>
			</plugin>
		</plugins>
	</build>

	<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.testng/testng -->
		<dependency>
			<groupId>org.testng</groupId>
			<artifactId>testng</artifactId>
			<version>7.10.2</version>
			<scope>test</scope>
		</dependency>

		<!-- 使用 testng-engine 讓 JUnit5 可以跑 TestNg 的 Test Case -->
		<!-- https://mvnrepository.com/artifact/org.junit.support/testng-engine -->
		<dependency>
			<groupId>org.junit.support</groupId>
			<artifactId>testng-engine</artifactId>
			<version>1.0.5</version>
			<scope>test</scope>
		</dependency>
		
		<!-- https://mvnrepository.com/artifact/com.github.database-rider/rider-junit5 -->
		<dependency>
		    <groupId>com.github.database-rider</groupId>
		    <artifactId>rider-junit5</artifactId>
		    <version>1.42.0</version>
		    <scope>test</scope>
		</dependency>
		
		<!-- Database Reider 輸出 XLSX Dataset 需要 poi-ooxml -->
		<!-- https://mvnrepository.com/artifact/org.apache.poi/poi-ooxml -->
		<dependency>
		    <groupId>org.apache.poi</groupId>
		    <artifactId>poi-ooxml</artifactId>
		    <version>5.3.0</version>
		</dependency>

	</dependencies>

</project>

TestNgDatabaseRiderTest.java:

package junittest;

import java.io.File;
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.testng.annotations.AfterTest;
import org.testng.annotations.BeforeTest;
import org.testng.annotations.AfterMethod;
import org.testng.annotations.BeforeMethod;
import org.testng.annotations.Test;

import com.github.database.rider.core.api.dataset.DataSetFormat;
import com.github.database.rider.core.api.exporter.DataSetExportConfig;
import com.github.database.rider.core.configuration.DBUnitConfig;
import com.github.database.rider.core.configuration.DataSetConfig;
import com.github.database.rider.core.dsl.RiderDSL;
import com.github.database.rider.core.exporter.DataSetExporter;

public class TestNgDatabaseRiderTest {

	static final String testResourceFolderPath = "";
	static final String backupDatasetResourcePath = "/backupDataset.xlsx";
	static final String testDatasetResourcePath = "/testDataset.xlsx";
	
	static final String jdbcDriverName = "org.postgresql.Driver";
	static final String databaseUrl = "jdbc:postgresql://localhost:5432/xxxDatabase";
	static final String databaseUsername = "username";
	static final String databasePassword = "password";
	
	static final String[] includeTableList = new String[] {"xxxTable1", 
														   "xxxTable2", 
														   "xxxTable3"};
                                                           
    static String[] statementsForExecuteAfterDataSet = new String[]{"SELECT setval('xxxTable1_xxxField_seq', (SELECT coalesce(MAX(xxxField), 0) + 1 FROM xxxTable1), false)",
            	   									   		        "SELECT setval('xxxTable2_xxxField_seq', (SELECT coalesce(MAX(xxxField), 0) + 1 FROM xxxTable2), false)"};

	@BeforeTest
	static void beforeAll() {
		//在所有 Test Case 執行之前先備份當前要用到的 Database Tables,
		//例如 export 成 XLSX 檔案
		try {			
			URL backupDatasetResourceFolderUrl = TestNgDatabaseRiderTest.class.getClassLoader().getResource(testResourceFolderPath);
			File backupDatasetResourceFolder = Paths.get(backupDatasetResourceFolderUrl.toURI()).toFile();
			String backupDatasetResourceFilePath = backupDatasetResourceFolder.getAbsolutePath() + backupDatasetResourcePath.replace("/", File.separator);
			
			DataSetExporter.getInstance().export(getConnection(),
												 new DataSetExportConfig()
												 	 .dataSetFormat(DataSetFormat.XLSX)
												 	 //可以設定只要 Export 某些 Table
												 	 .includeTables(includeTableList)
												 	 .queryList(new String[] {})
												 	 .outputFileName(backupDatasetResourceFilePath));
		} catch (Exception e) {
			e.printStackTrace();
		}
	}

	@BeforeMethod
	//在每一次的 Test Case 執行之前把 Test Dataset 檔案內容匯入當下 Database 中
	void beforeEach() throws DatabaseUnitException, SQLException {
		System.out.println("before each");
		
        try (Connection conn = getConnection();) {
			RiderDSL.withConnection(conn)
			    	.withDataSetConfig(new DataSetConfig(testResourceFolderPath + testDatasetResourcePath)				    		       
			    				   	//實測 executeStatementsBefore 和 executeStatementsAfter 效果不好,
                                   	// executeStatementsBefore 會在 DataSet 塞入 Database 前被執行,但我想要在 DataSet 塞入 Database 後執行。
                                   	// executeStatementsAfter 我目前測不出來什麼時候才會被執行,可能我理解有誤?
                                   	//.executeStatementsBefore("")
                                   	//.executeStatementsAfter("")
                                   	)
			    	.withDBUnitConfig(new DBUnitConfig()
                    				 .cacheConnection(false) //這個很重要,因為預設是 true,有可能會造成不預期的意外,例如使用了 cache 的 connection 但不知 connection 可能早被 close 了
					   			  	 .addDBUnitProperty("escapePattern", "\"?\"")
			    				  	 .addDBUnitProperty("caseSensitiveTableNames", true)
			    				  	 .addDBUnitProperty("allowEmptyFields", true))
	    	.createDataSet();
        }
        
        //你可以在這指定一些需要執行的前置做業 Sql 語句給 executeStatementsBefore,
        //例如使用 PostgreSQL 時,DatabaseOperation.CLEAN_INSERT.execute() 會把
        //遞增欄位的遞增值重設成 1,造成之後測試 insert 時發生問題 (id 重覆等之類的),
		//這時就會需要在這做例如如下 Sql 語句來設置正確的遞增值:
		//SELECT setval('xxxTable_xxxField_seq', (SELECT coalesce(MAX(xxxField), 0) + 1 FROM xxxTable), false)
        for (String statement : statementsForExecuteAfterDataSet) {
			try (Connection conn = getConnection();
				 PreparedStatement pstmt = conn.prepareStatement(statement);) {

				try (ResultSet rs = pstmt.executeQuery();) {
				}
			} catch (SQLException e) {
				Assert.fail("Exception occured: " + getExceptionDetail(e));
			}
		}
	}

	@AfterMethod
	void afterEach() {
	}

	@AfterTest
	//在所有 Test Case 測試完後,用之前 Export 出來的 Backup Dataset 匯回去到 Database 中
	static void afterAll() throws DatabaseUnitException, SQLException {
		System.out.println("afterAll");
		
        try (Connection conn = getConnection();) {
			RiderDSL.withConnection(conn)
        	    	.withDataSetConfig(new DataSetConfig(testResourceFolderPath + backupDatasetResourcePath)
        	    				   		.executeStatementsBefore("SELECT setval('xxxTable1_xxxField_seq', (SELECT coalesce(MAX(xxxField), 0) + 1 FROM xxxTable1), false)",
			    						   						 "SELECT setval('xxxTable2_xxxField_seq', (SELECT coalesce(MAX(xxxField), 0) + 1 FROM xxxTable2), false)"))
        	    	.withDBUnitConfig(new DBUnitConfig()
                			      	  .cacheConnection(false) //這個很重要,因為預設是 true,有可能會造成不預期的意外,例如使用了 cache 的 connection 但不知 connection 可能早被 close 了
        			   			  	  .addDBUnitProperty("escapePattern", "\"?\"")
        	    				  	  .addDBUnitProperty("caseSensitiveTableNames", true)
        	    				  	  .addDBUnitProperty("allowEmptyFields", true))
        	    	.createDataSet();
        }
                
        for (String statement : statementsForExecuteAfterDataSet) {
			try (Connection conn = getConnection();
				 PreparedStatement pstmt = conn.prepareStatement(statement);) {

				try (ResultSet rs = pstmt.executeQuery();) {
				}
			} catch (SQLException e) {
				Assert.fail("Exception occured: " + getExceptionDetail(e));
			}
		}        
	}
	
    //TestNg 可以使用 dependsOnMethods 來設定要在哪個 Test methods 之後才執行
	@Test(dependsOnMethods = {"test2"})
	void test() {
		assertEquals(1, 1, "Should be equal.");
		System.out.println("TestNg: test1");
	}
	
	@Test
	void test2() {
		assertEquals(1, 1, "Should be equal.");
		System.out.println("TestNg: test2");
	}
	
	static Connection getConnection() {
		Connection connection = null;
		
		try {
			Class.forName(jdbcDriverName);
			connection = DriverManager.getConnection(databaseUrl, databaseUsername, databasePassword);
		} catch (ClassNotFoundException | SQLException e) {
			e.printStackTrace();
		}
		
		return connection;
	}
}

參考資料:

  1. DataSet Executor
  2. RiderDSL

2024年7月24日 星期三

使用 Dabase Rider 進行 Database 測試

上一篇 使用 DBUnit 進行 Database 測試 介紹了 DBUnit,
這篇要來介紹 Database Rider。

Database Rider 是一個基於 DBUnit 的專案,它擴展了 DBUnit 原有的功能,並整合了 JUnit 5,
增加了註解 (Annotation) 的使用方式,讓使用上更為便利,
並且也支援了更多的 Dataset 檔輸出格式,像是除了原有的 XML, XLS,也多了 XLSX, JSON, YML ,不過實測還是 XLS, XLSX 比較好用,YML, JSON 還是會有一些跟 DBUnit 一樣的特殊字元問題,例如無法正確處理 \v (\u000B) 等。

以下直接演示實際的程式碼範例:

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/com.github.database-rider/rider-junit5 -->
		<dependency>
		    <groupId>com.github.database-rider</groupId>
		    <artifactId>rider-junit5</artifactId>
		    <version>1.42.0</version>
		    <scope>test</scope>
		</dependency>
		
		<!-- Database Reider 輸出 XLSX Dataset 需要 poi-ooxml -->
		<!-- https://mvnrepository.com/artifact/org.apache.poi/poi-ooxml -->
		<dependency>
		    <groupId>org.apache.poi</groupId>
		    <artifactId>poi-ooxml</artifactId>
		    <version>5.3.0</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.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.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;
import org.junit.jupiter.api.extension.ExtendWith;

import com.github.database.rider.core.api.configuration.DBUnit;
import com.github.database.rider.core.api.connection.ConnectionHolder;
import com.github.database.rider.core.api.dataset.DataSet;
import com.github.database.rider.core.api.dataset.DataSetFormat;
import com.github.database.rider.core.api.exporter.DataSetExportConfig;
import com.github.database.rider.core.exporter.DataSetExporter;
import com.github.database.rider.junit5.DBUnitExtension;

//可以不使用 @ExtendWith, @DBUnit Annotation, @DBRider,直接使用 Database Rider 提供的 API 來做,
//只有想要用例如 @DataSet, @ExpectedDataSet 這類 Annotation 的功能時才需要。
@ExtendWith(DBUnitExtension.class)
@DBUnit(escapePattern = "\"?\"",  //@DBUnit 可以設定一些 DBUnitConfig 參數給 @Dataset 使用
	    caseSensitiveTableNames = true,
	    allowEmptyFields = true)
//@DBRider(dataSourceBeanName = "xxx_DataSource") //如果有使用 Spring,可能會需要指定 Spring 中設定的 Data Source Name,因為 Database-Rider 會偵測到有 Spring 去抓 Datasource
class DBRiderTest {

	static final String testResourceFolderPath = "";
	static final String backupDatasetResourcePath = "/backupDataset.xlsx";
	static final String testDatasetResourcePath = "/testDataset.xlsx";
	
	static final String jdbcDriverName = "org.postgresql.Driver";
	static final String databaseUrl = "jdbc:postgresql://localhost:5432/xxxDatabase";
	static final String databaseUsername = "username";
	static final String databasePassword = "password";
	
	static final String[] includeTableList = new String[] {"xxxTable1", 
														   "xxxTable2", 
														   "xxxTable3"};
	
	//在使用 Annotation 的方式,即使用了 @ExtendWith(DBUnitExtension.class) 和 @DBUnit
	//或 @DBRider 時,可以定義一個 ConnectionHolder connectionHolder 屬性,
	//Database Rider 會用 Java Reflection (反射) 的方式從 connectionHolder
	//中呼叫 getConnection()來得到 Connection
	private static ConnectionHolder connectionHolder = new ConnectionHolder() {

		@Override
		public Connection getConnection() throws SQLException {
			try {
				Class.forName(jdbcDriverName);
			} catch (ClassNotFoundException e) {
				e.printStackTrace();
			}
			
			return DriverManager.getConnection(databaseUrl, databaseUsername, databasePassword);
		}
	};

	@BeforeAll //雖然 Database Rider 有提供 @ExportDataSet,但實測只有在被標注 @Test 的 non-static method 上才有用,不能用在 @BeforeAll 上,所以這裡直接使用 API 實作
	static void backupBackupDataset() {
		//在所有 Test Case 執行之前先備份當前要用到的 Database Tables,
		//例如 export 成 XLSX 檔案
		try {			
			URL backupDatasetResourceFolderUrl = DBRiderTest.class.getClassLoader().getResource(testResourceFolderPath);
			File backupDatasetResourceFolder = Paths.get(backupDatasetResourceFolderUrl.toURI()).toFile();
			String backupDatasetResourceFilePath = backupDatasetResourceFolder.getAbsolutePath() + backupDatasetResourcePath.replace("/", File.separator);
			
			DataSetExporter.getInstance().export(connectionHolder.getConnection(),
												 new DataSetExportConfig()
												 	 .dataSetFormat(DataSetFormat.XLSX)
													 //可以設定只要 Export 某些 Table
												 	 .includeTables(includeTableList)
												 	 .queryList(new String[] {})
												 	 .outputFileName(backupDatasetResourceFilePath));
		} catch (Exception e) {
			e.printStackTrace();
		}
	}

	@BeforeEach
	//在每一次的 Test Case 執行之前把 Test Dataset 檔案內容匯入當下 Database 中    
	@DataSet(value = testResourceFolderPath + testDatasetResourcePath,
             //executeStatementsBefore 可以設定在塞 dataset 前想要執行的 sql 語句,
             //例如 SQL Server (MS Sql) 要塞 Primary Key 值時可能會需要設定 SET IDENTITY_INSERT test_table ON 才行
             executeStatementsBefore = {},
             //executeStatementsAfter 實測好像沒效果,不確定是什麼原因還是是 Bug ?
             //所以如果有需要在 DataSet 塞入 Database 後執行的 Sql,
             //使用 @DataSet 的 Annotation (註解) 方式時,
             //還是必須在 method function 中直接用程式碼執行 DataSet 塞入 Database 後要執行的 Sql 語句,
             //例如 PostgreSQL 可能會需要在放入 dataset 後要設定遞增欄位的值,像是:
			 //SELECT setval('xxxTable_xxxField_seq', (SELECT coalesce(MAX(xxxField), 0) + 1 FROM xxxTable), false)
             executeStatementsAfter = {}
             )
	void importTestDatasetByAnnotation() throws DatabaseUnitException, SQLException {        
        try (Connection conn = connectionHolder.getConnection();
				 PreparedStatement pstmt = conn.prepareStatement("SELECT setval('xxxTable_xxxField_seq', (SELECT coalesce(MAX(xxxField), 0) + 1 FROM xxxTable), false)");) {
		
			try (ResultSet rs = pstmt.executeQuery();) {
			}
		} catch (SQLException e) {
			Assert.fail("Exception occured: " + getExceptionDetail(e));
		}
	}
    
    //也可以不使用 @DataSet,直接用 DatabaseRider 提供的 API 塞資料給 Database
    @BeforeEach 
	static void importTestDataset() throws DatabaseUnitException, SQLException {
		try (Connection conn = connectionHolder.getConnection();) {
        	RiderDSL.withConnection(conn)
			    	.withDataSetConfig(new DataSetConfig(testResourceFolderPath + testDatasetResourcePath))
			    	.withDBUnitConfig(new DBUnitConfig()
                                  	  .cacheConnection(false) //這個很重要,因為預設是 true,有可能會造成不預期的意外,例如使用了 cache 的 connection 但不知 connection 可能早被 close 了
					   			  	  .addDBUnitProperty("escapePattern", "\"?\"")
			    				  	  .addDBUnitProperty("caseSensitiveTableNames", true)
			    				  	  .addDBUnitProperty("allowEmptyFields", true))
			    	.createDataSet();
		}
        
		try (Connection conn = connectionHolder.getConnection();
				 PreparedStatement pstmt = conn.prepareStatement("SELECT setval('xxxTable_xxxField_seq', (SELECT coalesce(MAX(xxxField), 0) + 1 FROM xxxTable), false)");) {
		
			try (ResultSet rs = pstmt.executeQuery();) {
			}
		} catch (SQLException e) {
			Assert.fail("Exception occured: " + getExceptionDetail(e));
		}
	}
    
    

	@AfterEach
	void afterEach() {
	}

	@AfterAll //因為實測發現 @Dataset 只能作用在被標注 @Test 的 non-static method 上,所以這裡直接使用 API 實作
	static void importBackupDatasetBack() throws DatabaseUnitException, SQLException {
    	try (Connection conn = connectionHolder.getConnection();) {
			RiderDSL.withConnection(conn)
			    	.withDataSetConfig(new DataSetConfig(testResourceFolderPath + backupDatasetResourcePath))
			    	.withDBUnitConfig(new DBUnitConfig()
                				  	  .cacheConnection(false)
					   			  	  .addDBUnitProperty("escapePattern", "\"?\"")
			    				  	  .addDBUnitProperty("caseSensitiveTableNames", true)
			    				  	  .addDBUnitProperty("allowEmptyFields", true))
			    	.createDataSet();
		}
        
		try (Connection conn = connectionHolder.getConnection();
				 PreparedStatement pstmt = conn.prepareStatement("SELECT setval('xxxTable_xxxField_seq', (SELECT coalesce(MAX(xxxField), 0) + 1 FROM xxxTable), false)");) {
		
			try (ResultSet rs = pstmt.executeQuery();) {
			}
		} catch (SQLException e) {
			Assert.fail("Exception occured: " + getExceptionDetail(e));
		}
	}
	
	//使用 @ExpectedDataSet Annotation 比較 database table 的資料是否和 expected dataset 一樣
	@Test
	@ExpectedDataSet(value = "expectedDataset.xlsx")
	void testExpectedDatasetByAnnotation() throws DatabaseUnitException, SQLException {
		// 做你想做的 Test
		
		//例如以下是比較 Actual dataset 和 expected dataset 的範例 (請再自己準備一個 expected dataset file):
		//自己修改一下 database table 的資料
		try(PreparedStatement pstmt = connectionHolder.getConnection().prepareStatement("UPDATE test_table SET title = 'ABC' WHERE id = 2")) {
			pstmt.executeUpdate();
		}
		
		//因為使用了 @ExpectedDataSet Annotation,所以這裡就不需要再多寫用 RiderDSL 等的程式碼了
	}
	
	@Test //也可以不使用用 @ExpectedDataSet Annotation,直接使用 RiderDSL 來比較
	void testExpectedDataset() throws DatabaseUnitException, SQLException {
		// 做你想做的 Test
		
		//例如以下是比較 Actual dataset 和 expected dataset 的範例 (請再自己準備一個 expected dataset file):
		//自己修改一下 database table 的資料
		try(PreparedStatement pstmt = connectionHolder.getConnection().prepareStatement("UPDATE test_table SET title = 'ABC' WHERE id = 2")) {
			pstmt.executeUpdate();
		}
		
		//比較 database table 的資料是否和 expected dataset 一樣
		RiderDSL.withConnection(connectionHolder.getConnection())
			    .withDataSetConfig(new DataSetConfig("expectedDataset.xlsx"))
			    .withDBUnitConfig(new DBUnitConfig()
					              .addDBUnitProperty("escapePattern", "\"?\"")
					              .addDBUnitProperty("caseSensitiveTableNames", true)
					              .addDBUnitProperty("allowEmptyFields", true))
		        .expectDataSet(new ExpectedDataSetConfig());
	}
}

參考資料:

  1. Database Rider
  2. GitHub - database-rider/database-rider: Database testing made easy!

使用 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 等無法處理正確的問題
    //XLS 也會無法正確顯示 \v 字元,但至少程式不會有錯誤停住,file 也能輸出
	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() throws DatabaseUnitException, MalformedURLException, SQLException {
		//做你想做的 Test
        
		//例如以下是比較 Actual dataset 和 expected dataset 的範例 (請再自己準備一個 expected dataset file):
		//從 Database 取得 Dataset
		//Get actual dataset from database
        IDataSet dataSet = getIDatabaseConnection().createDataSet();
        ITable actualTable = dataSet.getTable("test_table");
        
        //取得預期 Dataset (expected dataset)
        //Load expected dataset from expected dataset file
        //From xls file
        IDataSet expectedDataSetFromXls = new XlsDataFileLoader().load("xxx/xxx/expectedDataSet.xls");
        //Or from xml file
        IDataSet expectedDataSetFromXml =  new FlatXmlDataSetBuilder().build(new File("xxx/xxx/expectedDataSet.xml"));
        ITable expectedTable = expectedDataSetFromXls.getTable("test_table");

        //比較 Actual Dataset 和 Expected Dataset 是否一樣
        //Assert actual database table match expected table
        Assertion.assertEquals(expectedTable, actualTable);
	}
}

我實測的情況是,目前 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 - 博客园