2024年8月28日 星期三

Spring - 使用 MockMVC 做 Controller 的 Unit Test - MockMvcBuilders.webAppContextSetup(webApplicationContext ) 與 MockMvcBuilders.standaloneSetup(controller) 的差別

在 SpringMVC Project中,如果想要對 Controller 做 Unit Test 的話,

通常會使用 Spring 提供的 MockMvc 來達成,
獲取了 MockMvc 後,其提供了模擬 HttpPost, HttpGet 等 HttpRequest 去觸發 Controller 中的 Method 以供測試,範例程式碼如下:  

MvcResult mvcResult = mockMvc.perform(MockMvcRequestBuilders.post("/xxx/xxx/xxx.do")
									  .session(new MockHttpSession())
									  .param("xxx", "yyy")
									  ).andReturn();
String responseBody = mvcResult.getResponse().getContentAsString();

Spring 提供了兩個 Method 供我們取得 MockMvc ,分別是: MockMvcBuilders.webAppContextSetup(webApplicationContext)

MockMvcBuilders.standaloneSetup(controller)。

MockMvcBuilders.webAppContextSetup(webApplicationContext) 需要的參數是 webApplicationContext,
webApplicationContext 可以用指定 Spring Config XML file 、指令 Config Class 、自己手動獲取 (例如自己使用 new XmlWebApplicationContext() 或 new AnnotationConfigWebApplicationContext() 方式做設定取得) 來取得,
此時 Spring 會幫我們建立整個 Spring Application 的上下文 (Context)。
如果使用 Junit 5 , Spring 提供了一個 SpringExtension  的 Junit 5 Extension 可以使用,
它可以幫我們建立 Spring Application 上下文,並讓我們可以在 Unit Test 中使用 @Autowired 取得裝配好的 Bean。
因為會建立整個 Spring Application ,所以會花比較久的時間,
並且如果想要用 Mock 去取代某些依賴 Bean 的話 (例如 Controller 裡面用 @Autowired 設定的特定 Service),需要自己用 ReflectionTestUtils 以反射(Reflection)去處理。
因為整個 Spring Application 的上下文都被建立起來,Spring Bean 都被裝配完成的關係,
此方法比較接近整合測試 (Integration Test) 而不像單元測試 (Unit Test)。

MockMvcBuilders.standaloneSetup(controller) 只需要特定待測的 Controller (可以多個) 做為參數,
此時 Spring 只會以做為參數的 Controller 來建立 mockMvc,
因為此方法不會設定 Spring Config,所以 Spring 也不會知道如何裝配 Controller 中的依賴,
裝配依賴要自己實做,
我們可以使用 Mockito 提供的 @Mock 和 @Injects 註解來實現 Mock 依賴裝配,
被 @Mock 標注的 Bean 能以 Mock Bean 的方式 Inject 進被 @Injects 標注的 Bean 中。
此外,因為不用建立整個 Spring Application 的關係,此方式執行的速度比較快。
也因為只關注待測的 Controller ,其他的都是 Mock Bean,所以比較接近單元測試 (Unit Test)。

下面展示一下程式範例,直接看程式應該會比較清楚了解,相關的說明也已寫在了註解中。

這裡範例借用了上一篇 No XML for Java EE Spring Application 的設定,
順便複習一下使用 No XML 的方式來設定 Java EE Spring Application,
所以此例不存在 web.xml 和 Spring Config File。

專案結構如下圖:


pom.xml :
<?xml version="1.0" encoding="UTF-8"?>

