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

沒有留言 :

張貼留言