共用方式為


啟用自動化單元測試

Microsoft 提供

下載 PDF

這是免費的 "NerdDinner" 應用程式教學課程的第 12 個步驟,詳細介紹了如何使用 ASP.NET MVC 1 建置一個小型但完整的 Web 應用程式。

步驟 12 示範如何開發一套自動化單元測試來驗證 NerdDinner 功能,讓我們有信心在未來對應用程式進行變更和改進。

如果使用 ASP.NET MVC 3,建議遵循 MVC 3 使用者入門MVC Music 市集教學課程。

NerdDinner 步驟 12:單元測試

讓我們開發一套自動化單元測試來驗證 NerdDinner 功能,讓我們有信心在未來對應用程式進行變更和改進。

為什麼是單元測試?

一天早上,在開車上班的路上,您突然想到了正在開發的一個應用程式。 您意識到可以實作一個變更,讓該應用程式變得更好。 此變更可以用來清除程式碼、新增新功能或修正錯誤的重構。

當您要使用電腦時會想到,「進行這個改進安全嗎?」如果進行變更會產生副作用或破壞某些東西怎麼辦? 變更可能很簡單,而且實作只需要幾分鐘的時間,但如果需要花費數小時的時間手動測試所有應用程式案例,該怎麼辦? 如果您忘記涵蓋案例而且損壞的應用程式投入生產怎麼辦? 進行此改進真的值得嗎?

自動化單元測試可以提供一個安全網,讓您能夠不斷增強應用程式,而不必擔心正在處理的程式碼。 擁有可快速驗證功能的自動化測試讓您自信地撰寫程式碼,並有信心進行那些可能原本不太敢做的改進。 它們也有助於建立更容易維護且存留期更長的解決方案,進而獲得更高的投資報酬率。

ASP.NET MVC Framework 讓您輕鬆且自然地對應用程式功能進行單元測試。 它也會啟用測試驅動開發 (TDD) 工作流程,以實現測試先行的開發。

NerdDinner.Tests 專案

在本教學課程開始時建立 NerdDinner 應用程式時,會出現一個對話方塊,詢問是否要建立一個單元測試專案以配合應用程式專案:

[建立單元測試專案] 對話方塊的螢幕擷取畫面。是,已選取 [建立單元測試專案]。[測試] 專案名稱設定為 Nerd Dinner dot Tests。

我們保持 [是,建立單元測試專案] 選項按鈕處於已選取狀態,這樣就會在我們的解決方案中新增一個名為 "NerdDinner.Tests" 的專案:

[方案總管] 導覽樹狀目錄的螢幕擷取畫面。已選取 [Nerd Dinner dot Tests]。

NerdDinner.Tests 專案會參考 NerdDinner 應用程式專案組件,讓我們輕鬆地將自動化測試加入其中以驗證應用程式功能。

建立 Dinner 模型類別的單元測試

讓我們將一些測試新增至 NerdDinner.Tests 專案,以驗證我們在建置模型層時所建立的 Dinner 類別。

首先,我們會在測試專案中建立名為 [模型] 的新資料夾,在其中放置模型相關測試。 然後,以滑鼠右鍵按一下資料夾,然後選擇新增 -> 新測試功能表命令。 這將開啟 [新增新測試] 對話方塊。

我們將選擇建立 [單元測試],並將其命名為 "DinnerTest.cs":

[新增測試] 對話方塊的螢幕擷取畫面。[單元測試] 會醒目提示。寫入的測試名稱是 Dinner Test dot c s。

當我們按一下 [確定] 按鈕時,Visual Studio 將會在專案中新增 (並開啟) DinnerTest.cs 檔案:

Visual Studio 中 Dinner Test dot c s 檔案的螢幕擷取畫面。

預設的 Visual Studio 單元測試範本內有一堆標準程式碼,我覺得有點混亂。 讓我們將其清除,只包含下列程式碼:

using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using NerdDinner.Models;

