測試檢視模型

在我們開始之前……

在應用程式層方面,ViewModel 是一個包含所有業務邏輯和規則的類,使應用程式可以根據需要執行應有的操作。同樣重要的是儘可能地使其獨立,減少對 UI,資料層,本機功能和 API 呼叫等的引用。所有這些都使你的 VM 可以測試。
簡而言之,你的 ViewModel:

  • 不應該依賴於 UI 類(檢視,頁面,樣式,事件);
  • 不應該使用其他類的靜態資料(儘可能多);
  • 應該在 UI 上實現業務邏輯並準備資料;
  • 應該通過使用依賴注入解析的介面使用其他元件(資料庫,HTTP,特定於 UI)。

你的 ViewModel 也可能具有其他 VM 型別的屬性。例如,ContactsPageViewModel 將具有像 ObservableCollection<ContactListItemViewModel> 這樣的收集型別

業務需求

假設我們要實現以下功能:

As an unauthorized user
I want to log into the app
So that I will access the authorized features

澄清使用者故事後,我們定義了以下方案:

Scenario: trying to log in with valid non-empty creds
  Given the user is on Login screen
   When the user enters 'user' as username
    And the user enters 'pass' as password
    And the user taps the Login button
   Then the app shows the loading indicator
    And the app makes an API call for authentication

Scenario: trying to log in empty username
  Given the user is on Login screen
   When the user enters '  ' as username
    And the user enters 'pass' as password
    And the user taps the Login button
   Then the app shows an error message saying 'Please, enter correct username and password'
    And the app doesn't make an API call for authentication

我們將只使用這兩種方案。當然,應該有更多的情況,你應該在實際編碼之前定義所有這些情況,但現在我們已經足夠熟悉檢視模型的單元測試了。

讓我們遵循經典的 TDD 方法,開始編寫一個正在測試的空類。然後我們將編寫測試並通過實現業務功能使它們變為綠色。

普通類

public abstract class BaseViewModel : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler PropertyChanged;

    protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
    {
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
    }
}

服務

你還記得我們的檢視模型不能直接使用 UI 和 HTTP 類嗎?你應該將它們定義為抽象而不是依賴於實現細節

/// <summary>
/// Provides authentication functionality.
/// </summary>
public interface IAuthenticationService
{
    /// <summary>
    /// Tries to authenticate the user with the given credentials.
    /// </summary>
    /// <param name="userName">UserName</param>
    /// <param name="password">User's password</param>
    /// <returns>true if the user has been successfully authenticated</returns>
    Task<bool> Login(string userName, string password);
}

/// <summary>
/// UI-specific service providing abilities to show alert messages.
/// </summary>
public interface IAlertService
{
    /// <summary>
    /// Show an alert message to the user.
    /// </summary>
    /// <param name="title">Alert message title</param>
    /// <param name="message">Alert message text</param>
    Task ShowAlert(string title, string message);
}

構建 ViewModel 存根

好的,我們將有登入介面的頁面類,但讓我們先從 ViewModel 開始:

public class LoginPageViewModel : BaseViewModel
{
    private readonly IAuthenticationService authenticationService;
    private readonly IAlertService alertService;

    private string userName;
    private string password;
    private bool isLoading;

    private ICommand loginCommand;

    public LoginPageViewModel(IAuthenticationService authenticationService, IAlertService alertService)
    {
        this.authenticationService = authenticationService;
        this.alertService = alertService;
    }

    public string UserName
    {
        get
        {
            return userName;
        }
        set
        {
            if (userName!= value)
            {
                userName= value;
                OnPropertyChanged();
            }
        }
    }
    
    public string Password
    {
        get
        {
            return password;
        }
        set
        {
            if (password != value)
            {
                password = value;
                OnPropertyChanged();
            }
        }
    }

    public bool IsLoading
    {
        get
        {
            return isLoading;
        }
        set
        {
            if (isLoading != value)
            {
                isLoading = value;
                OnPropertyChanged();
            }
        }
    }

    public ICommand LoginCommand => loginCommand ?? (loginCommand = new Command(Login));