<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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>

	<groupId>test</groupId>
	<artifactId>springunittest</artifactId>
	<version>0.1</version>
	<packaging>war</packaging>

	<name>springunittest Maven Webapp</name>
	<!-- FIXME change it to the project's website -->
	<url>http://www.example.com</url>

	<properties>
		<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
		<maven.compiler.source>11</maven.compiler.source>
		<maven.compiler.target>11</maven.compiler.target>
	</properties>

	<build>
		<finalName>springunittest</finalName>
		<pluginManagement><!-- lock down plugins versions to avoid using Maven
			defaults (may be moved to parent pom) -->
			<plugins>
				<plugin>
					<artifactId>maven-clean-plugin</artifactId>
					<version>3.1.0</version>
				</plugin>
				<!-- see
				http://maven.apache.org/ref/current/maven-core/default-bindings.html#Plugin_bindings_for_war_packaging -->
				<plugin>
					<artifactId>maven-resources-plugin</artifactId>
					<version>3.0.2</version>
				</plugin>
				<plugin>
					<artifactId>maven-compiler-plugin</artifactId>
					<version>3.8.0</version>
				</plugin>
				<plugin>
					<artifactId>maven-surefire-plugin</artifactId>
					<version>3.1.2</version>
				</plugin>
				<plugin>
					<artifactId>maven-failsafe-plugin</artifactId>
					<version>3.1.2</version>
				</plugin>
				<plugin>
					<artifactId>maven-war-plugin</artifactId>
					<version>3.2.2</version>
				</plugin>
				<plugin>
					<artifactId>maven-install-plugin</artifactId>
					<version>2.5.2</version>
				</plugin>
				<plugin>
					<artifactId>maven-deploy-plugin</artifactId>
					<version>2.8.2</version>
				</plugin>
			</plugins>
		</pluginManagement>
	</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.mockito/mockito-junit-jupiter -->
		<dependency>
			<groupId>org.mockito</groupId>
			<artifactId>mockito-junit-jupiter</artifactId>
			<version>5.10.0</version>
			<scope>test</scope>
		</dependency>

		<!-- https://mvnrepository.com/artifact/javax.servlet/javax.servlet-api -->
		<dependency>
			<groupId>javax.servlet</groupId>
			<artifactId>javax.servlet-api</artifactId>
			<version>4.0.1</version>
		</dependency>

		<!-- https://mvnrepository.com/artifact/org.springframework/spring-webmvc -->
		<dependency>
			<groupId>org.springframework</groupId>
			<artifactId>spring-webmvc</artifactId>
			<version>5.3.18</version>
		</dependency>

		<!-- https://mvnrepository.com/artifact/org.springframework/spring-jdbc -->
		<dependency>
			<groupId>org.springframework</groupId>
			<artifactId>spring-jdbc</artifactId>
			<version>5.3.18</version>
		</dependency>
		
		<!-- https://mvnrepository.com/artifact/org.springframework/spring-test -->
		<dependency>
		    <groupId>org.springframework</groupId>
		    <artifactId>spring-test</artifactId>
		    <version>5.3.18</version>
		    <scope>test</scope>
		</dependency>
	</dependencies>
</project>

src/main/java/com/config/WebInitializer.java :

package com.config;

import javax.servlet.ServletContext;
import javax.servlet.ServletRegistration;

import org.springframework.web.WebApplicationInitializer;
import org.springframework.web.context.support.AnnotationConfigWebApplicationContext;
import org.springframework.web.context.support.XmlWebApplicationContext;
import org.springframework.web.servlet.DispatcherServlet;

//Servlet Container (例如 Tomcat) 會在 Web 應用啟動時尋找實作 WebApplicationInitializer 的 Class 並執行 onStartup()
public class WebInitializer implements WebApplicationInitializer {
	
	@Override
	public void onStartup(ServletContext servletContext) {
		
		//可以用 AnnotationConfigWebApplicationContext 設定 Class Config
		//或是用 XmlWebApplicationContext 設定 XML Config 
		//來建立 Spring Application 上下文 (Context)
		AnnotationConfigWebApplicationContext springWebApplicationContext = new AnnotationConfigWebApplicationContext();
		springWebApplicationContext.register(SpringApplicationConfig.class); //這裡可以指定 Spring Class Config
//		XmlWebApplicationContext springWebApplicationContext = new XmlWebApplicationContext();
//		springWebApplicationContext.setConfigLocation("classpath:application-config.xml"); //這裡可以指定 Spring XML Config
		
		DispatcherServlet dispatcherServlet = new DispatcherServlet(springWebApplicationContext);
		
		ServletRegistration.Dynamic springDispatcherServlet = servletContext.addServlet("springDispatcherServlet", dispatcherServlet);
		springDispatcherServlet.setLoadOnStartup(1);
		springDispatcherServlet.addMapping("/");
	}
}

