文章目錄
- 5.1 測試使用者停止輸入查詢條件一段時間後才發出查詢請求
- 5.2 測試Spinner控制項在程式執行時顯示
- 5.3 測試連續搜尋時只有搜尋內容發生變化時才執行搜尋
Rx本身就是一個功能強大的測試架構。本文將介紹如何使用Rx類比非同步方法呼叫調用返回預定的值來輔助針對一步方法調用的單元測試。Rx可以類比經過一段預定時間返回結果的非同步方法呼叫,從而簡化了非同步處理方法的測試。Rx也可以類比非同步方法呼叫返回錯誤的情境,使得代碼測試可以覆蓋多有的用例。本文將介紹Rx中的TestSheduler方法,他可以類比耗時的非同步作業,但是能在測試的時候能夠立即執行。
1. 類比非同步方法呼叫
開發人員可能會對測試與時間有關的非同步方法呼叫會感到頭疼。與測試同步方法不同,非同步方法呼叫不僅需要返回需要的值,而且還要能夠類比出方法耗時以及延遲的效果。
有很多工具能夠用來測試同步方法,但是測試非同步方法呼叫則需要一定的技巧。我們可以使用Thread.Sleep()來類比需要長時間執行的方法,但是該方法能用來測試的時間用例非常粗糙,在有些對執行時間要求比較高的情境,Thread.Sleep方法不能很好的滿足我們的要求。
Rx的一個最大特色在於它能夠很好的控制事件的執行時間。我們可以使用Rx中的Timeout,Delay,Windows和Buffer等操作符來靈活的控制事件的產生時間。
例如,如果我們需要調用一個名為FetchWebPage()的方法,簽名如下:
public IObservable<string> FetchWebpage(string url);
那麼我們可以實現一個如下的類比方法:
public static IObservable<string> FetchWebpageStub(string url){ // Check the URL and error out if the Url is invalid Uri testUrl; try { testUrl = new Uri(url); } catch (UriFormatException ex) { return Observable.Throw<string>(ex); } return Observable.Return(String.Format(@"<html><body><p>'{0}' not found</p></body></html>", testUrl));}
我們也可以在傳統的同步方法中使用這些方法,就像測試同步方法一樣,只需要將所有的非同步方法呼叫使用Rx來類比即可。
2. 使用.First()方法來類比非同步方法呼叫
對於單元測試來說,其唯一目的就是測試從方法返回的實際值是否正確,調用非同步方法呼叫的最簡單方法就是在方法後面加上.First(),等待方法返回結果。下面這個例子就是用來測試上面的方法。我們可以對下面這個測試案例進行簡單更改就可以達到我們想要的結果。
[TestMethod]void FetchWebpageSuccessCase(){ var result = FetchWebpageStub("http://www.google.com").First(); Assert.IsFalse(String.IsNullOrEmpty(result)); Assert.IsTrue(result.ToLower().Contains("html"));}
3. 類比耗時
雖然上面的例子能夠正確的返回結果,但是他仍沒能有效類比處理的時間消耗。下面我們建立一個類比方法來使這個方法需要一定時間才能返回我們想要的結果。要注意的是,任何一個不需要立即傳回值的方法,都應該有一個Ischeduler類型的參數。後面我們將會看到這一點的重要性。
IObservable<string> FetchWebpageOnDialup(string url, IScheduler scheduler = null){ // 類比一個比較慢的操作,5秒後返回結果 return FetchWebpageStub(url).Delay(TimeSpan.FromSeconds(5.0), scheduler);}
或者在上面方法的基礎上類比一個拋出異常的方法:
IObservable<string> FetchWebpageOnAirportWifi(string url, IScheduler scheduler = null){ // 類比拋出異常,然後等待 return FetchWebpageOnDialup(url, scheduler) .SelectMany(_ => Observable.Throw<string>(new WebException()));}
4. 使用Schedulers對象
在Rx中,任何不直接通過計算後立即傳回值的方法都是通過實現Ischeduler介面來實現的,換句話說,Rx從不直接建立一個Task<T>或者直接建立一個Thread對象。他委託Scheduler來執行實際的工作。
例如,我們可以建立一個Scheduler對象,在某一段時間內不執行任何操作,只是記錄一下Scheduler啟動並執行時間,然後在某一時間點之後開始執行一些操作。如果我們知道了事件執行的時間順序,我們可以實現如“讓程式運行10分鐘,然後暫停”,Scheduler對象會運行10分鐘,然後停止。既然我們知道時間軸上的事件的執行順序,我們不必關心某一個事件實際發生的時間,我們只需關心事件執行的順序是否正確就可以了。這樣我們就可以讓測試方法儘快的按順序執行這些事件,仍然可以得到正確的結果,因為我們的事件執行順利和真實環境中事件的執行順序是一致的。當我們使用虛擬Scheduler來做測試時,結果是確定性,即每次運行相同的測試方法始終會得到相同的結果,這點和Thread.Sleep()不同。
如前所述,使用Scheduler方法的關鍵在於保證每一個接受Ischeduler的方法都提供了自己實現的Scheduler對象。在一些比較複雜的程式中,這一點可能不是特別明顯。有時候,我們可以建立一個全域的靜態Scheduler對象來在單元測試中使用。下面的代碼展示了我們使用TestScheduler來測試FetchWebpageOnDialup方法。TestScheduler對象繼承與VirtualTimeScheduler。
[TestMethod]void MakeSureFetchWebpageTakesTime(){ var sched = new TestScheduler(); string result = null; // 不要使用.First()方法,否則起不到示範效果了 var fixture = FetchWebpageOnDialup("http://www.yahoo.com", sched); fixture.Subscribe(x => result = x); // 向前推3秒,由於FetchWebpageOnDialup要等5秒才返回結果,所以現在的結果仍然是空 sched.AdvanceTo(TimeSpan.FromSeconds(3.0).Ticks); Assert.AreEqual(null, result); // 向前推6秒,現在應該有結果了 sched.AdvanceTo(TimeSpan.FromSeconds(6.0).Ticks); Assert.IsFalse(String.IsNullOrEmpty(result)); Assert.IsTrue(result.ToLower().Contains("html"));}
Scheduler對象可以使得我們構造自己的時間軸,有了這個時間軸,我們就可以用來精確的測試一些耗時的非同步方法呼叫了。
5. 測試ReactiveUI應用程式
在上一篇文章中,我們介紹了與Rx結合比較緊密的MVVM架構ReactiveUI。和其他MVVM架構一樣,ReactiveUI架構引入的ViewModel這一層使得單元測試變得更加容易。在上篇文章中我們展示了使用MVVM架構開發了一個搜尋圖片的應用程式。現在我們利用前面講過的知識來為這個MVVM應用程式編寫單元測試代碼。
測試應用程式的UI互動邏輯是Rx測試架構的一大特色。傳統的UI互動代碼都是在後台執行的,要對UI介面的互動進行測試比較困難,一般的做法是,使用一些專門的自動化測試軟體類比螢幕滑鼠或者鍵盤操作。現在使用ReactiveUI這個MVVM架構,我們就可以使用Rx來進行這些諸如類比使用者點擊按鈕,輸入資料,然後點擊查詢這些互動了。使用Rx類比使用者的這些互動,然後測試程式的反應就可以測試應用程式的UI邏輯是否正確。
我們要測試的應用程式互動介面很簡單,就是使用者在介面上輸入一些關鍵字,然後程式通過WebService進行查詢最後返回查詢結果。當然,我們測試的應用程式有幾點:
只有當使用者輸入的內容產生變化,才會進行查詢;當應用程式正在查詢時spinner對象要顯示出來;在同一時間不能執行多個查詢。一旦深究起介面互動邏輯,你會發現要編寫一個測試UI互動是否正確的單元測試還是比較難得,但至少RxUI及Rx能夠簡化不少工作。
由於我們的View是和ViewModel進行綁定的。所以我們的測試只需要對ViewModel即AppViewModel.cs 這個類進行測試就可以實現UI互動邏輯的測試了。
5.1 測試使用者停止輸入查詢條件一段時間後才發出查詢請求
我們的圖片查詢程式,並不會在使用者正在輸入查詢條件的時候就馬上觸發查詢,而是在使用者暫停輸入一小段時間後觸發查詢請求。我們將這個測試案例命名為SearchesShouldntRunOnEveryKeystroke,要測試這個用例,我們需要建立一個Scheduler對象,然後添加一系列的時間軸以及對應的資料互動。
(new TestScheduler()).With(sched =>{ // 類比使用者在輸入框中的輸入 var keyboardInput = sched.CreateColdObservable( sched.OnNextAt(10, "R"), sched.OnNextAt(20, "Ro"), sched.OnNextAt(30, "Robo"), sched.OnNextAt(40, "Robot"), sched.OnNextAt(2000, "Hat"));
在上面的代碼中,我們類比使用者在輸入框中輸入的字元以及時間。在ReactiveUI中,架構使用兩種類型的Scheduler對象,一種是Deferred Scheduler,比如UI線程,一種是Task Pool Sheduler比如後台執行的線程。在這裡我們建立了一個TestScheduler,然後調用With方法。With中有一個匿名方法,方法執行完了之後TestScheduler也會釋放,這樣就可以保證本測試不會影響到其他測試。
接下來,我們要保證查詢命令在我們發出請求時能夠執行:
// 保證命令總是能夠執行,類比實際搜尋的代碼var fixture = new AppViewModel(new ReactiveAsyncCommand(null, 1000), Observable.Never<List<FlickrPhoto>>());//封裝鍵盤輸入事件keyboardInput.Subscribe(x => fixture.SearchTerm = x);// 記錄查詢時間執行的次數int numTimesCommandInvoked = 0;fixture.ExecuteSearch.Subscribe(x => numTimesCommandInvoked++);
我們根據時間軸上的時間來測試互動是否正確。我們調用Scheduler對象的RunToMilliseconds方法,來類比執行到的時間點。
sched.RunToMilliseconds(25);Assert.AreEqual(0, numTimesCommandInvoked);sched.RunToMilliseconds(40);Assert.AreEqual(0, numTimesCommandInvoked);sched.RunToMilliseconds(1800);Assert.AreEqual(1, numTimesCommandInvoked);sched.RunToMilliseconds(2010);Assert.AreEqual(1, numTimesCommandInvoked);sched.RunToMilliseconds(5000);Assert.AreEqual(2, numTimesCommandInvoked);
5.2 測試Spinner控制項在程式執行時顯示
在我們的應用程式中,當程式正在查詢時,輸入框右側會顯示一個Spinner控制項,表示程式當前正在執行一次圖片查詢,查詢結果返回時Spinner控制項隱藏。
[TestMethod]public void SpinnerShouldSpinWhileAppIsSearching(){ (new TestScheduler()).With(sched => { // 我們需要建立一個類比的Observable對象來類比通過Webservice返回的資料 // 在這裡我們類比Webservice在5秒鐘後才返回資料 var searchObservable = Observable.Return(createSampleResults()) .Delay(TimeSpan.FromMilliseconds(5000), RxApp.TaskpoolScheduler); var command = new ReactiveAsyncCommand(); command.RegisterAsyncObservable(x => searchObservable); var fixture = new AppViewModel(command, searchObservable); // 程式最開始的時候Spinner控制項是隱藏的 Assert.AreNotEqual(Visibility.Visible, fixture.SpinnerVisibility); // 執行查詢命令 fixture.ExecuteSearch.Execute("Robot"); // 正在查詢時Spinner控制項應該隱藏 sched.RunToMilliseconds(100); Assert.AreEqual(Visibility.Visible, fixture.SpinnerVisibility); // 6秒過後,程式應該查詢完成,結果返回了,Spinner空間應該隱藏 sched.RunToMilliseconds(6 * 1000); Assert.AreNotEqual(Visibility.Visible, fixture.SpinnerVisibility); });}List<FlickrPhoto> createSampleResults(){ return new List<FlickrPhoto>() { new FlickrPhoto() { Description = "A sample image description", Title = "Sample Image", Url = "http://www.example.com/image.gif", }, };}
上面的代碼應該比較好理解。可以看出Scheduler能夠靈活的控制當前程式執行到的時間點,只需要調用RunToMilliseconds 即可,如sched.RunToMilliseconds(6 * 1000),該方法中我們並不需要等待著這6秒鐘過去,我們可以直接跳到6秒這個時間點,然後測試Spinner控制項是否顯示。
5.3 測試連續搜尋時只有搜尋內容發生變化時才執行搜尋
我們的圖片查詢程式中,只有當查詢的關鍵字發生改變時才執行查詢,這樣可以避免重複請求。要測試這個情境我們也需要向前面那樣類比一個查詢返回結果Observable表示查詢返回結果。
var searchObservable = Observable.Return(createSampleResults()) .Delay(TimeSpan.FromMilliseconds(5 * 1000), RxApp.TaskpoolScheduler);
然後我們類比一個查詢命令,傳入到ViewModel對象,返回一個Observable。
// 建立一個查詢命令,傳入到ViewModel對象中,然後返回Observablevar command = new ReactiveAsyncCommand();command.RegisterAsyncObservable(x => searchObservable);var fixture = new AppViewModel(command, searchObservable);Assert.IsTrue(fixture.SearchResults.Count == 0);fixture.SearchTerm = "Foo";
現在我們來執行一些測試,在兩秒時,沒有結果返回,但是10秒後應該有結果返回:
// 2秒時間點上,還沒有結果sched.RunToMilliseconds(2 * 1000);Assert.IsTrue(fixture.SearchResults.Count == 0);// 10秒後,應該有結果返回var sampleData = createSampleResults();sched.RunToMilliseconds(10 * 1000);Assert.AreEqual(sampleData.Count, fixture.SearchResults.Count);
最後我們要測試,返回的結果和實際的值是否一致:
// 保證這兩個查詢序列結果是一樣的foreach (var item in sampleData.Zip(fixture.SearchResults, (expected, actual) => new { expected, actual })){ Assert.AreEqual(item.expected.Title, item.actual.Title); Assert.AreEqual(item.expected.Description, item.actual.Description); Assert.AreEqual(item.expected.Url, item.actual.Url);}
6. 結語
單元測試在軟體開發中的作用日益重要,尤其是在測試驅動開發這樣的軟體開發模式中。Reactive Extension簡化了針對非同步方法呼叫進行單元測試的複雜性。使用Rx,對非同步方法呼叫調用進行類比變得非常直觀,並且測試結果確定。
本文介紹了使用Rx的測試架構來類比各種非同步方法呼叫,並對前面一篇文章中我們使用ReactiveUI這個MVVM架構編寫的WPF應用程式進行了單元測試。當然這些只是入門性的介紹,如果這些能讓您感受到無論是使用Rx編寫非同步方法呼叫,或者是編寫基於MVVM的應用程式,或者是編寫針對非同步方法呼叫進行單元測試所帶來的簡潔,那本文的目的就達到了,本文代碼點擊此處下載。