namespace NerdDinner.Tests.Models {
 
    [TestClass]
    public class DinnerTest {

    }
}

上述的 DinnerTest 類別上的 [TestClass] 屬性,將其標識為一個包含測試的類別,並且可以選擇性地包含測試初始化和清理程式碼。 我們可以新增具有 [TestMethod] 屬性的公用方法來定義其中的測試。

以下是我們將在 Dinner 類別中新增的兩個測試中的第一個測試。 如果新建立的 Dinner 的所有屬性未正確設定,第一個測試會將其驗證為無效。 如果 Dinner 的所有屬性都以有效值設定,第二個測試會將其驗證為有效。

[TestClass]
public class DinnerTest {

    [TestMethod]
    public void Dinner_Should_Not_Be_Valid_When_Some_Properties_Incorrect() {

        //Arrange
        Dinner dinner = new Dinner() {
            Title = "Test title",
            Country = "USA",
            ContactPhone = "BOGUS"
        };

        // Act
        bool isValid = dinner.IsValid;

        //Assert
        Assert.IsFalse(isValid);
    }

    [TestMethod]
    public void Dinner_Should_Be_Valid_When_All_Properties_Correct() {
        
        //Arrange
        Dinner dinner = new Dinner {
            Title = "Test title",
            Description = "Some description",
            EventDate = DateTime.Now,
            HostedBy = "ScottGu",
            Address = "One Microsoft Way",
            Country = "USA",
            ContactPhone = "425-703-8072",
            Latitude = 93,
            Longitude = -92,
        };

        // Act
        bool isValid = dinner.IsValid;

        //Assert
        Assert.IsTrue(isValid);
    }
}

您將會注意到我們的測試名稱都非常明確 (而且有些冗長)。 這是因為我們可能會建立數百或數千個小測試,希望能夠輕鬆快速地確定每個測試的目的和行為 (特別是在查看測試執行器中的失敗清單時)。 測試名稱應該以測試的功能命名。 我們在上面使用 "Noun_Should_Verb" 命名模式。

我們會使用 "AAA" 測試模式來建構測試,AAA 的意思是「排列 (Arrange)、行動 (Act)、判斷提示 (Assert)」:

  • 排列:設定要測試的單元
  • 行動:執行受測單元並擷取結果
  • 判斷提示:驗證行為

在撰寫測試時,我們想要避免讓個別測試執行太多動作。 而是只驗證單一概念 (讓您更輕鬆地找出失敗的原因)。 一個好的準則是,盡量每個測試只包含一個判斷提示陳述式。 如果一個測試方法中有多個判斷提示陳述式,請確定它們都被用來測試相同的概念。 如有疑問,請進行另一個測試。

執行測試

Visual Studio 2008 Professional (和更新版本) 包含內建的測試執行器,可在 IDE 內執行 Visual Studio 單元測試專案。 我們可以選取 Test->Run->All Tests in Solution 功能表命令 (或輸入 Ctrl R,A) 來執行所有單元測試。 或者,我們可以將游標放在特定的測試類別或測試方法內,並使用 Test->Run->Tests in Current Context 功能表命令 (或輸入 Ctrl R,T) 來執行單元測試的子集。

讓我們將游標放在 DinnerTest 類別內,然後輸入 "Ctrl R,T" 來執行我們剛才定義的兩個測試。 當我們執行此動作時,Visual Studio 中會出現 [測試結果] 視窗,我們將會在其中看到測試執行的結果清單:

Visual Studio 中 [測試結果] 視窗的螢幕擷取畫面。其中列出了測試執行的結果。

請注意:在預設情況下,VS 測試結果視窗不會顯示 [類別名稱] 資料行。 您可以在 [測試結果] 視窗中按一下滑鼠右鍵,並使用 [新增/移除資料行] 功能表命令來新增此內容。