src/main/java/com/config/SpringApplicationConfig.java :

package com.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.view.InternalResourceViewResolver;

@EnableWebMvc
@Configuration
@ComponentScan(basePackages = "com.*")
public class SpringApplicationConfig {
	
	@Bean
	public InternalResourceViewResolver setupViewResolver() {
		InternalResourceViewResolver viewResolver = new InternalResourceViewResolver();
		viewResolver.setPrefix("/WEB-INF/view/");
		viewResolver.setSuffix(".jsp");
		
		return viewResolver;
	}
}

src/main/java/com/controller/TestController.java :

package com.controller;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;

import com.service.TestService;

@Controller
public class TestController {
	
	@Autowired
	TestService testService;
	
	@ResponseBody
	@RequestMapping("/test")
	public String test() {
		return "HIHI";
	}
	
	@RequestMapping("/hello")
	public String hello() {
		return "hello";
	}
	
	@ResponseBody
	@RequestMapping("/grettingWithNameByMemberId")
	public String grettingWithNameByMemberId(int memberId) {
		return "Hello, " + testService.getNameByMemberId(memberId);
	}
	
}

src/main/java/com/service/TestService.java :

package com.service;

import org.springframework.stereotype.Service;

@Service
public class TestService {

	public String getNameByMemberId(int memberId) {
		return "realName";
	}
}

src/test/java/test/ControllerTestOnlySpecificController.java :

package test;

import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.mockito.ArgumentMatchers.anyInt;

import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
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.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import com.controller.TestController;
import com.service.TestService;

//預設 Junit 會為每個測試方法建立新的 Class Instance 來避免各測試方法間互相影響,
//所以 @BeforeAll 和 @AfterAll 只能標注在 static method 上。
//如果有需求需要把 @BeforeAll, @AfterAll 設定在 non-static method 上的話,
//可以設定 Lifecycle.PER_CLASS (預設是 Lifecycle.PER_METHOD),此時 JUnit 會改成只建立一次 Class Instance,
//並在同一個 Class Instance 中執行測試方法,
//這樣我們就可以把 @BeforeAll, @AfterAll 標注在 non-static method 上 
@TestInstance(Lifecycle.PER_CLASS) //這裡只是舉例紀錄一下用法
public class ControllerTestOnlySpecificController {

	AutoCloseable closeableMocks;
	
	MockMvc mockMvc;
	
	//被標注 @InjectMocks 的 Bean 中,
	//如果有用到 Spring 裝配的 Bean,
	//被 @Mock 標注的 Bean 會被 Inject 到被標注 @InjectMocks 的 Bean 中。
	//例如此例的 TestController 有用到 TestService,
	//因為 TestController 被標注 @InjectMocks 和
	//TestService 被標注了 @Mock,
	//所以 TestService 會被 Inject 到 TestController 中
	@InjectMocks
	TestController mockTestController;
	
	@Mock
	TestService mockTestService;
	
	@BeforeAll
	void beforeAll() {
	}
	
	@AfterAll
	void afterAll() throws Exception {
	}
	
	@BeforeEach
	void beforeEach() {
		//使用 MockitoAnnotations.openMocks() 來讓 @InjectMocks, @Mock 等註解產生作用
		closeableMocks = MockitoAnnotations.openMocks(this);
		//這裡沒有讓 Spring 建立 Spring Application 上下文 (Context),所以也不用指定 Spring Config,
		//這裡只有讓 Spring 根據特定 Controller(可以指定多個) 建立只為測試 Controller 的 mockMvc,
		//必需自行設定 Controller 中會用到的所有依賴行為,例如此例的 TestService,
		//因為沒有建立整個 Spring Application,所以執行速度比較快,
		//且因為 Controller 中的所有相關依賴都沒有被 Spring 裝配起來,只關注 Controller 功能本身,
		//所以比較偏向 Unit Test 而不是 Integration Test 
		mockMvc = MockMvcBuilders.standaloneSetup(mockTestController).build();
	}
	
