Spring MVC有一個優點:它的編程模型是圍繞POJO展開的,在POJO上添加註解,聲明如何處理Web請求。這種編程模型不僅簡單,還讓你能像對待應用程序中的其他組件一樣對待這些控制器。你還可以針對這些控制器編寫測試,就像測試POJO一樣。
舉例來說,考慮ReadingListController
裡的addToReadingList
方法:
@RequestMapping(method=RequestMethod.POST)
public String addToReadingList(Book book) {
book.setReader(reader);
readingListRepository.save(book);
return \"redirect:/readingList\";
}
如果忽略@RequestMapping
註解,你得到的就是一個相當基礎的Java方法。你立馬就能想到這樣一個測試,提供一個ReadingListRepository
的模擬實現,直接調用addToReadingList
,判斷返回值並驗證對ReadingListRepository
的save
方法有過調用。
該測試的問題在於,它僅僅測試了方法本身,當然,這要比沒有測試好一點。然而,它沒有測試該方法處理/readingList的POST
請求的情況,也沒有測試表單域綁定到Book
參數的情況。雖然你可以判斷返回的String
包含特定值,但沒法明確測試請求在方法處理完之後是否真的會重定向到/readingList。
要恰當地測試一個Web應用程序,你需要投入一些實際的HTTP請求,確認它能正確地處理那些請求。幸運的是,Spring Boot開發者有兩個可選的方案能實現這類測試。
Spring Mock MVC:能在一個近似真實的模擬Servlet容器裡測試控制器,而不用實際啟動應用服務器。
Web集成測試:在嵌入式Servlet容器(比如Tomcat或Jetty)裡啟動應用程序,在真正的應用服務器裡執行測試。
這兩種方法各有利弊。很明顯,啟動一個應用服務器會比模擬Servlet容器要慢一些,但毫無疑問基於服務器的測試會更接近真實環境,更接近部署到生產環境運行的情況。
接下來,你會看到如何使用Spring Mock MVC測試框架來測試Web應用程序。然後,在4.3節裡你會看到如何為運行在應用服務器裡的應用程序編寫測試。
4.2.1 模擬Spring MVC
早在Spring 3.2,Spring Framework就有了一套非常實用的Web應用程序測試工具,能模擬Spring MVC,不需要真實的Servlet容器也能對控制器發送HTTP請求。Spring的Mock MVC框架模擬了Spring MVC的很多功能。它幾乎和運行在Servlet容器裡的應用程序一樣,儘管實際情況並非如此。
要在測試裡設置Mock MVC,可以使用MockMvcBuilders
,該類提供了兩個靜態方法。
standaloneSetup
:構建一個Mock MVC,提供一個或多個手工創建並配置的控制器。webAppContextSetup
:使用Spring應用程序上下文來構建Mock MVC,該上下文裡可以包含一個或多個配置好的控制器。
兩者的主要區別在於,standaloneSetup
希望你手工初始化並注入你要測試的控制器,而webAppContextSetup
則基於一個WebApplicationContext
的實例,通常由Spring加載。前者同單元測試更加接近,你可能只想讓它專注於單一控制器的測試,而後者讓Spring加載控制器及其依賴,以便進行完整的集成測試。
我們要用的是webAppContextSetup
。Spring完成了ReadingListController
的初始化,並從Spring Boot自動配置的應用程序上下文裡將其注入,我們直接對其進行測試。
webAppContextSetup
接受一個WebApplicationContext
參數。因此,我們需要為測試類加上@WebAppConfiguration
註解,使用@Autowired
將WebApplicationContext
作為實例變量注入測試類。代碼清單4-2演示了Mock MVC測試的執行入口。
代碼清單4-2 為集成測試控制器創建Mock MVC
@RunWith(SpringJUnit4ClassRunner.class)
@SpringApplicationConfiguration(
classes = ReadingListApplication.class)
@WebAppConfiguration ←---開啟Web上下文測試
public class MockMvcWebTests {
@Autowired
private WebApplicationContext webContext; ←---注入WebApplicationContext
private MockMvc mockMvc;
@Before
public void setupMockMvc {
mockMvc = MockMvcBuilders ←---設置MockMvc
.webAppContextSetup(webContext)
.build;
}
}
@WebAppConfiguration
註解聲明,由SpringJUnit4ClassRunner
創建的應用程序上下文應該是一個WebApplicationContext
(相對於基本的非WebApplicationContext
)。
setupMockMvc
方法上添加了JUnit的@Before
註解,表明它應該在測試方法之前執行。它將 WebApplicationContext
注入webAppContextSetup
方法,然後調用build
產生了一個MockMvc
實例,該實例賦給了一個實例變量,供測試方法使用。
現在我們有了一個MockMvc
,已經可以開始寫測試方法了。我們先寫個簡單的測試方法,向/readingList發送一個HTTP GET
請求,判斷模型和視圖是否滿足我們的期望。下面的homePage
測試方法就是我們所需要的:
@Test
public void homePage throws Exception {
mockMvc.perform(MockMvcRequestBuilders.get(\"/readingList\"))
.andExpect(MockMvcResultMatchers.status.isOk)
.andExpect(MockMvcResultMatchers.view.name(\"readingList\"))
.andExpect(MockMvcResultMatchers.model.attributeExists(\"books\"))
.andExpect(MockMvcResultMatchers.model.attribute(\"books\",
Matchers.is(Matchers.empty)));
}
如你所見,我們在這個測試方法裡使用了很多靜態方法,包括Spring的MockMvcRequestBuilders
和MockMvcResultMatchers
裡的靜態方法,還有Hamcrest庫的Matchers
裡的靜態方法。在深入探討這個測試方法前,先添加一些靜態import
,這樣代碼看起來更清爽一些:
import static org.hamcrest.Matchers.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
有了這些靜態import
後,測試方法可以稍作調整:
@Test
public void homePage throws Exception {
mockMvc.perform(get(\"/readingList\"))
.andExpect(status.isOk)
.andExpect(view.name(\"readingList\"))
.andExpect(model.attributeExists(\"books\"))
.andExpect(model.attribute(\"books\", is(empty)));
}
現在這個測試方法讀起來就很自然了。首先向/readingList發起一個GET
請求,接下來希望該請求處理成功(isOk
會判斷HTTP 200響應碼),並且視圖的邏輯名稱為readingList
。測試還要斷定模型包含一個名為books
的屬性,該屬性是一個空集合。所有的斷言都很直觀。
值得一提的是,此處完全不需要將應用程序部署到Web服務器上,它是運行在模擬的Spring MVC中的,剛好能通過MockMvc
實例處理我們給它的HTTP請求。
太酷了,不是嗎?
讓我們再來看一個測試方法,這次會更有趣,我們實際發送一個HTTP POST
請求提交一本新書。我們應該期待POST
請求處理後重定向回/readingList,模型將包含新添加的圖書。代碼清單4-3演示了如何通過Spring的Mock MVC來實現這個測試。
代碼清單4-3 測試提交一本新書
@Test
public void postBook throws Exception {
mockMvc.perform(post(\"/readingList\") ←---執行POST請求
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.param(\"title\", \"BOOK TITLE\")
.param(\"author\", \"BOOK AUTHOR\")
.param(\"isbn\", \"1234567890\")
.param(\"description\", \"DESCRIPTION\"))
.andExpect(status.is3xxRedirection)
.andExpect(header.string(\"Location\", \"/readingList\"));
Book expectedBook = new Book; ←---配置期望的圖書
expectedBook.setId(1L);
expectedBook.setReader(\"craig\");
expectedBook.setTitle(\"BOOK TITLE\");
expectedBook.setAuthor(\"BOOK AUTHOR\");
expectedBook.setIsbn(\"1234567890\");
expectedBook.setDescription(\"DESCRIPTION\");
mockMvc.perform(get(\"/readingList\")) ←---執行GET請求
.andExpect(status.isOk)
.andExpect(view.name(\"readingList\"))
.andExpect(model.attributeExists(\"books\"))
.andExpect(model.attribute(\"books\", hasSize(1)))
.andExpect(model.attribute(\"books\",
contains(samePropertyValuesAs(expectedBook))));
}
很明顯,代碼清單4-3里的測試更加複雜,實際上是兩個測試放在一個方法裡。第一部分提交圖書並檢查了請求的結果,第二部分執行了一次對主頁的GET
請求,檢查新建的圖書是否在模型中。
在提交圖書時,我們必須確保內容類型(通過MediaType.APPLICATION_FORM_URLENCODED
)設置為application/x-www-form-urlencoded,這才是運行應用程序時瀏覽器會發送的內容類型。隨後,要用MockMvcRequestBuilders
的param
方法設置表單域,模擬要提交的表單。一旦請求執行,我們要檢查響應是否是一個到/readingList的重定向。
假定以上測試都通過,我們進入第二部分。首先設置一個Book
對象,包含想要的值。我們用這個對象和首頁獲取的模型的值進行對比。
隨後要對/readingList發起一個GET
請求,大部分內容和我們之前測試主頁時一樣,只是之前模型中有一個空集合,而現在有一個集合項。這裡要檢查它的內容是否和我們創建的 expectedBook
一致。如此一來,我們的控制器看來保存了發送給它的圖書,完成了工作。
至此,這些測試驗證了一個未經保護的應用程序,和我們在第2章裡寫的應用程序很類似。但如果我們想要測試一個安全加固過的應用程序(比如我們在第3章裡寫的程序),又該怎麼辦?
4.2.2 測試Web安全
Spring Security能讓你非常方便地測試安全加固後的Web應用程序。為了利用這點優勢,你必須在項目裡添加Spring Security的測試模塊。要在Gradle裡做到這一點,你需要的就是以下testCompile
依賴:
testCompile(\"org.springframework.security:spring-security-test\")
如果你用的是Maven,則添加以下<dependency>
:
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
應用程序的Classpath裡有了Spring Security的測試模塊之後,只需在創建MockMvc
實例時運用Spring Security的配置器。
@Before
public void setupMockMvc {
mockMvc = MockMvcBuilders
.webAppContextSetup(webContext)
.apply(springSecurity)
.build;
}
springSecurity
方法返回了一個Mock MVC配置器,為Mock MVC開啟了Spring Security支持。只需像上面這樣運用就行了,Spring Security會介入MockMvc
上執行的每個請求。具體的安全配置取決於你如何配置Spring Security(或者Spring Boot如何自動配置Spring Security)。在閱讀列表這個應用程序裡,我們在第3章裡創建SecurityConfig.java
時,配置也是如此。
springSecurity
方法springSecurity
是SecurityMockMvcConfigurers
的一個靜態方法,考慮到可讀性,我已經將其靜態導入。
開啟了Spring Security之後,在請求主頁的時候,我們便不能只期待HTTP 200響應。如果請求未經身份驗證,我們應該期待重定向到登錄頁面:
@Test
public void homePage_unauthenticatedUser throws Exception {
mockMvc.perform(get(\"/\"))
.andExpect(status.is3xxRedirection)
.andExpect(header.string(\"Location\",
\"http://localhost/login\"));
}
不過,經過身份驗證的請求又該如何發起呢?Spring Security提供了兩個註解。
@WithMockUser
:加載安全上下文,其中包含一個UserDetails
,使用了給定的用戶名、密碼和授權。@WithUserDetails
:根據給定的用戶名查找UserDetails
對象,加載安全上下文。
在這兩種情況下,Spring Security的安全上下文都會加載一個UserDetails
對象,添加了該註解的測試方法在運行過程中都會使用該對象。@WithMockUser
註解是兩者裡比較基礎的那個,允許顯式聲明一個UserDetails
,並加載到安全上下文。
@Test
@WithMockUser(username=\"craig\",
password=\"password\",
roles=\"READER\")
public void homePage_authenticatedUser throws Exception {
...
}
如你所見,@WithMockUser
繞過了對UserDetails
對象的正常查詢,用給定的值創建了一個UserDetails
對像取而代之。在簡單的測試裡,這就夠用了。但我們的測試需要Reader
(實現了UserDetails
)而非@WithMockUser
創建的通用UserDetails
。為此,我們需要@WithUserDetails
。
@WithUserDetails
註解使用事先配置好的UserDetailsService
來加載UserDetails
對象。回想一下第3章,我們配置了一個UserDetailsService
Bean,它會根據給定的用戶名查找並返回一個Reader
對象。太完美了!所以我們要為測試方法添加@WithUserDetails
註解,如代碼清單4-4所示。
代碼清單4-4 測試帶有用戶身份驗證的安全加固方法
@Test
@WithUserDetails(\"craig\") ←---使用craig用戶
public void homePage_authenticatedUser throws Exception {
Reader expectedReader = new Reader; ←---配置期望的Reader
expectedReader.setUsername(\"craig\");
expectedReader.setPassword(\"password\");
expectedReader.setFullname(\"Craig Walls\");
mockMvc.perform(get(\"/\")) ←---發起GET請求
.andExpect(status.isOk)
.andExpect(view.name(\"readingList\"))
.andExpect(model.attribute(\"reader\",
samePropertyValuesAs(expectedReader)))
.andExpect(model.attribute(\"books\", hasSize(0)))
}
在代碼清單4-4里,我們通過@WithUserDetails
註解聲明要在測試方法執行過程中向安全上下文裡加載craig用戶。Reader
會放入模型,該測試方法先創建了一個期望的Reader
對象,後續可以用來進行比較。隨後GET
請求發起,也有了針對視圖名和模型內容的斷言,其中包括名為reader
的模型屬性。
同樣,此處沒有啟動Servlet容器來運行這些測試,Spring的Mock MVC取代了實際的Servlet容器。這樣做的好處是測試方法運行相對較快。因為不需要等待服務器啟動,而且不需要打開Web瀏覽器發送表單,所以測試比較簡單快捷。
不過,這並不是一個完整的測試。它比直接調用控制器方法要好,但它並沒有真的在Web瀏覽器裡執行應用程序,驗證呈現出的視圖。為此,我們需要啟動一個真正的Web服務器,用真實瀏覽器來訪問它。讓我們來看看Spring Boot如何啟動一個真實的Web服務器來幫助測試。