執行兩個測試只花了不到一秒鐘就完成,而且兩個都通過測試。 我們現在可以繼續建立用來驗證特定規則驗證的額外測試來增強它們,並涵蓋我們新增到 Dinner 類別中的兩個協助程式方法 - IsUserHost() 和 IsUserRegistered()。 為 Dinner 類別準備好所有這些測試,如果未來要在其中新增商務規則和驗證也會更加容易和安全。 我們可以將新規則邏輯新增至 Dinner,然後在幾秒鐘內確認它並未中斷任何先前的邏輯功能。

請注意使用描述性測試名稱如何讓您輕鬆了解每個測試正在驗證的內容。 我建議使用 工具 -> 選項功能表命令,開啟測試工具 -> 測試執行組態畫面,然後核取 [按兩下失敗或不確定的單元測試結果會顯示測試中的失敗點] 核取方塊。 這可讓您按兩下測試結果視窗中的失敗,並立即跳至判斷提示失敗。

建立 DinnersController 單元測試

現在讓我們建立一些驗證 DinnersController 功能的單元測試。 我們先以滑鼠右鍵按一下測試專案中的 [控制器] 資料夾,然後選擇新增 -> 新測試功能表命令。 我們將建立 [單元測試],並將它命名為 "DinnersControllerTest.cs"。

我們將建立兩個測試方法以驗證 DinnersController 上的 Details() 動作方法。 第一個測試會驗證在要求現有的 Dinner 時是否會傳回 [檢視]。 第二個測試會驗證當要求不存在的 Dinner 時是否會會傳回 “NotFound” 檢視:

[TestClass]
public class DinnersControllerTest {

    [TestMethod]
    public void DetailsAction_Should_Return_View_For_ExistingDinner() {

        // Arrange
        var controller = new DinnersController();

        // Act
        var result = controller.Details(1) as ViewResult;

        // Assert
        Assert.IsNotNull(result, "Expected View");
    }

    [TestMethod]
    public void DetailsAction_Should_Return_NotFoundView_For_BogusDinner() {

        // Arrange
        var controller = new DinnersController();

        // Act
        var result = controller.Details(999) as ViewResult;

        // Assert
        Assert.AreEqual("NotFound", result.ViewName);
    } 
}

上述程式碼可以順利編譯。 不過,當我們執行測試時,兩者都會失敗:

程式碼的螢幕擷取畫面。兩個測試都失敗。

如果我們查看錯誤訊息,就會看到測試失敗是因為 DinnersRepository 類別無法連接到資料庫所導致的。 我們的 NerdDinner 應用程式使用了一個指向本機 SQL Server Express 檔案的連接字串,該檔案位於 NerdDinner 應用程式專案的 \App_Data 目錄下。 由於 NerdDinner.Tests 專案在與應用程式專案不同的目錄中編譯和執行,因此連接字串的相對路徑位置不正確。

如果要修正此問題,我們可以將 SQL Express 資料庫檔案複製到測試專案,然後在測試專案的 App.config 中新增適當的測試連接字串。 這樣就可以解除封鎖上述測試並執行。

不過,使用實際資料庫對程式碼進行單元測試會面對許多挑戰。 具體而言:

  • 它會大幅降低單元測試的執行時間。 執行測試所需的時間越長,就越不可能頻繁的執行測試。 單元測試最好能在幾秒內執行,並讓它與編譯項目一樣自然地執行。
  • 這會使測試內的設定和清除邏輯複雜化。 您希望每個單元測試彼此隔離且獨立 (沒有副作用或相依性)。 在使用實際資料庫時,請注意狀態並在測試之間將其重設。

讓我們看看稱為「相依性插入」的設計模式,以協助我們解決這些問題,並避免需要使用實際資料庫搭配我們的測試。

相依性插入

現在,DinnersController 與 DinnerRepository 類別緊密「結合」。 「結合」是指類別明確依賴另一個類別才能運作的情況:

public class DinnersController : Controller {

    DinnerRepository dinnerRepository = new DinnerRepository();