	@AfterEach
	void afterEach() throws Exception {
		closeableMocks.close();
	}
	
	@Test
	void grettingWithNameByMemberIdTest() throws Exception {
		//可以用 MockHttpSession 模擬 Session
		MockHttpSession mockSession = new MockHttpSession();
		
		String mockName = "mockName";
		String expectedReturnString = "Hello, " + mockName;
		//可以設宣 mockTestService.querySomeDataById(int id) 要回傳什麼值
		//注意:如果參數是 int,不能用 any() 代替,因為 int 不是 Object,此時 any() 會判斷用 null 回傳,
		//而造成 null 無法被轉型成 int 造成 NullPointerException
		when(mockTestService.getNameByMemberId(anyInt())).thenReturn(mockName);
		//使用 MockMvc 樣擬 Http Request 觸發 Controller,此例模擬 Http Post
		MvcResult mvcResult = mockMvc.perform(post("/grettingWithNameByMemberId")
										      .session(mockSession)
										      .param("memberId", "12345")
										     ).andReturn();
		String responseBody = mvcResult.getResponse().getContentAsString();
		Assertions.assertEquals(expectedReturnString, responseBody, "Should get correct data.");
	}
}

src/test/java/test/ControllerTestRunningWholeSpringApplication.java :

package test;

import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.mockito.ArgumentMatchers.anyInt;

import org.junit.jupiter.api.AfterEach;
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.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mockito;
import org.mockito.MockitoAnnotations;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.test.context.web.WebAppConfiguration;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;

import com.config.SpringApplicationConfig;
import com.controller.TestController;
import com.service.TestService;

//使用以下設定後,Spring 會幫我們以設定的 Spring Config 建立完整的 Spring Application 上下文 (Context),
//我們就可以得到實際裝配好的 Spring Bean。
//嚴格說這比較像是整合測試(Integration Test)而不是單元測試(Unit test),
//單元測試應該要只測試 Controller 本身,其中依賴的其他 Bean (例如 TestService) 不應該由 Spring 幫忙裝配
@ExtendWith(SpringExtension.class)
@WebAppConfiguration
@ContextConfiguration(classes = SpringApplicationConfig.class)
//也可以指定 Spring XML 的位置
//@ContextConfiguration({"file:xxx/xxx/spring-config.xml"})
/**
 * @ExtendWith + @WebAppConfiguration + @ContextConfiguration = @SpringJUnitWebConfig
 * 上述程式碼等同於:
 * @SpringJUnitWebConfig(SpringApplicationConfig.class)
 */
@TestInstance(Lifecycle.PER_CLASS)
public class ControllerTestRunningWholeSpringApplication {

AutoCloseable closeableMocks;
	
	MockMvc mockMvc;
	
	@Autowired
	TestController testController;
	
	@Autowired
	TestService testService;

	@BeforeAll
	void beforeAll(WebApplicationContext webApplicationContext) {
		//webApplicationContext 也可以用 @Autowired 得到
		mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build();
	}
	
	@BeforeEach
	void beforeEach() {
		closeableMocks = MockitoAnnotations.openMocks(this);
	}
	
	@AfterEach
	void afterEach() throws Exception {
		closeableMocks.close();
	}
	