    private void Login()
    {
        authenticationService.Login(UserName, Password);
    }
}

我們定義了兩個 string 屬性和一個要在 UI 上繫結的命令。在本主題中,我們不會描述如何構建頁面類,XAML 標記和將 ViewModel 繫結到它,因為它們沒有任何特定內容。

如何建立 LoginPageViewModel 例項?

我想你可能只是用建構函式建立了 VM。現在你可以看到我們的 VM 依賴於 2 個服務作為建構函式引數注入,所以不能只做 var viewModel = new LoginPageViewModel()。如果你不熟悉依賴注入, 那麼它是瞭解它的最佳時機。如果不瞭解並遵循這一原則,就不可能進行適當的單元測試。

測試

現在讓我們根據上面列出的用例編寫一些測試。首先,你需要建立一個新的程式集(只是一個類庫,或者如果你想使用 Microsoft 單元測試工具,請選擇一個特殊的測試專案)。將它命名為 ProjectName.Tests,並新增對原始 PCL 專案的引用。

我這個例子我將使用 NUnitMoq, 但你可以繼續使用你選擇的任何測試庫。他們沒什麼特別的。

好的,那是測試類:

[TestFixture]
public class LoginPageViewModelTest
{
}

寫測試

這是前兩個場景的測試方法。嘗試每 1 個預期結果保留 1 個測試方法,而不是在一個測試中檢查所有內容。這將有助於你獲得有關程式碼中失敗內容的更清晰的報告。

[TestFixture]
public class LoginPageViewModelTest
{
    private readonly Mock<IAuthenticationService> authenticationServiceMock =
        new Mock<IAuthenticationService>();
    private readonly Mock<IAlertService> alertServiceMock =
        new Mock<IAlertService>();
    
    [TestCase("user", "pass")]
    public void LogInWithValidCreds_LoadingIndicatorShown(string userName, string password)
    {
        LoginPageViewModel model = CreateViewModelAndLogin(userName, password);

        Assert.IsTrue(model.IsLoading);
    }

    [TestCase("user", "pass")]
    public void LogInWithValidCreds_AuthenticationRequested(string userName, string password)
    {
        CreateViewModelAndLogin(userName, password);

        authenticationServiceMock.Verify(x => x.Login(userName, password), Times.Once);
    }

    [TestCase("", "pass")]
    [TestCase("   ", "pass")]
    [TestCase(null, "pass")]
    public void LogInWithEmptyuserName_AuthenticationNotRequested(string userName, string password)
    {
        CreateViewModelAndLogin(userName, password);

        authenticationServiceMock.Verify(x => x.Login(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
    }

    [TestCase("", "pass", "Please, enter correct username and password")]
    [TestCase("   ", "pass", "Please, enter correct username and password")]
    [TestCase(null, "pass", "Please, enter correct username and password")]
    public void LogInWithEmptyUserName_AlertMessageShown(string userName, string password, string message)
    {
        CreateViewModelAndLogin(userName, password);

        alertServiceMock.Verify(x => x.ShowAlert(It.IsAny<string>(), message));
    }

    private LoginPageViewModel CreateViewModelAndLogin(string userName, string password)
    {
        var model = new LoginPageViewModel(
            authenticationServiceMock.Object,
            alertServiceMock.Object);

        model.UserName = userName;
        model.Password = password;

        model.LoginCommand.Execute(null);

        return model;
    }
}

現在我們開始:

StackOverflow 文件

現在的目標是為 ViewModel 的 Login 方法編寫正確的實現,就是這樣。

業務邏輯實現

private async void Login()
{
    if (String.IsNullOrWhiteSpace(UserName) || String.IsNullOrWhiteSpace(Password))
    {
        await alertService.ShowAlert("Warning", "Please, enter correct username and password");
    }
    else
    {
        IsLoading = true;
        bool isAuthenticated = await authenticationService.Login(UserName, Password);
    }
}

再次執行測試後:

StackOverflow 文件

現在,你可以使用新測試繼續覆蓋程式碼,使其更穩定,更安全。