    //
    // GET: /Dinners/Details/5

    public ActionResult Details(int id) {

        Dinner dinner = dinnerRepository.FindDinner(id);

        if (dinner == null)
            return View("NotFound");

        return View(dinner);
    }

由於 DinnerRepository 類別需要資料庫的存取權,DinnersController 類別在 DinnerRepository 上的緊密結合相依性最終會要求我們必須有資料庫才能測試 DinnersController 動作方法。

若要解決此問題,可以採用稱為「相依性插入」的設計模式來進行,此方法使得相依性 (例如提供資料存取的存放庫類別) 不再隱含地建立在使用這些模式的類別內。 相反地,相依性可以明確傳遞至使用建構函式引數的類別。 如果使用介面定義相依性,我們便可以靈活地傳入單元測試案例的「假」相依性實作。 這使我們能夠建立特定於測試的相依性實作,而不需要實際存取資料庫。

若要查看其實際運作情況,請使用 DinnersController 實作相依性插入。

擷取 IDinnerRepository 介面

我們的第一個步驟是建立新的 IDinnerRepository 介面,它封裝了控制器需要的存放庫合約,以便檢索和更新 Dinners。

我們可以使用滑鼠右鍵按一下 \Models 資料夾,然後選擇新增 -> 新項目功能表命令,然後建立名為 IDinnerRepository.cs 的新介面,手動定義此介面合約。

或者,我們可以使用 Visual Studio Professional (和更高版本) 內建的重構工具,從現有的 DinnerRepository 類別自動擷取和建立介面。 若要使用 VS 擷取此介面,只需將游標放在 DinnerRepository 類別的文字編輯器中,然後按一下滑鼠右鍵並選擇重構 -> 擷取介面功能表命令:

顯示 [重構] 子功能表中已選取 [擷取介面] 的螢幕擷取畫面。

這會啟動 [擷取介面] 對話方塊,並提示我們建立介面的名稱。 它的名稱會預設為 IDinnerRepository,並自動選取現有 DinnerRepository 類別上的所有公用方法,以新增至該介面:

Visual Studio 中 [測試結果] 視窗的螢幕擷取畫面。

當我們按一下 [確定] 按鈕時,Visual Studio 會將新的 IDinnerRepository 介面新增至應用程式:

public interface IDinnerRepository {

    IQueryable<Dinner> FindAllDinners();
    IQueryable<Dinner> FindByLocation(float latitude, float longitude);
    IQueryable<Dinner> FindUpcomingDinners();
    Dinner             GetDinner(int id);

    void Add(Dinner dinner);
    void Delete(Dinner dinner);
    
    void Save();
}

而我們現有的 DinnerRepository 類別將會更新,以便實作該介面:

public class DinnerRepository : IDinnerRepository {
   ...
}

更新 DinnersController 以支援建構函式插入

我們現在會更新 DinnersController 類別以使用新的介面。

目前 DinnersController 是硬式編碼,因此其 “dinnerRepository” 欄位一律是 DinnerRepository 類別:

public class DinnersController : Controller {

    DinnerRepository dinnerRepository = new DinnerRepository();

    ...
}

我們將變更該編碼,使 "dinnerRepository" 欄位的類型變更為 IDinnerRepository 而非 DinnerRepository。 然後,我們將新增兩個公用 DinnersController 建構函式。 其中一個建構函式可讓 IDinnerRepository 以引數的形式傳遞。 另一個是使用現有 DinnerRepository 實作的預設建構函式:

public class DinnersController : Controller {

    IDinnerRepository dinnerRepository;

    public DinnersController()
        : this(new DinnerRepository()) {
    }