	@Test
	void grettingWithNameByMemberIdTest() throws Exception {
		MockHttpSession mockSession = new MockHttpSession();
		
		String mockName = "mockName";
		String expectedReturnString = "Hello, " + mockName;
		//如果要對特定 Controller 的依賴用 Mock 替換來測試,例如 TestService,
		//可以用 ReflectionTestUtils 使用反射(Reflection) 來達成,
		//測完後別忘了把原來的依賴換回去,不要影響其他他測試方法。
		TestService mockTestService = Mockito.mock(TestService.class);
		ReflectionTestUtils.setField(testController, "testService", mockTestService);
		when(mockTestService.getNameByMemberId(anyInt())).thenReturn(mockName);
		
		//使用 MockMvc 樣擬 Http Request 觸發 Controller,此例模擬 Http Post
		MvcResult mvcResult = mockMvc.perform(post("/grettingWithNameByMemberId")
										      .session(mockSession)
										      .param("memberId", "12345")
										     ).andReturn();
		String responseBody = mvcResult.getResponse().getContentAsString();
		Assertions.assertEquals(expectedReturnString, responseBody, "Should get correct data.");

		//把 TestController 原來的依賴換回去,不要影響其他他測試方法。
		ReflectionTestUtils.setField(testController, "testService", testService);
	}
}

源碼下載分享:
spring-unit-test.7z

參考資料:

  1. 第 5 章 Spring 应用的测试 - 《Java 研发自测》
  2. Setup Choices :: Spring Framework
  3. 测试实例生命周期 | Junit 5官方文档中文版
  4. 菜鳥工程師 肉豬: Mockito @Mock與@InjectMocks的差別
  5. The SpringJUnitConfig and SpringJUnitWebConfig Annotations in Spring 5 – Spring5中的SpringJUnitConfig和SpringJUnitWebConfig注解

2024年8月27日 星期二

No XML for Java EE Spring Application

這裡來紀錄一下如何不使用 XML 配置檔改用程式的方式來設定 Java EE + Spring 的 Web Application。

首先來看一下專案的檔案結構如下圖:




因為不用 XML 了的關係,所以我們這次範例專案中不會特別放 Java EE 的 web.xml 和 Spring 的 Config XML。

pom.xml (設定了一些要用到的 Dependency) :
<?xml version="1.0" encoding="UTF-8"?>

<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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <groupId>my.test</groupId>
  <artifactId>javaee-spring-no-xml-test</artifactId>
  <version>0.1</version>
  <packaging>war</packaging>

  <name>javaee-spring-no-xml-test Maven Webapp</name>
  <!-- FIXME change it to the project's website -->
  <url>http://www.example.com</url>

  <properties>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <maven.compiler.source>11</maven.compiler.source>
    <maven.compiler.target>11</maven.compiler.target>
  </properties>

  <dependencies>
    <dependency>
      <groupId>junit</groupId>
      <artifactId>junit</artifactId>
      <version>4.11</version>
      <scope>test</scope>
    </dependency>
    
    <!-- https://mvnrepository.com/artifact/javax.servlet/javax.servlet-api -->
	<dependency>
	    <groupId>javax.servlet</groupId>
	    <artifactId>javax.servlet-api</artifactId>
	    <version>4.0.1</version>
	</dependency>
	
    <!-- https://mvnrepository.com/artifact/org.springframework/spring-webmvc -->  
    <dependency>
    	<groupId>org.springframework</groupId>
    	<artifactId>spring-webmvc</artifactId>
    	<version>5.3.18</version>
	</dependency>
	
	<!-- https://mvnrepository.com/artifact/org.springframework/spring-jdbc -->
	<dependency>
	    <groupId>org.springframework</groupId>
	    <artifactId>spring-jdbc</artifactId>
	    <version>5.3.18</version>
	</dependency>

  </dependencies>

  <build>
    <finalName>javaee-spring-no-xml-test</finalName>
    <pluginManagement><!-- lock down plugins versions to avoid using Maven defaults (may be moved to parent pom) -->
      <plugins>
        <plugin>
          <artifactId>maven-clean-plugin</artifactId>
          <version>3.1.0</version>
        </plugin>
        <!-- see http://maven.apache.org/ref/current/maven-core/default-bindings.html#Plugin_bindings_for_war_packaging -->
        <plugin>
          <artifactId>maven-resources-plugin</artifactId>
          <version>3.0.2</version>
        </plugin>
        <plugin>
          <artifactId>maven-compiler-plugin</artifactId>
          <version>3.8.0</version>
        </plugin>
        <plugin>
          <artifactId>maven-surefire-plugin</artifactId>
          <version>2.22.1</version>
        </plugin>
        <plugin>
          <artifactId>maven-war-plugin</artifactId>
          <version>3.2.2</version>
        </plugin>
        <plugin>
          <artifactId>maven-install-plugin</artifactId>
          <version>2.5.2</version>
        </plugin>
        <plugin>
          <artifactId>maven-deploy-plugin</artifactId>
          <version>2.8.2</version>
        </plugin>
      </plugins>
    </pluginManagement>
  </build>
