說到測試Web應用程序,我們還沒接觸實質內容。在真實的服務器裡啟動應用程序,用真實的Web瀏覽器訪問它,這樣比使用模擬的測試引擎更能展現應用程序在用戶端的行為。
但是,用真實的Web瀏覽器在真實的服務器上運行測試會很麻煩。雖然構建時的插件能把應用程序部署到Tomcat或者Jetty裡,但它們配置起來多有不便。而且測試這麼多,幾乎不可能隔離運行,也很難不啟動構建工具。
然而Spring Boot找到了解決方案。它支持將Tomcat或Jetty這樣的嵌入式Servlet容器作為運行中的應用程序的一部分,可以運用相同的機制,在測試過程中用嵌入式Servlet容器來啟動應用程序。
Spring Boot的@WebIntegrationTest
註解就是這麼做的。在測試類上添加@WebIntegrationTest
註解,可以聲明你不僅希望Spring Boot為測試創建應用程序上下文,還要啟動一個嵌入式的Servlet容器。一旦應用程序運行在嵌入式容器裡,你就可以發起真實的HTTP請求,斷言結果了。
舉例來說,考慮一下代碼清單4-5里的那段簡單的Web測試。這裡採用@WebIntegrationTest
,在服務器裡啟動了應用程序,以Spring的RestTemplate
對應用程序發起HTTP請求。
代碼清單4-5 測試運行在服務器裡的Web應用程序
@RunWith(SpringJUnit4ClassRunner.class)
@SpringApplicationConfiguration(
classes=ReadingListApplication.class)
@WebIntegrationTest ←---在服務器裡運行測試
public class SimpleWebTest {
@Test(expected=HttpClientErrorException.class)
public void pageNotFound {
try {
RestTemplate rest = new RestTemplate;
rest.getForObject(
\"http://localhost:8080/bogusPage\", String.class); ←---發起GET請求
fail(\"Should result in HTTP 404\");
} catch (HttpClientErrorException e) {
assertEquals(HttpStatus.NOT_FOUND, e.getStatusCode); ←---判斷HTTP 404(not found)響應
throw e;
}
}
}
雖然這個測試非常簡單,但足以演示如何使用@WebIntegrationTest
在服務器裡啟動應用程序。要判斷實際啟動的服務器究竟是哪個,可以遵循在命令行裡運行應用程序時的邏輯。默認情況下,會有一個監聽8080端口的Tomcat啟動。但是,如果Classpath裡有的話,Jetty或者Undertow也能啟動這些服務器。
測試方法的主體部分假設應用程序已經運行,監聽了8080端口。它使用了Spring的RestTemplate
對一個不存在的頁面發起請求,判斷服務器的響應是否為HTTP 404(NOT FOUND)。如果返回了其他響應,則測試失敗。
4.3.1 用隨機端口啟動服務器
前面提到過,此處的默認行為是啟動服務器監聽8080端口。在一台機器上一次只運行一個測試的話,這沒什麼問題,因為沒有其他服務器監聽8080端口。但如果你和我一樣,本機總是有其他服務器在監聽8080端口,那該怎麼辦?這時測試會失敗,因為端口衝突,服務器啟動不了。一定要有更好的辦法才行。
幸運的是,讓Spring Boot在隨機選擇的端口上啟動服務器很方便。一種辦法是將server.port
屬性設置為0
,讓Spring Boot選擇一個隨機的可用端口。@WebIntegrationTest
的value
屬性接受一個String
數組,數組中的每項都是鍵值對,形如name=value
,用來設置測試中使用的屬性。要設置server.port
,你可以這樣做:
@WebIntegrationTest(value={\"server.port=0\"})
另外,因為只要設置一個屬性,所以還能有更簡單的形式:
@WebIntegrationTest(\"server.port=0\")
通過value
屬性來設置屬性通常還算方便。但@WebIntegrationTest
還提供了一個randomPort
屬性,更明確地表示讓服務器在隨機端口上啟動。你可以將randomPort
設置為true
,啟用隨機端口:
@WebIntegrationTest(randomPort=true)
既然我們在隨機端口上啟動了服務器,就需要在發起Web請求時確保使用正確的端口。此時的getForObject
方法在URL裡硬編碼了8080端口。如果端口是隨機選擇的,那在構造請求時又該怎麼確定正確的端口呢?
首先,我們需要以實例變量的形式注入選中的端口。為了方便,Spring Boot將local.server.port
的值設置為了選中的端口。我們只需使用Spring的@Value
註解將其注入即可:
@Value(\"${local.server.port}\")
private int port;
有了端口之後,只需對getForObject
稍作修改,使用這個port
就好了:
rest.getForObject(
\"http://localhost:{port}/bogusPage\", String.class, port);
這裡我們在URL裡把硬編碼的8080改為{port}
佔位符。在getForObject
調用裡把port
屬性作為最後一個參數傳入,就能確保該佔位符被替換為注入port
的值了。
4.3.2 使用Selenium測試HTML頁面
RestTemplate
對於簡單的請求而言使用方便,是測試REST端點的理想工具。但是,就算它能對返回HTML頁面的URL發起請求,也不方便對頁面內容或者頁面上執行的操作進行斷言。結果HTML裡的內容最好能夠精確判斷(這種測試很脆弱)。不過你無法輕易判斷頁面上選中的內容,或者執行諸如點擊鏈接或提交表單這樣的操作。
對於HTML應用程序測試,有一個更好的選擇——Selenium(www.seleniumhq.org),它的功能遠不止提交請求和獲取結果。它能實際打開一個Web瀏覽器,在瀏覽器的上下文中執行測試。Selenium盡量接近手動執行測試,但與手工測試不同。Selenium的測試是自動的,而且可以重複運行。
為了用Selenium測試閱讀列表應用程序,讓我們先寫一個測試來獲取首頁,為新書填寫表單,提交表單,隨後判斷返回的頁面裡是否包含新添加的圖書。
首先需要把Selenium作為測試依賴添加到項目裡:
testCompile(\"org.seleniumhq.selenium:selenium-java:2.45.0\")
現在就可以編寫測試了。代碼清單4-6是一個基本的Selenium測試模板,使用了Spring Boot的@WebIntegrationTest
。
代碼清單4-6 在Spring Boot裡使用Selenium測試的模板
@RunWith(SpringJUnit4ClassRunner.class)
@SpringApplicationConfiguration(
classes=ReadingListApplication.class)
@WebIntegrationTest(randomPort=true) ←---用隨機端口啟動
public class ServerWebTests {
private static FirefoxDriver browser;
@Value(\"${local.server.port}\") ←---注入端口號
private int port;
@BeforeClass
public static void openBrowser {
browser = new FirefoxDriver;
browser.manage.timeouts
.implicitlyWait(10, TimeUnit.SECONDS); ←---配置Firefox驅動
}
@AfterClass
public static void closeBrowser {
browser.quit; ←---關閉瀏覽器
}
}
和之前更簡單的Web測試一樣,這個類添加了@WebIntegrationTest
註解,將randomPort
設置為true
,這樣應用程序啟動後會運行一個監聽隨機端口的服務器。同樣,端口號注入port
屬性,這樣我們就能用它來構造指向運行中應用程序的URL了。
靜態方法openBrowser
會創建一個FirefoxDriver
的實例,它將打開Firefox瀏覽器(需要在運行測試的服務器上安裝該瀏覽器)。我們的測試方法將通過FirefoxDriver
實例來執行瀏覽器操作。在頁面上查找元素時,FirefoxDriver
配置了10秒的等候時間(以防元素加載過慢)。
測試執行完畢,我們需要關閉Firefox瀏覽器。因此要在closeBrowser
裡要調用FirefoxDriver
實例的quit
方法,關閉瀏覽器。
選擇瀏覽器 雖然我們用Firefox進行了測試,但Selenium還提供了不少其他瀏覽器的驅動,包括IE、Google的Chrome,還有Apple的Safari。測試可以使用其他瀏覽器。你也可以使用你想支持的各種瀏覽器,這也許也是個不錯的想法。
現在可以開始編寫測試方法了,給你提個醒,我們想要加載首頁,填充並發送表單,然後判斷登錄的頁面是否包含剛剛添加的新書。代碼清單4-7演示了如何用Selenium實現這個功能。
代碼清單4-7 用Selenium測試閱讀列表應用程序
@Test
public void addBookToEmptyList {
String baseUrl = \"http://localhost:\" + port;
browser.get(baseUrl); ←---獲取主頁
assertEquals(\"You have no books in your book list\",
browser.findElementByTagName(\"p\").getText); ←---判斷圖書列表是否為空
browser.findElementByName(\"title\")
.sendKeys(\"BOOK TITLE\");
browser.findElementByName(\"author\")
.sendKeys(\"BOOK AUTHOR\");
browser.findElementByName(\"isbn\")
.sendKeys(\"1234567890\");
browser.findElementByName(\"description\")
.sendKeys(\"DESCRIPTION\");
browser.findElementByTagName(\"form\")
.submit; ←---填充並發送表單
WebElement dl =
browser.findElementByCssSelector(\"dt.bookHeadline\");
assertEquals(\"BOOK TITLE by BOOK AUTHOR (ISBN: 1234567890)\",
dl.getText);
WebElement dt =
browser.findElementByCssSelector(\"dd.bookDescription\");
assertEquals(\"DESCRIPTION\", dt.getText); ←---判斷列表中是否包含新書
}
該測試方法所做的第一件事是使用FirefoxDriver
來發起GET
請求,獲取閱讀列表的主頁,隨後查找頁面裡的一個<p>
元素,從它的文本裡判斷列表裡沒有圖書。
接下來的幾行查找表單裡的元素,使用驅動的sendKeys
方法模擬敲擊鍵盤事件(實際上就是用給定的值填充那些表單域)。最後,找到<form>
元素並提交。
提交的表單經處理後,瀏覽器就會跳到一個頁面,上面的列表包含了新添加的圖書。因此最後幾行查找列表裡的<dt>
和<dd>
元素,判斷其中是否包含測試表單裡提交的數據。
運行測試時,你會看到瀏覽器打開,加載閱讀列表應用程序。如果夠仔細,你還會看到填充表單的過程,就好像幽靈在操作,當然,並沒有幽靈使用你的應用程序——這只是一個測試。
這個測試裡最值得注意的是,@WebIntegrationTest
可以為我們啟動應用程序和服務器,這樣Selenium才可以用Web瀏覽器執行測試。但真正有趣的是你可以使用IDE的測試功能來運行測試,運行幾次都行,無需依賴構建過程中的某些插件啟動服務器。
要是你覺得使用Selenium進行測試很實用,可以閱讀Yujun Liang和Alex Collins的Selenium WebDriver in Practice(http://manning.com/liang/),該書更深入地討論了Selenium測試的細節。