    public DinnersController(IDinnerRepository repository) {
        dinnerRepository = repository;
    }
    ...
}

由於 ASP.NET MVC 在預設情況下設會使用預設建構函式建立控制器類別,因此在執行階段的 DinnersController 會繼續使用 DinnerRepository 類別來執行資料存取。

不過,我們現在可以更新單元測試,以使用參數建構函式傳入「假」Dinner 存放庫實作。 此「假」 Dinner 存放庫不需要存取實際資料庫,而是會改用記憶體內部範例資料。

建立 FakeDinnerRepository 類別

讓我們建立 FakeDinnerRepository 類別。

我們將從在 NerdDinner.Tests 專案中建立「假」目錄開始,然後將新的 FakeDinnerRepository 類別新增至其中 (以滑鼠右鍵按一下資料夾並選擇新增 -> 新類別):

[新增新類別] 功能表項目的螢幕擷取畫面。[新增新項目] 已醒目提示。

我們將更新程式碼,讓 FakeDinnerRepository 類別實作 IDinnerRepository 介面。 然後,我們可以用滑鼠右鍵按一下該介面,然後選擇 [實作介面 IDinnerRepository] 內容功能表命令:

實作介面 I Dinner Repository 內容功能表命令的螢幕擷取畫面。

這會導致 Visual Studio 自動將所有 IDinnerRepository 介面成員新增至 FakeDinnerRepository 類別,並使用預設的「簡單」實作:

public class FakeDinnerRepository : IDinnerRepository {

    public IQueryable<Dinner> FindAllDinners() {
        throw new NotImplementedException();
    }

    public IQueryable<Dinner> FindByLocation(float lat, float long){
        throw new NotImplementedException();
    }

    public IQueryable<Dinner> FindUpcomingDinners() {
        throw new NotImplementedException();
    }

    public Dinner GetDinner(int id) {
        throw new NotImplementedException();
    }

    public void Add(Dinner dinner) {
        throw new NotImplementedException();
    }

    public void Delete(Dinner dinner) {
        throw new NotImplementedException();
    }

    public void Save() {
        throw new NotImplementedException();
    }
}

接著,我們可以更新 FakeDinnerRepository 的實作,使其能夠基於作為建構函式引數傳遞進來的記憶體中的 List<Dinner> 集合來運作。

public class FakeDinnerRepository : IDinnerRepository {

    private List<Dinner> dinnerList;

    public FakeDinnerRepository(List<Dinner> dinners) {
        dinnerList = dinners;
    }

    public IQueryable<Dinner> FindAllDinners() {
        return dinnerList.AsQueryable();
    }

    public IQueryable<Dinner> FindUpcomingDinners() {
        return (from dinner in dinnerList
                where dinner.EventDate > DateTime.Now
                select dinner).AsQueryable();
    }

    public IQueryable<Dinner> FindByLocation(float lat, float lon) {
        return (from dinner in dinnerList
                where dinner.Latitude == lat && dinner.Longitude == lon
                select dinner).AsQueryable();
    }

    public Dinner GetDinner(int id) {
        return dinnerList.SingleOrDefault(d => d.DinnerID == id);
    }

    public void Add(Dinner dinner) {
        dinnerList.Add(dinner);
    }

    public void Delete(Dinner dinner) {
        dinnerList.Remove(dinner);
    }

    public void Save() {
        foreach (Dinner dinner in dinnerList) {
            if (!dinner.IsValid)
                throw new ApplicationException("Rule violations");
        }
    }
}

我們現在有了一個假的 IDinnerRepository 實作,它不需要資料庫,而是可以根據記憶體中的 Dinner 物件清單來運作。

搭配單元測試使用 FakeDinnerRepository

讓我們回到先前因為資料庫無法使用而失敗的 DinnersController 單元測試。 我們可以使用下列程式碼,將測試方法更新為使用填入範例記憶體內部 Dinner 資料的 FakeDinnerRepository 至 DinnersController:

[TestClass]
public class DinnersControllerTest {