</project>

src/main/com/config/WebInitializer.java (Java EE Web Application 的啟動設定,取代 web.xml) :
package com.config;

import javax.servlet.ServletContext;
import javax.servlet.ServletRegistration;

import org.springframework.web.WebApplicationInitializer;
import org.springframework.web.context.support.AnnotationConfigWebApplicationContext;
import org.springframework.web.context.support.XmlWebApplicationContext;
import org.springframework.web.servlet.DispatcherServlet;

//Servlet Container (例如 Tomcat) 會在 Web 應用啟動時尋找實作 WebApplicationInitializer 的 Class 並執行 onStartup()
public class WebInitializer implements WebApplicationInitializer {
	
	@Override
	public void onStartup(ServletContext servletContext) {
		
		//可以用 AnnotationConfigWebApplicationContext 設定 Class Config
		//或是用 XmlWebApplicationContext 設定 XML Config 
		//來建立 Spring Application 上下文 (Context)
		AnnotationConfigWebApplicationContext springWebApplicationContext = new AnnotationConfigWebApplicationContext();
		springWebApplicationContext.register(SpringApplicationConfig.class); //這裡可以指定 Spring Class Config
//		XmlWebApplicationContext springWebApplicationContext = new XmlWebApplicationContext();
//		springWebApplicationContext.setConfigLocation("classpath:application-config.xml"); //這裡可以指定 Spring XML Config
		
		DispatcherServlet dispatcherServlet = new DispatcherServlet(springWebApplicationContext);
		
		ServletRegistration.Dynamic springDispatcherServlet = servletContext.addServlet("springDispatcherServlet", dispatcherServlet);
		springDispatcherServlet.setLoadOnStartup(1);
		springDispatcherServlet.addMapping("/");
		/** 上述 code 相當於 web.xml 中的以下 DispatcherServlet 設定
		 * <servlet>
				<servlet-name>springDispatcherServlet</servlet-name>
				<servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
				<init-param>
					<param-name>contextConfigLocation</param-name>
					<param-value>/xx/xx/application-config.xml</param-value>
					</init-param>
				<load-on-startup>1</load-on-startup>
			</servlet>
			<servlet-mapping>
				<servlet-name>springDispatcherServlet</servlet-name> 
				<url-pattern>/</url-pattern>
			</servlet-mapping>
		 */
	}
}
src/main/com/config/SpringApplicatoinConfig.java (Spring Config 設定,取代 Spring XML Config File) :
package com.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.view.InternalResourceViewResolver;

@EnableWebMvc
@Configuration
@ComponentScan(basePackages = "com.*")
public class SpringApplicationConfig {
	
	@Bean
	public InternalResourceViewResolver setupViewResolver() {
		InternalResourceViewResolver viewResolver = new InternalResourceViewResolver();
		viewResolver.setPrefix("/WEB-INF/view/");
		viewResolver.setSuffix(".jsp");
		
		return viewResolver;
		
		/** 上述 code 相當於 Spring Config XML 的如下 InternalResourceViewResolver 設定
		 * <bean id="viewResolver" class="org.springframework.web.servlet.view.InternalResourceViewResolver"> 
		        <property name="prefix">
		            <value>/WEB-INF/view/</value>
		        </property>
		        <property name="suffix">
		            <value>.jsp</value>
		        </property>
		    </bean>
		 */
	}
}


