WPF 引入數據綁定與 MVVM 模式,使表示邏輯從 View 代碼後置剝離,成為可獨立實例化、無生命週期綁定的 POCO 類,從而實現真正的單元測試。本文以開源框架 Charmed 及示例應用 Charmed Reader(跨平台博客閱讀器,支援 Windows 8 / Phone 8)為載體,完整演示 ViewModel 測試策略、依賴注入、Mock 手工實作、導航與輔助磁貼測試。
[TestMethod]
public void SomeTestMethod() {
// Arrange - 構建被測對象與依賴
// Act - 呼叫目標方法/屬性
// Assert - 驗證結果符合預期
}
IRssFeedService 抽象 RSS 下載/解析,MainViewModel 僅依賴介面:
public interface IRssFeedService {
Task<List<FeedData>> GetFeedsAsync();
}
public class MainViewModel {
private readonly IRssFeedService _rssService;
public MainViewModel(IRssFeedService rssService, ...) { _rssService = rssService; }
public List<FeedData> FeedData { get; private set; }
public async Task LoadFeeds() { FeedData = await _rssService.GetFeedsAsync(); }
}
public class RssFeedServiceMock : IRssFeedService {
public Func<List<FeedData>> GetFeedsAsyncDelegate { get; set; }
public Task<List<FeedData>> GetFeedsAsync() =>
GetFeedsAsyncDelegate != null ? Task.FromResult(GetFeedsAsyncDelegate()) : Task.FromResult<List<FeedData>>(null);
}
[TestMethod]
public void FeedData() {
// Arrange
var vm = GetViewModel();
var expected = new List<FeedData>();
this.RssFeedService.GetFeedsAsyncDelegate = () => expected;
// Act
var actual = vm.FeedData;
// Assert
Assert.AreSame(expected, actual);
}
public interface INavigator {
bool CanGoBack { get; }
void GoBack();
void NavigateToViewModel<TViewModel>(object parameter = null);
}
public class NavigatorMock : INavigator {
public Action<Type, object> NavigateToViewModelDelegate { get; set; }
public void NavigateToViewModel<TViewModel>(object parameter = null) =>
NavigateToViewModelDelegate?.Invoke(typeof(TViewModel), parameter);
}
[TestMethod]
public void ViewFeed() {
var vm = GetViewModel();
var expectedItem = new FeedItem();
Type actualVmType = null; FeedItem actualItem = null;
this.Navigator.NavigateToViewModelDelegate = (t, p) => { actualVmType = t; actualItem = p as FeedItem; };
vm.ViewFeed(expectedItem);
Assert.AreSame(expectedItem, actualItem);
Assert.AreEqual(typeof(FeedItemViewModel), actualVmType);
}
Windows 8 / Phone 8 磁貼 API 差異大,統一介面 ISecondaryPinner + DTO TileInfo,測試驗證參數組裝與成功/失敗分支:
[TestMethod]
public async Task Pin_PinSucceeded() {
var vm = GetViewModel(); vm.LoadState(feedItem, null);
Placement actualPlacement = Placement.Default; TileInfo actualTile = null;
SecondaryPinner.PinDelegate = (ti) => { actualPlacement = ti.RequestPlacement; actualTile = ti; return true; };
string actualKey = null; List<FeedItem> actualItems = null;
Storage.SaveAsyncDelegate = (k, v) => { actualKey = k; actualItems = (List<FeedItem>)v; };
await vm.Pin(null);
Assert.AreEqual(Placement.Above, actualPlacement);
Assert.AreEqual(Constants.PinnedFeedItemsKey, actualKey);
Assert.IsNotNull(actualItems);
}
[TestMethod]
public async Task Pin_PinNotSucceeded() {
// ... PinDelegate 回傳 false
Assert.IsFalse(wasSaveCalled); // 失敗時不應呼叫儲存
}
完整代碼見 GitHub: github.com/brentedwards/Charmed