反覆項目 #5 – 建立單元測試 (C#)
由 Microsoft 提供
在第五個反覆項目中,我們會藉由新增單元測試,更容易維護和修改應用程式。 我們會模擬資料模型類別,並為控制器和驗證邏輯建置單元測試。
建置連絡人管理 ASP.NET MVC 應用程式 (C#)
在此系列教學課程中,我們會從頭到尾建置整個連絡人管理應用程式。 Contact Manager 應用程式可讓您儲存連絡人資訊 (姓名、電話號碼和電子郵件地址) 以取得人員名單。
我們會透過多個反覆項目建置應用程式。 每次反覆運算時,我們會逐步改善應用程式。 這種多次反覆運算方法的目標是,讓您能夠瞭解每次變更的原因。
反覆項目 #1 – 建立應用程式 在第一個反覆項目中,我們以最簡單的方式建立 Contact Manager。 我們新增對基本資料庫作業的支援:建立、讀取、更新和刪除 (CRUD)。
反覆項目 #2 – 美化應用程式外觀。 在這個反覆項目中,我們修改預設 ASP.NET MVC 檢視主版頁面和串聯樣式表,以改善應用程式的外觀。
反覆項目 #3 – 新增表單驗證。 在第三個反覆項目中,我們新增基本表單驗證。 我們要防止人員提交未完成表單必填欄位的表單。 我們也驗證電子郵件地址和電話號碼。
反覆項目 #4 – 讓應用程式鬆散耦合。 在這個第四個反覆項目中,我們利用好幾種軟體設計模式,更輕鬆地維護和修改 Contact Manager 應用程式。 例如,我們將應用程式重構為使用存放庫模式和相依性插入模式。
反覆項目 #5 – 建立單元測試。 在第五個反覆項目中,我們會藉由新增單元測試,更容易維護和修改應用程式。 我們會模擬資料模型類別,並為控制器和驗證邏輯建置單元測試。
反覆項目 #6 – 使用測試導向的開發。 在這第六個反覆項目中,我們會先撰寫單元測試,再針對單元測試撰寫程式碼,藉此將新功能新增至應用程式。 在此反覆項目中,我們會新增連絡人群組。
反覆項目 #7 – 新增 Ajax 功能性。 在第七個反覆項目中,我們會藉由新增對 Ajax 的支援來改善應用程式的回應性和效能。
此反覆項目
在 Contact Manager 應用程式的先前反覆項目中,我們已重構應用程式以更鬆散耦合。 我們已將應用程式分成不同的控制器、服務和存放庫層。 每層都會透過介面與其下面的層互動。
我們已重構應用程式,讓應用程式更容易維護和修改。 例如,如果需要使用新的資料存取技術,我們可以直接變更存放庫層,而不需要觸及控制器或服務層。 藉由讓 Contact Manager 鬆散耦合,我們已讓應用程式更具變更彈性。
但是,當我們需要將新功能新增至 Contact Manager 應用程式時,會發生什麼事? 或者,當我們修正錯誤時會發生什麼事? 撰寫程式碼的可悲但經過充分證實的事實是,每當您接觸程式碼時,就會造成引入新錯誤的風險。
例如,有一天,您的經理可能會要求您為 Contact Manager 新增一項新功能。 她想要新增 Contact Groups 的支援。 她希望您可以讓使用者將其連絡人組織成朋友、商務等群組。
若要實作這項新功能,您必須修改 Contact Manager 應用程式的所有三個層級。 您必須將新功能新增至控制器、服務層和存放庫。 一旦您開始修改程式碼,就會有中斷先前運作的功能的風險。
將應用程式重構為個別層,就像我們在先前的反覆項目中所做的一樣,是件好事。 這是件好事,因為它可讓我們變更整個層,而不需要觸及應用程式的其餘部分。 不過,如果您想要讓層內的程式碼更容易維護和修改,您需要建立程式碼的單元測試。
您可以使用單元測試來測試個別的程式碼單元。 這些程式碼單元小於整個應用層。 一般而言,您會使用單元測試來驗證程式碼中的特定方法是否以預期的方式運作。 例如,您會為 ContactManagerService 類別公開的 CreateContact() 方法建立單元測試。
應用程式的單元測試就像安全網一樣運作。 每當您在應用程式中修改程式碼時,您可以執行一組單元測試,以檢查修改是否會中斷現有的功能。 單元測試可讓您的程式碼安全修改。 單元測試可讓應用程式中的所有程式碼更具變更彈性。
在此反覆項目中,我們會將單元測試新增至 Contact Manager 應用程式。 如此一來,在下一個反覆項目中,我們可以將 Contact Groups 新增至應用程式,而不必擔心中斷現有的功能。
注意
有各種不同的單元測試架構,包括 NUnit、xUnit.net 和 MbUnit。 在本教學課程中,我們會使用 Visual Studio 隨附的單元測試架構。 不過,您可以同樣輕鬆地使用其中一個替代架構。
經過測試的項目
在完美的世界中,所有程式碼都會由單元測試所涵蓋。 在完美的世界中,您會有完美的安全網。 您可以藉由執行單元測試,立即修改應用程式中的任何程式碼,並立即知道變更是否中斷了現有的功能。
然而,我們並非活在一個完美的世界。 在實務上,撰寫單元測試時,您會專注於撰寫商業規則的測試 (例如驗證邏輯)。 特別是,您不會撰寫資料存取邏輯或檢視邏輯的單元測試。
為了具有實用性,單元測試必須非常快速地執行。 您可以輕鬆地為應用程式累積數百個單元測試 (甚至數千個)。 如果單元測試需要很長的時間才能執行,則您將避免執行它們。 換句話說,長時間執行的單元測試對於日常編碼用途是毫無用處的。
基於這個理由,您通常不會針對與資料庫互動的程式碼撰寫單元測試。 針對即時資料庫執行數百個單元測試的速度太慢。 相反地,您會模擬您的資料庫,並撰寫與模擬資料庫互動的程式碼 (我們討論以下模擬資料庫)。
基於類似的原因,您通常不會撰寫檢視的單元測試。 若要測試檢視,您必須啟動網頁伺服器。 因為啟動網頁伺服器是相對緩慢的流程,因此不建議為您的檢視建立單元測試。
如果您的檢視包含複雜的邏輯,則您應該考慮將邏輯移至協助程式方法。 您可以撰寫協助程式方法的單元測試,而不需要啟動網頁伺服器。
注意
雖然撰寫資料存取邏輯或檢視邏輯的測試在撰寫單元測試時不是個好主意,但這些測試在建置功能或整合測試時可能非常有價值。
注意
ASP.NET MVC 是 Web Form 檢視引擎。 雖然 Web Form 檢視引擎相依於網頁伺服器,但其他檢視引擎可能不是。
使用模擬物件架構
建置單元測試時,您幾乎一律需要利用模擬物件架構。 模擬物件架構可讓您為應用程式中的類別建立模擬和虛設常式。
例如,您可以使用模擬物件架構來產生存放庫類別的模擬版本。 如此一來,您就可以在單元測試中使用模擬存放庫類別,而不是實際的存放庫類別。 使用模擬存放庫可讓您避免在執行單元測試時執行資料庫程式碼。
Visual Studio 不包含模擬物件架構。 不過,.NET Framework 提供數個商業和開放原始碼模擬物件架構:
- Moq - 此架構可在開放原始碼 BSD 授權下取得。 您可以從 https://code.google.com/p/moq/ 下載 Moq。
- Rhino Mocks - 此架構可在開放原始碼 BSD 授權下取得。 您可以從 http://ayende.com/projects/rhino-mocks.aspx 下載 Rhino Mocks。
- Typemock Isolator - 這是商業架構。 您可以從 http://www.typemock.com/ 下載試用版。
在本教學課程中,我決定使用 Moq。 不過,您可以同樣輕鬆地使用 Rhino Mocks 或 Typemock Isolator 來建立 Contact Manager 應用程式的 Mock 物件。
您必須先完成下列步驟,才能使用 Moq:
- .
- 解壓縮下載內容之前,請確定以滑鼠右鍵按一下檔案,然後按下標示為 [解除鎖定] 的按鈕 (請參閱圖 1)。
- 解壓縮下載內容。
- 以滑鼠右鍵按一下 ContactManager.Tests 專案中的 References 資料夾,然後選取 [新增參考],以新增 Moq 組件的參考。 在 [瀏覽] 索引標籤下,瀏覽至您解壓縮 Moq 的資料夾,然後選取 Moq.dll 組件。 按一下 [確定] 按鈕。
- 完成這些步驟之後,您的 References 資料夾看起來應該像圖 2。
圖 01:解除鎖定 Moq (按一下以檢視完整大小的圖片)
圖 02:新增 Moq 之後的參考資料 (按一下以檢視完整大小的圖片)
建立服務層的單元測試
讓我們從為 Contact Manager 應用程式服務層建立一組單元測試開始。 我們將使用這些測試來驗證我們的驗證邏輯。
在 ContactManager.Tests 專案中建立一個名為 Models 的新資料夾。 接下來,以滑鼠右鍵按一下 [Models] 資料夾並選取 [新增、新測試]。 圖 3 中顯示的 [新增新測試] 對話方塊隨即出現。 選取單元測試範本,並將新的測試命名為 ContactManagerServiceTest.cs。 按一下 [確定] 按鈕,將新的測試新增至測試專案。
注意
一般而言,您想要測試專案的資料夾結構符合 ASP.NET MVC 專案的資料夾結構。 例如,您可以將控制器測試放在 Controllers 資料夾中、模型測試放在 Models 資料夾中等等。
圖 03:Models\ContactManagerServiceTest.cs (按一下以檢視完整大小的影像)
一開始,我們想要測試 ContactManagerService 類別所公開的 CreateContact() 方法。 我們將建立下列五個測試:
- CreateContact() - 當有效的 Contact 傳遞至 方法時,CreateContact() 的測試會傳回 true 值。
- CreateContactRequiredFirstName() - 測試當缺少名字的連絡人傳遞給 CreateContact() 方法時,是否將錯誤訊息新增至模型狀態。
- CreateContactRequiredLastName() - 測試當缺少姓氏的連絡人傳遞給 CreateContact() 方法時,是否將錯誤訊息新增至模型狀態。
- CreateContactInvalidPhone() - 測試當將具有無效電話號碼的連絡人傳遞給 CreateContact() 方法時,是否將錯誤訊息新增至模型狀態。
- CreateContactInvalidEmail() - 測試當將具有無效電子郵件地址的連絡人傳遞給 CreateContact() 方法時,是否將錯誤訊息新增至模型狀態。
第一個測試會驗證有效的連絡人不會產生驗證錯誤。 其餘的測試會檢查每個驗證規則。
這些測試的程式碼包含在清單 1 中。
清單 1 - Models\ContactManagerServiceTest.cs
using System.Web.Mvc;
using ContactManager.Models;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
namespace ContactManager.Tests.Models
{
[TestClass]
public class ContactManagerServiceTest
{
private Mock<IContactManagerRepository> _mockRepository;
private ModelStateDictionary _modelState;
private IContactManagerService _service;
[TestInitialize]
public void Initialize()
{
_mockRepository = new Mock<IContactManagerRepository>();
_modelState = new ModelStateDictionary();
_service = new ContactManagerService(new ModelStateWrapper(_modelState), _mockRepository.Object);
}
[TestMethod]
public void CreateContact()
{
// Arrange
var contact = Contact.CreateContact(-1, "Stephen", "Walther", "555-5555", "steve@somewhere.com");
// Act
var result = _service.CreateContact(contact);
// Assert
Assert.IsTrue(result);
}
[TestMethod]
public void CreateContactRequiredFirstName()
{
// Arrange
var contact = Contact.CreateContact(-1, string.Empty, "Walther", "555-5555", "steve@somewhere.com");
// Act
var result = _service.CreateContact(contact);
// Assert
Assert.IsFalse(result);
var error = _modelState["FirstName"].Errors[0];
Assert.AreEqual("First name is required.", error.ErrorMessage);
}
[TestMethod]
public void CreateContactRequiredLastName()
{
// Arrange
var contact = Contact.CreateContact(-1, "Stephen", string.Empty, "555-5555", "steve@somewhere.com");
// Act
var result = _service.CreateContact(contact);
// Assert
Assert.IsFalse(result);
var error = _modelState["LastName"].Errors[0];
Assert.AreEqual("Last name is required.", error.ErrorMessage);
}
[TestMethod]
public void CreateContactInvalidPhone()
{
// Arrange
var contact = Contact.CreateContact(-1, "Stephen", "Walther", "apple", "steve@somewhere.com");
// Act
var result = _service.CreateContact(contact);
// Assert
Assert.IsFalse(result);
var error = _modelState["Phone"].Errors[0];
Assert.AreEqual("Invalid phone number.", error.ErrorMessage);
}
[TestMethod]
public void CreateContactInvalidEmail()
{
// Arrange
var contact = Contact.CreateContact(-1, "Stephen", "Walther", "555-5555", "apple");
// Act
var result = _service.CreateContact(contact);
// Assert
Assert.IsFalse(result);
var error = _modelState["Email"].Errors[0];
Assert.AreEqual("Invalid email address.", error.ErrorMessage);
}
}
}
因為我們在清單 1 中使用 Contact 類別,所以我們必須將 Microsoft Entity Framework 的參考新增至測試專案。 將參考新增至 System.Data.Entity 組件。
清單 1 包含名為 Initialize() 的方法,這個方法是以 [TestInitialize] 屬性裝飾。 在每次單元測試執行之前,都會自動呼叫這個方法 (每個單元測試前都會呼叫 5 次)。 The Initialize() 方法會使用下列程式碼行來建立模擬存放庫:
_mockRepository = new Mock<IContactManagerRepository>();
這一行程式碼會使用 Moq 架構,從 IContactManagerRepository 介面產生模擬存放庫。 模擬存放庫是用來取代實際的 EntityContactManagerRepository,以避免在執行每個單元測試時存取資料庫。 模擬存放庫會實作 IContactManagerRepository 介面的方法,但方法實際上不會執行任何動作。
注意
使用 Moq 架構時,_mockRepository與 _mockRepository.Object 有區別。 前者指的是 Mock <IContactManagerRepository> 類別,該類別包含用於指定模擬儲存庫行為方式的方法。 後者是指實作 IContactManagerRepository 介面的實際模擬存放庫。
建立 ContactManagerService 類別的執行個體時,模擬存放庫會在 Initialize() 方法中使用。 所有個別單元測試都會使用 ContactManagerService 類別的這個執行個體。
清單 1 包含五個對應至每個單元測試的方法。 這些方法中的每一個都以 [TestMethod] 屬性修飾。 當您執行單元測試時,會呼叫具有此屬性的任何方法。 換句話說,任何以 [TestMethod] 屬性裝飾的方法都是單元測試。
第一個單元測試名為 CreateContact(),驗證當 Contact 類別的有效執行個體傳遞給該方法時,呼叫 CreateContact() 是否傳回 true 值。 測試會建立 Contact 類別的執行個體、呼叫 CreateContact() 方法,並驗證 CreateContact() 是否傳回 true 值。
其餘的測試會驗證當 CreateContact() 方法以無效的 Contact 呼叫時,方法會傳回 false,並將預期的驗證錯誤訊息新增至模型狀態。 例如,CreateContactRequiredFirstName() 測試會為其 FirstName 屬性建立具有空字串的 Contact 類別執行個體。 接下來,會使用無效的 Contact 呼叫 CreateContact() 方法。 最後,測試會驗證 CreateContact() 傳回 false,且該模型狀態包含預期的驗證錯誤訊息「需要名字」。
您可以透過選取功能表選項「測試」、「執行」、「解決方案中的所有測試」(CTRL+R、A) 來執行清單 1 中的單元測試。 測試結果顯示在 Test Results (測試結果) 視窗中 (參見圖 4)。
圖 04:測試結果 (按一下以檢視完整大小的圖片)
建立控制器的單元測試
ASP.NETMVC 應用程式會控制使用者互動的流程。 測試控制器時,您想要測試控制器是否傳回正確的動作結果並檢視資料。 您也可以測試控制器是否以預期的方式與模型類別互動。
例如,清單 2 包含 Contact 控制器 Create() 方法的兩個單元測試。 第一個單元測試會驗證當有效的 Contact 傳遞至 Create() 方法時,Create() 方法會重新導向至 Index 動作。 換句話說,當傳遞有效的 Contact 時,Create() 方法應該會傳回代表 Index 動作的 RedirectToRouteResult。
當我們測試控制器層時,我們不想測試 ContactManager 服務層。 因此,我們會在 Initialize 方法中使用下列程式碼模擬服務層:
_service = new Mock();
在 CreateValidContact() 單元測試中,我們會使用下列程式碼來模擬呼叫服務層 CreateContact() 方法的行為:
_service.Expect(s => s.CreateContact(contact)).Returns(true);
這一行程式碼會使模擬 ContactManager 服務在呼叫 CreateContact() 方法時傳回 true 值。 藉由模擬服務層,我們可以測試控制器的行為,而不需要在服務層中執行任何程式碼。
第二個單元測試會驗證 Create() 動作在傳遞至方法無效的連絡人時,會傳回建立檢視。 我們會導致服務層 CreateContact() 方法使用下列程式碼行傳回 false 值:
_service.Expect(s => s.CreateContact(contact)).Returns(false);
如果 Create() 方法的行為如預期般運作,當服務層傳回值 false 時,它應該會傳回 Create 檢視。 如此一來,控制器就可以在 [建立] 檢視中顯示驗證錯誤訊息,而且使用者有機會更正無效的 [連絡人] 屬性。
如果您打算為控制器建置單元測試,則必須從控制器動作傳回明確的檢視名稱。 例如,請勿傳回如下的檢視:
傳回檢視 ();
而是該傳回如下的檢視:
傳回檢視 ("Create");
如果傳回檢視時不明確,ViewResult.ViewName 屬性會傳回空字串。
清單 2 - Controllers\ContactControllerTest.cs
using System.Web.Mvc;
using ContactManager.Controllers;
using ContactManager.Models;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
namespace ContactManager.Tests.Controllers
{
[TestClass]
public class ContactControllerTest
{
private Mock<IContactManagerService> _service;
[TestInitialize]
public void Initialize()
{
_service = new Mock<IContactManagerService>();
}
[TestMethod]
public void CreateValidContact()
{
// Arrange
var contact = new Contact();
_service.Expect(s => s.CreateContact(contact)).Returns(true);
var controller = new ContactController(_service.Object);
// Act
var result = (RedirectToRouteResult)controller.Create(contact);
// Assert
Assert.AreEqual("Index", result.RouteValues["action"]);
}
[TestMethod]
public void CreateInvalidContact()
{
// Arrange
var contact = new Contact();
_service.Expect(s => s.CreateContact(contact)).Returns(false);
var controller = new ContactController(_service.Object);
// Act
var result = (ViewResult)controller.Create(contact);
// Assert
Assert.AreEqual("Create", result.ViewName);
}
}
}
摘要
在此反覆項目中,我們為 Contact Manager 應用程式建立了單元測試。 我們隨時都可以執行這些單元測試,以確認我們的應用程式仍以我們預期的方式運作。 單元測試可做為應用程式的安全網,可讓我們在未來安全地修改應用程式。
我們建立了兩組單元測試。 首先,我們會藉由建立服務層的單元測試來測試我們的驗證邏輯。 接下來,我們會建立控制器層的單元測試,以測試流程控制邏輯。 測試服務層時,我們藉由模擬存放庫層,將服務層的測試與存放庫層隔離。 測試控制器層時,我們藉由模擬服務層來隔離控制器層的測試。
在下一個反覆項目中,我們會修改 Contact Manager 應用程式,使其支援 Contact Groups。 我們將使用稱為測試為導向開發的軟體設計程式,將這項新功能新增至應用程式。