src/main/com/controller/TestController.java (測試用的 Controller) :
package com.controller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;

@Controller
public class TestController {
	
	@ResponseBody
	@RequestMapping("/test")
	public String test() {
		return "HIHI";
	}
	
	@RequestMapping("/hello")
	public String hello() {
		return "hello";
	}
	
}

src/main/webapp/WEB-INF/view/hello.jsp (測試用的 JSP View) : 
<%@ page language="java" contentType="text/html; charset=UTF-8"
    pageEncoding="UTF-8"%>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Hello</title>
</head>
<body>
Say hello!
</body>
</html>

可以自己跑一個 Tomcat 起來試,應該可以成功存取以下 url

  1. http://localhost:8080/index.jsp (直接存取 webapp 下的 JSP Resource)
  2. http://localhost:8080/hello (會觸發 TestController.hello(),Request 會導向 webapp/WEB-INF/view/hello.jsp)
  3. http://localhost:8080/test (會觸發 TestController.test())

源碼下載分享:
javaee-spring-no-xml-test.7z


參考資料:

  1.  NO XML - Spring MVC Java-based configuration in 9 steps || Spring Annotation || Part 3

2024年8月25日 星期日

Webpack 使用 Bebel 並用 @babel/preset-typescript 使用 typescript

記錄下在使用 Webpack 時,
如何使用 Bebel 並用 @babel/preset-typescript 的方式配合使用 Typescript 的功能

將 @babel/preset-typescript 設定在 babel-loader 的 presets 裡就好了。

package.json:

webpack.config.js:

{
  "name": "xxx-proejct",
  "scripts": {
    "deploy": "webpack"
  },
  "devDependencies": {
    "@babel/core": "7.18.10",
    "@babel/preset-env": "7.18.10",
    "@babel/preset-typescript": "^7.24.7",
    "babel-loader": "8.2.5",
    "css-loader": "5.0.1",
    "mini-css-extract-plugin": "1.3.3",
    "sass": "1.54.9",
    "sass-loader": "10.1.0",
    "style-loader": "3.3.1",
    "webpack": "5.74.0",
    "webpack-cli": "4.10.0"
  }
}

webpack.config.js:

var path = require('path');
var MiniCssExtractPlugin = require('mini-css-extract-plugin');
module.exports = {
    entry: {
        index_js: {
            import: "./src/app.js",
            filename: "js/index_bundle.js"
        }
    },
    output: {
        path: __dirname + "/dist"
    },
    watch: false,
    devtool : 'source-map',
    mode : "production",
    resolve : {
     extensions : ['.js', '.ts'],
     modules: [path.resolve(__dirname, '.'), 'node_modules']
    },
    module: {
        rules : [{
            test : /\.(ts|js)$/,
            use : {
                loader : 'babel-loader',
                options : {
                    presets : ['@babel/preset-env', '@babel/preset-typescript']
                }
            }
        },
        {
          test: /\.s[ac]ss$/,
          use: [        
               MiniCssExtractPlugin.loader,
               "css-loader", "sass-loader"
              ]
        }]
    },
    plugins: [new MiniCssExtractPlugin({
        filename: "./css/index_bundle.css"
    })],
    externals: {
        angular: "angular"
    }
}

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月28日 星期日

使用 JUnit5 同時跑 JUnit5 和 TestNg 的 Test Case

通常來講 Java 的 Unit Test 只會用一個 Framework 來跑,
例如使用JUnit5、或著是使用 TestNg 等,一次只會用一種 Framework,
不過 JUnit5 提供一個 JUnit Platform 可供其他第三方的 Unit Test framework去 implement,
讓 JUnit5 可以跑其他的 Unit Test framework,
這樣我們就可以同時跑專案裡的 JUnit5 和 TestNg 的測試案例了。

下面展示範例:

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>
			<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>

	</dependencies>

</project>

