讀古今文學網 > Spring Boot實戰 > 4.2 測試Web應用程序 >

4.2 測試Web應用程序

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,判斷返回值並驗證對ReadingListRepositorysave方法有過調用。

該測試的問題在於,它僅僅測試了方法本身,當然,這要比沒有測試好一點。然而,它沒有測試該方法處理/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註解,使用@AutowiredWebApplicationContext作為實例變量注入測試類。代碼清單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的MockMvcRequestBuildersMockMvcResultMatchers裡的靜態方法,還有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,這才是運行應用程序時瀏覽器會發送的內容類型。隨後,要用MockMvcRequestBuildersparam方法設置表單域,模擬要提交的表單。一旦請求執行,我們要檢查響應是否是一個到/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方法 springSecuritySecurityMockMvcConfigurers的一個靜態方法,考慮到可讀性,我已經將其靜態導入。

開啟了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服務器來幫助測試。