    List<Dinner> CreateTestDinners() {

        List<Dinner> dinners = new List<Dinner>();

        for (int i = 0; i < 101; i++) {

            Dinner sampleDinner = new Dinner() {
                DinnerID = i,
                Title = "Sample Dinner",
                HostedBy = "SomeUser",
                Address = "Some Address",
                Country = "USA",
                ContactPhone = "425-555-1212",
                Description = "Some description",
                EventDate = DateTime.Now.AddDays(i),
                Latitude = 99,
                Longitude = -99
            };
            
            dinners.Add(sampleDinner);
        }
        
        return dinners;
    }

    DinnersController CreateDinnersController() {
        var repository = new FakeDinnerRepository(CreateTestDinners());
        return new DinnersController(repository);
    }

    [TestMethod]
    public void DetailsAction_Should_Return_View_For_Dinner() {

        // Arrange
        var controller = CreateDinnersController();

        // Act
        var result = controller.Details(1);

        // Assert
        Assert.IsInstanceOfType(result, typeof(ViewResult));
    }

    [TestMethod]
    public void DetailsAction_Should_Return_NotFoundView_For_BogusDinner() {

        // Arrange
        var controller = CreateDinnersController();

        // Act
        var result = controller.Details(999) as ViewResult;

        // Assert
        Assert.AreEqual("NotFound", result.ViewName);
    }
}

現在,當我們執行這些測試時,它們都會通過:

兩個單元測試都通過的螢幕擷取畫面。

最棒的是,它們只需要不到一秒的時間即可執行,且不需要任何複雜的設定/清理邏輯。 現在,我們可以對所有 DinnersController 的動作方法程式碼 (包括清單、分頁、詳細資料、建立、更新和刪除) 進行單元測試,而不需要連接到實際資料庫。

側邊主題:相依性插入架構
手動相依性注入 (如上所述) 執行良好,但隨著應用程式中相依性和元件數目的增加,會變得更難維護。 .NET 有數個相依性插入架構,有助於提供更靈活的相依性管理。 這些架構 (有時也稱為「控制反轉」(IoC) 容器) 提供機制讓額外層級的組態支援,在執行階段指定和傳遞相依性 (最常使用建構函式插入)。 .NET 中一些較受歡迎的 OSS 相依性插入/IOC 架構包括:AutoFac、Ninject、Spring.NET、StructureMap 和 Windsor。 ASP.NET MVC 會公開擴充性 API,讓開發人員能夠參與控制器的解析和具現化,這可讓相依性插入/IoC 架構在此程序中完全整合。 使用 DI/IOC 架構也可讓我們從 DinnersController 中移除預設建構函式,這將完全移除它與 DinnerRepository 之間的結合。 我們不會搭配 NerdDinner 應用程式使用相依性插入/IOC 架構。 但如果 NerdDinner 程式碼庫和功能不斷成長,我們可以在未來考慮這一點。

建立編輯動作單元測試

現在讓我們建立一些單元測試,以驗證 DinnersController 的 [編輯] 功能。 我們將從測試 HTTP-GET 版本的 [編輯] 動作開始:

//
// GET: /Dinners/Edit/5

[Authorize]
public ActionResult Edit(int id) {

    Dinner dinner = dinnerRepository.GetDinner(id);

    if (!dinner.IsHostedBy(User.Identity.Name))
        return View("InvalidOwner");

    return View(new DinnerFormViewModel(dinner));
}

我們將建立測試,驗證當要求有效的 Dinner 時是否會轉譯回 DiningFormViewModel 物件支援的 [檢視]:

[TestMethod]
public void EditAction_Should_Return_View_For_ValidDinner() {

    // Arrange
    var controller = CreateDinnersController();

    // Act
    var result = controller.Edit(1) as ViewResult;

    // Assert
    Assert.IsInstanceOfType(result.ViewData.Model, typeof(DinnerFormViewModel));
}

但是,在執行測試時,我們會發現它失敗了,因為當 [編輯] 方法存取 User.Identity.Name 屬性以執行 Dinner.IsHostedBy() 檢查時,會擲回 Null 參考例外狀況。

Controller 基底類別上的 [使用者] 物件會封裝登入使用者的詳細資料,並在執行階段建立控制器時由 ASP.NET MVC 填入。 因為我們在網頁伺服器環境外部測試 DinnersController,所以不會設定 [使用者] 物件 (因此為 Null 參考例外狀況)。

模擬 User.Identity.Name 屬性

模擬架構可讓我們動態建立支援測試之相依物件的假版本,讓測試變得更容易。 例如,我們可以在 [編輯] 動作測試中使用模擬架構,以動態方式建立 DinnersController 可用來查閱模擬使用者名稱的 [使用者] 物件。 這可避免在執行測試時擲回 Null 參考。

有許多 .NET 模擬架構可以和 ASP.NET MVC 搭配使用 (清單如下:http://www.mockframeworks.com/)。

下載之後,我們會將 NerdDinner.Tests 專案中的參考新增至 Moq.dll 組件:

Nerd Dinner 導覽樹狀目錄的螢幕擷取畫面。Moq 已醒目提示。

接著,我們會將 "CreateDinnersControllerAs(username)" 協助程式方法新增至我們的測試類別,以使用者名稱作為參數,然後在 DinnersController 執行個體上「模擬」User.Identity.Name 屬性:

DinnersController CreateDinnersControllerAs(string userName) {

    var mock = new Mock<ControllerContext>();
    mock.SetupGet(p => p.HttpContext.User.Identity.Name).Returns(userName);
    mock.SetupGet(p => p.HttpContext.Request.IsAuthenticated).Returns(true);

    var controller = CreateDinnersController();
    controller.ControllerContext = mock.Object;

    return controller;
}

我們在上面使用 Moq 建立 [模擬] 物件來偽造 ControllerContext 物件 (這是 ASP.NET MVC 傳遞至 [控制器] 類別以公開執行階段物件,例如使用者、要求、回應和工作階段)。 我們在 Mock 上呼叫 "SetupGet" 方法,指示 ControllerContext 上的 HttpContext.User.Identity.Name 屬性應該傳回我們傳遞給協助程式方法的使用者名稱字串。

我們可以模擬任意數目的 ControllerContext 屬性和方法。 為了說明這一點,我也新增了 Request.IsAuthenticated 屬性的 SetupGet() 呼叫 (雖然在下面的測試中實際上並不需要,但這有助於說明如何模擬 [要求] 屬性)。 完成後,我們將 ControllerContext 模擬的執行個體指派給我們的協助程式方法傳回的 DiningsController。

我們現在可以撰寫使用這個協助程式方法來測試涉及不同使用者的 [編輯] 案例的單元測試:

[TestMethod]
public void EditAction_Should_Return_EditView_When_ValidOwner() {

    // Arrange
    var controller = CreateDinnersControllerAs("SomeUser");

    // Act
    var result = controller.Edit(1) as ViewResult;

    // Assert
    Assert.IsInstanceOfType(result.ViewData.Model, typeof(DinnerFormViewModel));
}

[TestMethod]
public void EditAction_Should_Return_InvalidOwnerView_When_InvalidOwner() {

    // Arrange
    var controller = CreateDinnersControllerAs("NotOwnerUser");

    // Act
    var result = controller.Edit(1) as ViewResult;

    // Assert
    Assert.AreEqual(result.ViewName, "InvalidOwner");
}

現在,當我們執行測試時,他們會通過測試:

使用協助程式方法之單元測試的螢幕擷取畫面。測試已通過。

測試 UpdateModel() 案例

我們已建立涵蓋 [編輯] 動作 HTTP-GET 版本的測試。 現在讓我們建立一些測試,以驗證 [編輯] 動作的 HTTP-POST 版本:

//
// POST: /Dinners/Edit/5

[AcceptVerbs(HttpVerbs.Post), Authorize]
public ActionResult Edit (int id, FormCollection collection) {

    Dinner dinner = dinnerRepository.GetDinner(id);

    if (!dinner.IsHostedBy(User.Identity.Name))
        return View("InvalidOwner");

    try {
        UpdateModel(dinner);

        dinnerRepository.Save();

        return RedirectToAction("Details", new { id=dinner.DinnerID });
    }
    catch {
        ModelState.AddModelErrors(dinner.GetRuleViolations());
        
        return View(new DinnerFormViewModel(dinner));
    }
}

我們用此操作方法支援的有趣的新測試案例是它在 Controller 基底類別上使用 UpdateModel() 協助程式方法。 我們使用此協助程式方法,將表單張貼值繫結至 Dinner 物件執行個體。

以下兩個測試示範如何為 UpdateModel() 協助程式方法提供表單張貼的值。 我們會建立並填入 FormCollection 物件,然後將它指派給 [控制器] 上的 “ValueProvider” 屬性來執行此動作。

第一個測試,會驗證成功儲存後,瀏覽器是否重定向到詳細資料動作。 第二個測試,會驗證當張貼無效輸入時,該動作是否會再次重新顯示編輯檢視並顯示錯誤訊息。

[TestMethod]
public void EditAction_Should_Redirect_When_Update_Successful() {

    // Arrange      
    var controller = CreateDinnersControllerAs("SomeUser");

    var formValues = new FormCollection() {
        { "Title", "Another value" },
        { "Description", "Another description" }
    };

    controller.ValueProvider = formValues.ToValueProvider();
    
    // Act
    var result = controller.Edit(1, formValues) as RedirectToRouteResult;

    // Assert
    Assert.AreEqual("Details", result.RouteValues["Action"]);
}

[TestMethod]
public void EditAction_Should_Redisplay_With_Errors_When_Update_Fails() {

    // Arrange
    var controller = CreateDinnersControllerAs("SomeUser");

    var formValues = new FormCollection() {
        { "EventDate", "Bogus date value!!!"}
    };

    controller.ValueProvider = formValues.ToValueProvider();

    // Act
    var result = controller.Edit(1, formValues) as ViewResult;

    // Assert
    Assert.IsNotNull(result, "Expected redisplay of view");
    Assert.IsTrue(result.ViewData.ModelState.Count > 0, "Expected errors");
}

測試總結

我們已涵蓋單元測試控制器類別所涉及的核心概念。 我們可以使用這些技術,輕鬆地建立數百個驗證應用程式行為的簡單測試。

由於我們的控制器和模型測試不需要實際資料庫,因此它們非常快速且易於執行。 我們將能夠在幾秒鐘內執行數百個自動化測試,並立即獲得有關所做的變更是否破壞了某些內容的回饋。 這有助於讓我們有信心持續改善、重構和精簡應用程式。

我們將測試作為本章的最後一個主題,但這並不是因為測試是您應該在開發過程結束時進行的事情! 反之,您應該在開發過程中儘早編寫自動化測試。 這樣做可以讓您在開發時獲得即時回饋,幫助您充分思考應用程式的使用案例,並指導您在設計應用程式時要顧及清晰的分層和結合。

本書後面的章節將討論測試驅動開發 (TDD),以及如何將其與 ASP.NET MVC 一起使用。 TDD是一種反覆運算程式碼實踐,您會先在其中編寫最終程式碼將滿足的測試。 使用 TDD 時,每新增一個功能,應先撰寫測試來驗證即將實作的功能。 先撰寫單元測試有助於確保您清楚了解功能及其運作方式。 只有在測試寫入之後 (且您已驗證測試失敗),您才能實作測試驗證的實際功能。 因為您已經花時間思考該功能應該如何運作的使用案例,所以您將更能夠理解需求以及如何最好地實作它們。 完成實作後,您可以重新執行測試,並立即獲得有關該功能是否正常運作的回饋。 我們將在第 10 章中進一步討論 TDD。

後續步驟

最後的一些總結評論。