兩個 Test Case Class,一個使用 JUnit5、另一個使用 TestNg:

Juit5Test.java (使用 JUnit5):

package junittest;

import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;

public class Juit5Test {
	
	@BeforeAll
	static void beforeAll() {
		System.out.println("Junit5: beforeAll");
	}
	
	@Test
	void test() {
		assertEquals(1, 1, "Should be equal.");
		System.out.println("Junit5: test1");
	}
	
	@Test
	void test2() {
		assertEquals(1, 1, "Should be equal.");
		System.out.println("Junit5: test2");
	}
}

TestNgTest.java (使用 TestNg):

package junittest;

import static org.testng.Assert.assertEquals;

import org.testng.annotations.BeforeTest;
import org.testng.annotations.Test;
public class TestNgTest{

	@BeforeTest
	static void beforeAll() {
		System.out.println("TestNg: beforeAll");
	}
	
	//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");
	}
	
}

接下來我們就可以使用 Maven 的 test 指令來跑 Test Case:

mvn clean test

以下是跑完的結果,可以看到 JUnit5 和 TestNg 的 Test Case 都有被成功執行

[INFO] -------------------------------------------------------
[INFO]  T E S T S
[INFO] -------------------------------------------------------
SLF4J: Failed to load class "org.slf4j.impl.StaticLoggerBinder".
SLF4J: Defaulting to no-operation (NOP) logger implementation
SLF4J: See http://www.slf4j.org/codes.html#StaticLoggerBinder for further details.
[INFO] Running junittest.Juit5Test
Junit5: beforeAll
Junit5: Junit5: OK1
Junit5: Junit5: OK2
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.028 s -- in junittest.Juit5Test
[INFO] Running junittest.TestNgTest
TestNg: beforeAll
TestNg: TestNg: OK2
TestNg: TestNg: OK1
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.005 s -- in junittest.TestNgTest
[INFO]
[INFO] Results:
[INFO]
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0

The engine’s main intention is integration with build tools like Gradle and Maven. Hence, custom suites specified via testng.xml files are not supported.

  1. TestNG Engine for the JUnit Platform
  2. JUnit Jupiter Episode 3 - Running JUnit 4 / JUnit 5 / TestNG Tests in a Maven project.
  3. TestNg and Junit not running in same gradle project

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(DBUnitExtension.class)
@DBUnit(escapePattern = "\"?\"",
	    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"};
	
	//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
	static void beforeAll() {
		//在所有 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,
    		 //雖然 Database Rider 有提供 executeStatementsBefore 和 executeStatementsAfter 參數,
             //但實測好像不太有效果,
             //因為例如我想在 DataSet 塞入 Database 後執行 PostgreSQL 的 setval() 重設遞增值 等 Sql 語句,
             //但 executeStatementsBefore 的 Sql 會在 Dataset 塞入 Database 之前執行,
             //而 executeStatementsAfter 好像都不會被執行,可能我理解有誤,
             //就算直接在 method 中 (例如 beforeEach() 裡) 執行 Sql 語句,它也會在 DataSet 塞入 Database 之前執行。
			 //所以最後我想如果有需要在 DataSet 塞入 Database 後執行的 Sql,
             //可能要放棄使用 @DataSet 的 Annotation (註解) 方式,
             //改成直接用程式碼直接執行 DataSet 塞入 Database 動作再執行我們要的 Sql 語句,
             //下方示範一個實測可以成功的 beforeEach() 和 afterAll()。
			 //SELECT setval('xxxTable_xxxField_seq', (SELECT coalesce(MAX(xxxField), 0) + 1 FROM xxxTable), false)
			 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)"})
	void beforeEach_donnoWhyNotWork() throws DatabaseUnitException, SQLException {
	}
    */
    
    @BeforeEach
	static void beforeEach() 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
	static void afterAll() 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));
		}
	}
	
	@Test
	void test() {
		//做你想做的 Test
	}
}

參考資料:

  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 等無法處理正確的問題
	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 - 博客园