史考特·艾倫
發表日期:2010年5月
簡介
本白皮書描述並示範如何使用 ADO.NET Entity Framework 4.0 與 Visual Studio 2010 撰寫可測試的程式碼。 本文並不試圖聚焦於特定的測試方法,如測試驅動設計(TDD)或行為驅動設計(BDD)。 本文將著重於如何撰寫使用 ADO.NET 實體框架,同時又能以自動化方式易於隔離與測試的程式碼。 我們將探討在資料存取情境中促進測試的常見設計模式,並探討如何在使用框架時應用這些模式。 我們也會探討框架的具體功能,看看這些功能如何在可測試的程式碼中運作。
什麼是可測試程式碼?
利用自動化單元測試驗證軟體的能力帶來許多理想的好處。 大家都知道,良好的測試能減少應用程式中的軟體缺陷數量並提升應用程式品質——但設置單元測試遠不止於發現錯誤。
一個好的單元測試套件能讓開發團隊節省時間,並持續掌控他們所開發的軟體。 團隊可以修改現有程式碼、重構、重新設計及重組軟體以符合新需求,並在測試套件中驗證應用程式行為的同時,新增元件。 單元測試是快速回饋循環的一部分,旨在促進變革並隨著複雜度增加,維護軟體的可維護性。
然而,單元測試是有代價的。 團隊必須投入時間來建立並維護單元測試。 建立這些測試所需的工作量,直接取決於底層軟體的 可測試 性。 這套軟體測試起來有多容易? 一個以可測試性為設計軟體的團隊,會比使用無法測試軟體的團隊更快創造出有效的測試。
Microsoft設計 ADO.NET 實體框架4.0(EF4)時,考量了可測試性。 這並不代表開發者會針對框架程式碼本身撰寫單元測試。 相反地,EF4 的可測試性目標讓建立建立在框架之上、可測試的程式碼變得容易。 在我們看具體範例之前,先了解可測試程式碼的特性是值得的。
可測試程式碼的特性
容易測試的程式碼總是會展現至少兩個特徵。 首先,可測試的程式碼很容易 觀察。 給定一組輸入,應該很容易觀察程式碼的輸出。 例如,測試以下方法很容易,因為該方法會直接回傳計算結果。
public int Add(int x, int y) {
return x + y;
}
如果方法將計算出的值寫入網路套接字、資料庫資料表或像以下程式碼這樣的檔案,測試方法會變得困難。 檢定必須進行額外運算來取得該值。
public void AddAndSaveToFile(int x, int y) {
var results = string.Format("The answer is {0}", x + y);
File.WriteAllText("results.txt", results);
}
其次,可測試的程式碼很容易 被分離出來。 讓我們用以下偽程式碼作為一個糟糕的可測試程式碼範例。
public int ComputePolicyValue(InsurancePolicy policy) {
using (var connection = new SqlConnection("dbConnection"))
using (var command = new SqlCommand(query, connection)) {
// business calculations omitted ...
if (totalValue > notificationThreshold) {
var message = new MailMessage();
message.Subject = "Warning!";
var client = new SmtpClient();
client.Send(message);
}
}
return totalValue;
}
這個方法很容易觀察——我們可以提交保險單,並驗證報酬值與預期結果相符。 不過,要測試這個方法,我們需要安裝一個包含正確結構的資料庫,並設定 SMTP 伺服器以防該方法嘗試發送電子郵件。
單元測試只想驗證方法內的計算邏輯,但測試可能因電子郵件伺服器離線或資料庫伺服器移動而失敗。 這兩種失敗都與測試想要驗證的行為無關。 這種行為很難單獨辨識。
致力於撰寫可測試程式碼的軟體開發者,通常會努力在程式碼中保持關注點的分離。 上述方法應專注於業務計算,並將資料庫與電子郵件實作細節委派給其他元件。 羅伯特·C·馬丁稱之為單一責任原則。 一個物件應該封裝單一且狹義的責任,就像計算保單價值一樣。 所有其他資料庫和通知工作應該由其他物件負責。 以這種方式撰寫的程式碼較容易被分離,因為它專注於單一任務。
在 .NET 中,我們擁有遵循單一責任原則並實現隔離所需的抽象。 我們可以使用介面定義,強制程式碼使用介面抽象而非具體型別。 本文稍後我們將探討,像上述不良範例這樣的方法,如何與 看似 會與資料庫溝通的介面運作。 不過在測試時,我們可以用一個不與資料庫通訊、而是將資料存放在記憶體中的虛擬實作來替代。 此虛擬實作將將程式碼與資料存取碼或資料庫設定中無關的問題隔離開來。
隔離還有其他好處。 最後一種方法中的商業計算應該只需幾毫秒即可執行,但測試本身可能會持續數秒,因為程式碼在網路中跳躍並與各種伺服器通訊。 單元測試應快速執行以促進小幅調整。 單元測試也應該具備可重複性,不能因為與測試無關的元件出現問題而失敗。 撰寫易於觀察與隔離的程式碼,意味著開發者能更輕鬆撰寫測試,減少等待測試執行的時間,更重要的是,能減少追蹤不存在錯誤的時間。
希望你能體會測試的好處,並了解可測試程式碼所展現的特質。 我們即將說明如何撰寫與 EF4 相容的程式碼,既能將資料儲存到資料庫,又能保持可觀察性且易於隔離,但首先我們會聚焦於可測試的資料存取設計。
資料持久性設計模式
前面提到的兩個壞例子都肩負太多責任。 第一個糟糕的例子必須執行計算 並 寫入檔案。 第二個糟糕的例子是從資料庫讀取資料 、 進行商業計算 並 發送電子郵件。 透過設計較小的方法,將責任分給其他元件,你將大幅提升撰寫可測試程式碼的步伐。 目標是透過從小型且具體的抽象概念組成操作來建構功能。
談到資料持久性,我們尋找的小型且聚焦的抽象非常普遍,以至於已被記錄為設計模式。 Martin Fowler 的著作《企業應用架構模式》是第一本以書面形式描述這些模式的著作。 在展示這些 ADO.NET 實體框架如何實作並運用這些模式之前,我們將在接下來的章節中簡要說明這些模式。
儲存庫模式
Fowler 表示,儲存庫「透過類似收藏的介面來存取網域物件,在網域與資料映射層之間進行中介」。 儲存庫模式的目標是將程式碼與資料存取的細節隔離開來,正如我們之前所見,隔離是可測試性的必要特質。
隔離的關鍵在於儲存庫如何透過類似集合式介面來暴露物件。 你寫的使用儲存庫的邏輯根本不知道儲存庫會如何實現你請求的物件。 儲存庫可能與資料庫通訊,或直接回傳記憶體內的物件集合。 你的程式碼只需要知道倉庫看起來會維護這個集合,並且你可以從集合中檢索、新增和刪除物件。
在現有的 .NET 應用程式中,具體的儲存庫通常繼承自以下通用介面:
public interface IRepository<T> {
IEnumerable<T> FindAll();
IEnumerable<T> FindBy(Expression<Func\<T, bool>> predicate);
T FindById(int id);
void Add(T newEntity);
void Remove(T entity);
}
當我們提供 EF4 實作時,會對介面定義做一些修改,但基本概念保持不變。 程式碼可利用實作此介面的具體儲存庫,依據主鍵值檢索實體、根據謂詞評估取得一組實體,或直接取得所有可用實體。 程式碼也能透過儲存庫介面新增或移除實體。
給定一個員工物件的儲存庫,程式碼可以執行以下操作。
var employeesNamedScott =
repository
.FindBy(e => e.Name == "Scott")
.OrderBy(e => e.HireDate);
var firstEmployee = repository.FindById(1);
var newEmployee = new Employee() {/*... */};
repository.Add(newEmployee);
由於程式碼正在使用員工的 IRepository 介面,我們可以為程式碼提供該介面的不同實作。 一種實作可能是由 EF4 支援,並將物件持久化到 Microsoft SQL Server 資料庫中。 另一個實作(我們在測試時使用的)可能會由記憶體中的 Employee 物件清單作為後盾。 介面有助於在程式碼中實現隔離。
請注意,IRepository<T> 介面不會暴露儲存操作。 我們要如何更新現有的物件? 你可能會看到包含儲存操作的 IRepository 定義,這些儲存庫的實作需要立即將物件持久化到資料庫中。 然而,在許多應用程式中,我們不希望個別持久化物件。 相反地,我們希望讓物件活起來,可能來自不同的儲存庫,作為商業活動的一部分修改這些物件,然後將所有物件作為單一原子操作的一部分持續存在。 幸運的是,有一種模式允許這種行為。
工作單位模式
Fowler 表示,工作單元會「維護一份受業務交易影響的物件清單,並協調變更的寫入與並行問題的解決」。 工作單位負責追蹤從倉庫引入的物件變更,並在我們指示工作單位提交變更時,持久化對這些物件所做的任何更改。 工作單位也負責將我們新增到所有存儲庫的物件插入資料庫,以及管理物件的刪除。
如果你曾經用過 ADO.NET DataSet,那你應該已經熟悉工作單元的模式。 ADO.NET DataSets 能夠追蹤我們對 DataRow 物件的更新、刪除與插入,並能透過 TableAdapter 將所有變更對帳到資料庫。 然而,DataSet 物件建模的是底層資料庫中斷開的子集。 工作模式單元呈現相同行為,但可處理與資料存取碼隔離且不認識資料庫的業務物件與領域物件。
用 .NET 程式碼來建模工作單元的抽象可能如下:
public interface IUnitOfWork {
IRepository<Employee> Employees { get; }
IRepository<Order> Orders { get; }
IRepository<Customer> Customers { get; }
void Commit();
}
透過公開工作單元的儲存庫參考,我們可以確保單一工作物件單元能追蹤所有在業務交易中實體化的實體。 在實際工作單元中實作 Commit 方法,是將記憶體變更與資料庫調和的魔法發生之處。
給定 IUnitOfWork 參考,程式碼可對從一個或多個儲存庫取得的業務物件進行變更,並透過原子提交操作儲存所有變更。
var firstEmployee = unitofWork.Employees.FindById(1);
var firstCustomer = unitofWork.Customers.FindById(1);
firstEmployee.Name = "Alex";
firstCustomer.Name = "Christopher";
unitofWork.Commit();
延遲載入模式
Fowler 用「lazy load」這個名稱來描述「一個不包含所有所需資料,但知道如何取得這些資料的物件」。 透明的延遲加載是撰寫可測試商業程式碼及使用關聯式資料庫時的重要功能。 舉例來說,請考慮以下程式碼。
var employee = repository.FindById(id);
// ... and later ...
foreach(var timeCard in employee.TimeCards) {
// .. manipulate the timeCard
}
TimeCards 集合是如何填入的? 有兩種可能的答案。 一種說法是,當員工資料庫被要求取回員工時,會發出查詢,以取得該員工及其相關的工時卡資訊。 在關聯式資料庫中,這通常需要帶有 JOIN 子句的查詢,且可能導致取得超出應用程式需要的資訊。 如果應用程式根本不需要碰到TimeCards屬性呢?
第二個方法是將 TimeCards 屬性「按需」載入。 這種懶惰載入對業務邏輯來說是隱含且透明的,因為程式碼不會呼叫特殊 API 來擷取工時卡資訊。 該程式碼假設在需要時工時卡資訊已經存在。 懶載入通常涉及執行時攔截方法調用的魔法。 攔截程式碼負責與資料庫通訊並取得工時卡資訊,同時讓業務邏輯不受影響,能專注於本身的功能。 這種懶散載入魔法讓商業程式碼能與資料擷取操作隔離,產生更多可測試的程式碼。
延遲載入的缺點是,當應用程式需要時間卡資訊時,程式碼將會執行額外的查詢。 這對許多應用程式來說不是問題,但對於效能敏感的應用程式,或是每次迴圈時要重複多個員工物件並執行查詢以取得時間卡的應用程式(通常稱為 N+1 查詢問題),延遲載入會造成負擔。 在這些情況下,應用程式可能希望以最有效率的方式急切載入工時卡資訊。
幸運的是,在實作這些模式的下一節,我們將看到 EF4 是如何同時支援隱式延遲載入與高效積極載入的。
利用實體框架實作模式
好消息是,我們在上一節描述的所有設計模式都能直接用 EF4 實作。 為了示範,我們將使用一個簡單的 ASP.NET MVC 應用程式來編輯並顯示員工及其相關的考勤卡資訊。 我們將從使用以下「普通的 .NET CLR 物件」(POCO)開始。
public class Employee {
public int Id { get; set; }
public string Name { get; set; }
public DateTime HireDate { get; set; }
public ICollection<TimeCard> TimeCards { get; set; }
}
public class TimeCard {
public int Id { get; set; }
public int Hours { get; set; }
public DateTime EffectiveDate { get; set; }
}
隨著我們探索 EF4 的不同方法與特性,這些類別定義會略有調整,但目標是盡可能保持這些類別的持久性無知(PI)。 PI 物件不知道 它所持有的狀態 如何,甚至是否存在於資料庫中。 PI 和 POCO 與可測試的軟體是密不可分的。 採用 POCO 方法的物件限制較少、更具彈性且更容易測試,因為它們可以在沒有資料庫的情況下運作。
有了 POCO,我們就能在 Visual Studio 建立實體資料模型(EDM)(見圖 1)。 我們不會使用 EDM 來為我們的實體產生程式碼。 相反地,我們希望使用那些我們用心手工打造的實體。 我們只會用 EDM 來產生資料庫架構,並提供 EF4 需要的元資料,用來將物件映射到資料庫。
圖 1
注意:如果你想先開發 EDM 模型,可以從 EDM 產生乾淨且 POCO 的程式碼。 你可以使用資料可程式設計團隊提供的 Visual Studio 2010 擴充功能來完成這件事。 要下載擴充功能,請從 Visual Studio 的工具選單啟動擴充功能管理器,並在線上範本圖庫中搜尋「POCO」(見圖 2)。 EF(Entity Framework)有多種 POCO 範本可用。 欲了解更多使用範本的資訊,請參閱「 攻略:實體框架的 POCO 範本」。
圖 2
從這個 POCO 出發點,我們將探討兩種不同的可測試程式碼方法。 我稱第一種方法是 EF 方法,因為它利用 Entity Framework API 的抽象來實作工作單元和倉庫。 在第二種方法中,我們將建立自己的自訂儲存庫抽象,然後檢視每種方法的優缺點。 我們將從 EF 方法開始探討。
以 EF 為中心的實作
請考慮以下一個 ASP.NET MVC 專案中的控制器動作。 該動作會擷取一個 Employee 物件,並回傳結果以顯示該員工的詳細視圖。
public ViewResult Details(int id) {
var employee = _unitOfWork.Employees
.Single(e => e.Id == id);
return View(employee);
}
程式碼可以測試嗎? 我們至少需要兩個測試來驗證這個動作的行為。 首先,我們想確認操作是否回傳正確的視圖——這是一個簡單的測試。 我們也想寫一個測試來驗證動作是否取得正確的員工,且希望在不執行程式碼查詢資料庫的情況下完成此事。 記得我們要把被測試的程式碼隔離起來。 隔離能確保測試不會因資料存取程式碼或資料庫設定的錯誤而失敗。 如果測試失敗,我們就知道是控制器邏輯有錯誤,而非低階系統元件。
為了達成隔離,我們需要一些抽象,就像之前介紹的儲存庫和工作單元介面一樣。 請記得,儲存庫模式的設計目的是介於網域物件與資料映射層之間。 在此情境中,EF4 是 資料映射層,並已提供類似倉庫的抽象,名為 IObjectSet<T> (來自 System.Data.Objects 命名空間)。 介面定義如下。
public interface IObjectSet<TEntity> :
IQueryable<TEntity>,
IEnumerable<TEntity>,
IQueryable,
IEnumerable
where TEntity : class
{
void AddObject(TEntity entity);
void Attach(TEntity entity);
void DeleteObject(TEntity entity);
void Detach(TEntity entity);
}
IObjectSet<T> 符合儲存庫的需求,因為它類似於一組物件(透過 IEnumerable<T>),並提供方法來從模擬集合中新增或移除物件。 Attach與Detach方法則暴露EF4 API的額外功能。 要使用 IObjectSet<T> 作為倉庫介面,我們需要一個工作抽象單元來綁定倉庫。
public interface IUnitOfWork {
IObjectSet<Employee> Employees { get; }
IObjectSet<TimeCard> TimeCards { get; }
void Commit();
}
這個介面的一個具體實作會與 SQL Server 溝通,且使用 EF4 的 ObjectContext 類別很容易建立。 ObjectContext 類別是 EF4 API 中真正的工作單位。
public class SqlUnitOfWork : IUnitOfWork {
public SqlUnitOfWork() {
var connectionString =
ConfigurationManager
.ConnectionStrings[ConnectionStringName]
.ConnectionString;
_context = new ObjectContext(connectionString);
}
public IObjectSet<Employee> Employees {
get { return _context.CreateObjectSet<Employee>(); }
}
public IObjectSet<TimeCard> TimeCards {
get { return _context.CreateObjectSet<TimeCard>(); }
}
public void Commit() {
_context.SaveChanges();
}
readonly ObjectContext _context;
const string ConnectionStringName = "EmployeeDataModelContainer";
}
讓 IObjectSet<> T 活起來就像呼叫 ObjectContext 物件的 CreateObjectSet 方法一樣簡單。 在幕後,框架會利用我們在 EDM 中提供的元資料,產生一個具體的 ObjectSet<T>。 我們會繼續回傳 IObjectSet<T> 介面,因為它有助於保留客戶端程式碼的可測試性。
這種具體的實作在生產環境中很有用,但我們需要著重於如何利用 IUnitOfWork 抽象來促進測試。
測試雙打
為了隔離控制器動作,我們需要能在真實工作單元(由 ObjectContext 支援)與測試雙重或「假」工作單元(執行記憶體內操作)之間切換。 執行此類交換的常見方法是不讓 MVC 控制器實例化工作單位,而是將工作單位作為建構參數傳入控制器。
class EmployeeController : Controller {
publicEmployeeController(IUnitOfWork unitOfWork) {
_unitOfWork = unitOfWork;
}
...
}
上述程式碼即為依賴注入的範例。 我們不允許控制器建立其相依性(工作單元),而是將相依性注入控制器中。 在 MVC 專案中,通常會使用自訂控制器工廠搭配控制反演(IoC)容器來自動化相依注入。 這些主題超出本文範圍,但你可以透過本文末尾的參考資料閱讀更多內容。
一個我們用來測試的假工作單元實作可能如下。
public class InMemoryUnitOfWork : IUnitOfWork {
public InMemoryUnitOfWork() {
Committed = false;
}
public IObjectSet<Employee> Employees {
get;
set;
}
public IObjectSet<TimeCard> TimeCards {
get;
set;
}
public bool Committed { get; set; }
public void Commit() {
Committed = true;
}
}
注意假工作單位暴露了一個承諾財產。 有時候,在假類別中加入有助於測試的功能是有用的。 在這種情況下,可以透過檢查已提交屬性來觀察程式碼是否已成功提交工作單元。
我們還需要一個假的 IObjectSet<T> 來存放 Employee 和 TimeCard 物件在記憶體中。 我們可以提供使用泛型的單一實作。
public class InMemoryObjectSet<T> : IObjectSet<T> where T : class
public InMemoryObjectSet()
: this(Enumerable.Empty<T>()) {
}
public InMemoryObjectSet(IEnumerable<T> entities) {
_set = new HashSet<T>();
foreach (var entity in entities) {
_set.Add(entity);
}
_queryableSet = _set.AsQueryable();
}
public void AddObject(T entity) {
_set.Add(entity);
}
public void Attach(T entity) {
_set.Add(entity);
}
public void DeleteObject(T entity) {
_set.Remove(entity);
}
public void Detach(T entity) {
_set.Remove(entity);
}
public Type ElementType {
get { return _queryableSet.ElementType; }
}
public Expression Expression {
get { return _queryableSet.Expression; }
}
public IQueryProvider Provider {
get { return _queryableSet.Provider; }
}
public IEnumerator<T> GetEnumerator() {
return _set.GetEnumerator();
}
IEnumerator IEnumerable.GetEnumerator() {
return GetEnumerator();
}
readonly HashSet<T> _set;
readonly IQueryable<T> _queryableSet;
}
此測試將大部分工作雙重委派給底層的 HashSet<T> 物件。 請注意,IObjectSet<T> 需要一個通用約束來強制 T 作為類別(參考型別),同時也迫使我們實作 IQueryable<T>。 使用標準 LINQ 運算符 AsQueryable 可以讓記憶體中的集合呈現為 IQueryable<T> 。
測試賽
傳統單元測試會使用單一測試類別,將所有動作的測試都存放在單一 MVC 控制器中。 我們可以使用我們建置的記憶體中的仿真物件來撰寫這些測試或任何類型的單元測試。 不過,本文我們將避免單一測試類別的方法,而是將測試群組以聚焦於特定功能。 例如,「建立新員工」可能是我們想測試的功能,因此我們會用單一測試類別來驗證負責創建新員工的單一控制器動作。
我們需要一些通用的設定程式碼來處理這些細緻的測試類別。 例如,我們總是需要建立記憶體中的儲存庫和假的工作單元。 我們也需要一個員工控制器的實例,並在其中注入虛擬工作單元。 我們將透過基底類別,將這些常見的設定程式碼分享到不同測試類別。
public class EmployeeControllerTestBase {
public EmployeeControllerTestBase() {
_employeeData = EmployeeObjectMother.CreateEmployees()
.ToList();
_repository = new InMemoryObjectSet<Employee>(_employeeData);
_unitOfWork = new InMemoryUnitOfWork();
_unitOfWork.Employees = _repository;
_controller = new EmployeeController(_unitOfWork);
}
protected IList<Employee> _employeeData;
protected EmployeeController _controller;
protected InMemoryObjectSet<Employee> _repository;
protected InMemoryUnitOfWork _unitOfWork;
}
我們在基底類別中使用的「物件母」是建立測試資料的常見模式之一。 物件母物件包含工廠方法,用於實例化測試實體,以便在多個測試環境間使用。
public static class EmployeeObjectMother {
public static IEnumerable<Employee> CreateEmployees() {
yield return new Employee() {
Id = 1, Name = "Scott", HireDate=new DateTime(2002, 1, 1)
};
yield return new Employee() {
Id = 2, Name = "Poonam", HireDate=new DateTime(2001, 1, 1)
};
yield return new Employee() {
Id = 3, Name = "Simon", HireDate=new DateTime(2008, 1, 1)
};
}
// ... more fake data for different scenarios
}
我們可以使用 EmployeeControllerTestBase 作為多個測試夾具的基底類別(見圖 3)。 每個測試燈具都會測試特定的控制器動作。 例如,一個測試夾具會專注於測試在 HTTP GET 請求中使用的 Create 動作(用以顯示建立員工的視圖),另一個則會專注於 HTTP POST 請求中使用的 Create 動作(用來接收使用者提交的資訊以建立員工)。 每個派生類別僅負責其特定上下文所需的設置,並提供驗證其特定測試上下文結果所需的斷言。
圖 3
這裡呈現的命名規則和測試風格並非可測試程式碼的必要條件——這只是其中一種方法。 圖 4 顯示了 Visual Studio 2010 的 Jet Brains Resharper 測試執行器外掛中執行的測試。
圖4
有了基底類別來處理共用的設定程式碼,每個控制器動作的單元測試都很小且容易撰寫。 測試會很快執行(因為我們正在執行記憶體內操作),而且不會因為其他基礎設施或環境問題而失敗(因為我們已經隔離了被測單元)。
[TestClass]
public class EmployeeControllerCreateActionPostTests
: EmployeeControllerTestBase {
[TestMethod]
public void ShouldAddNewEmployeeToRepository() {
_controller.Create(_newEmployee);
Assert.IsTrue(_repository.Contains(_newEmployee));
}
[TestMethod]
public void ShouldCommitUnitOfWork() {
_controller.Create(_newEmployee);
Assert.IsTrue(_unitOfWork.Committed);
}
// ... more tests
Employee _newEmployee = new Employee() {
Name = "NEW EMPLOYEE",
HireDate = new System.DateTime(2010, 1, 1)
};
}
在這些測試中,基底類別負責大部分的設定工作。 請記得基底類別建構子會建立記憶體儲存庫、一個假的工作單元,以及 EmployeeController 類別的實例。 測試類別源自此基底類別,專注於 Create 方法的測試細節。 在這種情況下,細節歸結為你在任何單元測試程序中會看到的「安排、行動與斷言」步驟:
- 建立一個 newEmployee 物件來模擬輸入資料。
- 呼叫 EmployeeController 的 Create 動作,並傳入 newEmployee。
- 確認建立動作產生預期結果(員工會出現在資料庫中)。
我們所建立的系統讓我們能夠測試任何 EmployeeController 的操作。 例如,當我們為 Employee controller 的 Index 動作撰寫測試時,可以繼承測試基底類別,建立相同的測試基礎設定。 基底類別同樣會建立記憶體儲存庫、假工作單元,以及 EmployeeController 的實例。 索引動作的測試只需著重於調用索引動作並測試該動作回傳的模型特性。
[TestClass]
public class EmployeeControllerIndexActionTests
: EmployeeControllerTestBase {
[TestMethod]
public void ShouldBuildModelWithAllEmployees() {
var result = _controller.Index();
var model = result.ViewData.Model
as IEnumerable<Employee>;
Assert.IsTrue(model.Count() == _employeeData.Count);
}
[TestMethod]
public void ShouldOrderModelByHiredateAscending() {
var result = _controller.Index();
var model = result.ViewData.Model
as IEnumerable<Employee>;
Assert.IsTrue(model.SequenceEqual(
_employeeData.OrderBy(e => e.HireDate)));
}
// ...
}
我們利用記憶體內的假冒工具所製作的測試,主要是針對軟體的 狀態 進行測試。 例如,在測試建立動作時,我們希望在建立動作執行後檢查儲存庫的狀態——儲存庫是否保存了新員工?
[TestMethod]
public void ShouldAddNewEmployeeToRepository() {
_controller.Create(_newEmployee);
Assert.IsTrue(_repository.Contains(_newEmployee));
}
稍後我們會探討基於互動的測試。 基於互動的測試會詢問被測程式碼是否在物件上啟用了正確的方法並通過了正確的參數。 現在我們將繼續介紹另一種設計模式——延遲加載。
預載入與延遲加載
在 ASP.NET MVC網頁應用程式的某個階段,我們可能希望顯示員工的資訊並包含該員工相關的考勤卡。 例如,我們可能會有一個工時卡摘要顯示,顯示員工姓名及系統中考勤卡的總數。 我們可以採取多種方法來實作此功能。
投影
建立摘要的一個簡單方法是建立一個專門針對我們想在視圖中顯示的資訊的模型。 在這種情況下,模型可能如下所示。
public class EmployeeSummaryViewModel {
public string Name { get; set; }
public int TotalTimeCards { get; set; }
}
請注意,EmployeeSummaryViewModel 不是一個實體——換句話說,我們不希望它持續存在資料庫中。 我們只會用這個類別來以強類型的方式將資料整理到視圖中。 視圖模型類似資料傳輸物件(DTO),因為它不包含行為(不包含方法)——只有屬性。 屬性會儲存我們移動所需的資料。 使用 LINQ 標準投影運算子 Select 運算子,可以簡單實例化此視圖模型。
public ViewResult Summary(int id) {
var model = _unitOfWork.Employees
.Where(e => e.Id == id)
.Select(e => new EmployeeSummaryViewModel
{
Name = e.Name,
TotalTimeCards = e.TimeCards.Count()
})
.Single();
return View(model);
}
上述程式碼有兩個顯著特點。 首先——程式碼容易測試,因為它仍易於觀察和隔離。 Select 運算元在記憶體中的仿真物和真實的工作單元上運作效果一樣好。
[TestClass]
public class EmployeeControllerSummaryActionTests
: EmployeeControllerTestBase {
[TestMethod]
public void ShouldBuildModelWithCorrectEmployeeSummary() {
var id = 1;
var result = _controller.Summary(id);
var model = result.ViewData.Model as EmployeeSummaryViewModel;
Assert.IsTrue(model.TotalTimeCards == 3);
}
// ...
}
第二個顯著特點是該程式碼允許 EF4 產生單一且高效的查詢,將員工與工時卡資訊整合在一起。 我們已經將員工資訊和工時卡資訊載入同一個物件,且未使用任何特殊 API。 程式碼僅僅利用標準 LINQ 運算子來表達所需的資訊,這些運算子可以適用於記憶體內資料來源的查詢以及遠端資料來源的查詢。 EF4 能夠將 LINQ 查詢與 C# 編譯器產生的表達式樹轉換成單一且高效的 T-SQL 查詢。
SELECT
[Limit1].[Id] AS [Id],
[Limit1].[Name] AS [Name],
[Limit1].[C1] AS [C1]
FROM (SELECT TOP (2)
[Project1].[Id] AS [Id],
[Project1].[Name] AS [Name],
[Project1].[C1] AS [C1]
FROM (SELECT
[Extent1].[Id] AS [Id],
[Extent1].[Name] AS [Name],
(SELECT COUNT(1) AS [A1]
FROM [dbo].[TimeCards] AS [Extent2]
WHERE [Extent1].[Id] =
[Extent2].[EmployeeTimeCard_TimeCard_Id]) AS [C1]
FROM [dbo].[Employees] AS [Extent1]
WHERE [Extent1].[Id] = @p__linq__0
) AS [Project1]
) AS [Limit1]
有時候我們不想使用視圖模型或 DTO 物件,而是想使用真實的實體。 當我們知道需要員工 及其 工時卡時,我們可以熱切地以低調且高效的方式載入相關資料。
明確的急切載入
當我們想要急切載入相關實體資訊時,需要某種機制讓業務邏輯(或在此情境中為控制器動作邏輯)向資料庫表達其需求。 EF4 的 ObjectQuery<T> 類別定義了一個 Include 方法,用以指定在查詢過程中要檢索的相關物件。 請記得 EF4 的 ObjectContext 是透過 ObjectSet<T> 類別來暴露實體,該類別繼承自 ObjectQuery<T>。 如果我們在控制器動作中使用 ObjectSet<T> 參考,我們可以寫以下程式碼,為每位員工指定急需的工時卡資訊載入。
_employees.Include("TimeCards")
.Where(e => e.HireDate.Year > 2009);
為了保持程式碼的可測試性,我們不會在真實的單元工作類別之外公開 ObjectSet<T>。 取而代之的是,我們依賴 IObjectSet<T> 介面,該介面較容易偽造,但 IObjectSet<T> 並未定義包含方法。 LINQ 的優點在於我們可以自行創建自定義的 Include 運算子。
public static class QueryableExtensions {
public static IQueryable<T> Include<T>
(this IQueryable<T> sequence, string path) {
var objectQuery = sequence as ObjectQuery<T>;
if(objectQuery != null)
{
return objectQuery.Include(path);
}
return sequence;
}
}
請注意,這個 Include 運算子被定義為 IQueryable<T> 的擴展方法,而非 IObjectSet<T>。 這讓我們能夠使用更廣泛的類型,包括 IQueryable<T>、IObjectSet<T>、ObjectQuery<T> 和 ObjectSet<T>。 若底層序列不是真正的 EF4 ObjectQuery<T>,則無損害,Include 運算子為 no-op。 如果底層序列 是 ObjectQuery<T> (或由 ObjectQuery<T> 衍生),EF4 會看到我們對額外資料的需求,並提出正確的 SQL 查詢。
有了這個新操作員,我們可以明確向資料庫請求大量時間卡資訊。
public ViewResult Index() {
var model = _unitOfWork.Employees
.Include("TimeCards")
.OrderBy(e => e.HireDate);
return View(model);
}
當程式碼執行於真實的 ObjectContext 時,會產生以下單一查詢。 查詢一次從資料庫收集足夠資訊,將員工物件實體化並完整填滿他們的 TimeCards 屬性。
SELECT
[Project1].[Id] AS [Id],
[Project1].[Name] AS [Name],
[Project1].[HireDate] AS [HireDate],
[Project1].[C1] AS [C1],
[Project1].[Id1] AS [Id1],
[Project1].[Hours] AS [Hours],
[Project1].[EffectiveDate] AS [EffectiveDate],
[Project1].[EmployeeTimeCard_TimeCard_Id] AS [EmployeeTimeCard_TimeCard_Id]
FROM ( SELECT
[Extent1].[Id] AS [Id],
[Extent1].[Name] AS [Name],
[Extent1].[HireDate] AS [HireDate],
[Extent2].[Id] AS [Id1],
[Extent2].[Hours] AS [Hours],
[Extent2].[EffectiveDate] AS [EffectiveDate],
[Extent2].[EmployeeTimeCard_TimeCard_Id] AS
[EmployeeTimeCard_TimeCard_Id],
CASE WHEN ([Extent2].[Id] IS NULL) THEN CAST(NULL AS int)
ELSE 1 END AS [C1]
FROM [dbo].[Employees] AS [Extent1]
LEFT OUTER JOIN [dbo].[TimeCards] AS [Extent2] ON [Extent1].[Id] = [Extent2].[EmployeeTimeCard_TimeCard_Id]
) AS [Project1]
ORDER BY [Project1].[HireDate] ASC,
[Project1].[Id] ASC, [Project1].[C1] ASC
好消息是,動作方法內的程式碼仍然完全可測試。 我們不需要為假貨提供任何額外功能來支援 Include 操作。 壞消息是,我們必須在想要的程式碼中使用包含運算子,以保持持久性無關。 這是建置可測試程式碼時需要評估權衡的典型範例。 有時候你需要讓持久性問題洩漏到倉庫抽象之外,以達成效能目標。
急向裝填的替代方案是懶惰裝填。 延遲載入意味著我們 不需要 業務代碼明確宣告相關資料的需求。 相反地,我們會在應用程式中使用實體,若需要額外資料,實體框架會按需載入資料。
延遲載入
很容易想像一個情況:我們不知道商業邏輯需要什麼資料。 我們可能知道邏輯需要員工物件,但我們可能會分支成不同的執行路徑,有些路徑需要員工的打卡資訊,有些則不需要。 這種情境非常適合隱含性延遲載入,因為資料會自動地按需出現。
延遲載入,也稱為推遲載入,確實對我們的實體物件施加了一些需求。 具有真正持久性無知的 POCO 不會受到持久層的任何要求,但真正持久性無知幾乎不可能達成。 相反地,我們以相對程度來衡量持續性無知。 如果我們必須從一個以持久性為導向的基礎類別繼承,或使用專門的集合來實現 POCO 的延遲載入,那就太可惜了。 幸運的是,EF4 有較不具侵入性的解決方案。
幾乎無法被偵測
使用 POCO 物件時,EF4 可動態產生實體的執行代理。 這些代理無形地封裝了已實體化的 POCO,透過攔截每個屬性的 get 和 set 操作來執行額外的工作,進而提供額外的功能。 其中一項服務就是我們正在尋找的懶散載入功能。 另一種服務是高效的變更追蹤機制,可記錄程式何時更改實體屬性值。 ObjectContext 在 SaveChanges 方法中會使用變更清單,透過 UPDATE 指令來持久化任何修改過的實體。
然而,這些代理要運作,需要一種方式來掛鉤實體的屬性取得和設定操作,而代理則透過覆蓋虛擬成員來達成這個目標。 因此,如果我們想要隱含的懶散載入和高效的變更追蹤,就必須回到 POCO 類別定義,並將屬性標記為虛擬。
public class Employee {
public virtual int Id { get; set; }
public virtual string Name { get; set; }
public virtual DateTime HireDate { get; set; }
public virtual ICollection<TimeCard> TimeCards { get; set; }
}
我們仍然可以說 Employee 實體大多是對持久性無知的。 唯一的要求是使用虛擬成員,這不影響程式碼的可測試性。 我們不需要從任何特殊的基底類別衍生,也不需要使用專門用於延遲載入的特殊集合。 如程式碼所示,任何實作 ICollection<T> 的類別都可以持有相關實體。
我們還有一個小小的改變需要在工作單位內做。 直接使用 ObjectContext 物件時,延遲載入預設是關閉的。 我們可以在 ContextOptions 屬性上設定一個屬性來啟用延遲載入,如果我們想在所有地方啟用懶散載入,也可以在我們的真實工作單元中設定這個屬性。
public class SqlUnitOfWork : IUnitOfWork {
public SqlUnitOfWork() {
// ...
_context = new ObjectContext(connectionString);
_context.ContextOptions.LazyLoadingEnabled = true;
}
// ...
}
啟用隱式懶惰載入後,應用程式碼可以使用員工及其相關的工時卡,卻完全不知 EF 載入額外資料所需的工作量。
var employee = _unitOfWork.Employees
.Single(e => e.Id == id);
foreach (var card in employee.TimeCards) {
// ...
}
延遲載入讓應用程式程式碼更容易撰寫,而代理的巧妙功能讓程式碼完全可測試。 工作單元的記憶體偽造可以在測試時預先載入假實體並附上相關資料。
此時我們將把注意力從使用 IObjectSet<T> 建立存儲庫轉移到抽象化上,以隱藏所有持久性框架的跡象。
自訂倉庫
當我們首次在本文中介紹工作單位設計模式時,提供了一些範例程式碼,說明工作單位可能的樣貌。 讓我們用我們一直在研究的員工與員工工時卡情境,重新呈現這個原始想法。
public interface IUnitOfWork {
IRepository<Employee> Employees { get; }
IRepository<TimeCard> TimeCards { get; }
void Commit();
}
這個工作單元與我們在上一節建立的工作單元的主要差異在於,這個單元並未使用EF4框架的任何抽象(沒有IObjectSet<T>)。 IObjectSet<T> 作為儲存庫介面運作良好,但它所暴露的 API 可能不完全符合我們應用程式的需求。 在即將到來的這個方法中,我們將使用自訂的IRepository<T> 抽象來表示儲存庫。
許多採用測試驅動設計、行為驅動設計及領域驅動方法論設計的開發者,基於多種原因偏好 IRepository<T> 方法。 首先,IRepository<T> 介面代表一個「反腐敗」層。 正如 Eric Evans 在他的《Domain Driven Design》一書中所描述,防損層能讓你的網域程式碼遠離基礎設施 API,就像持久化 API。 其次,開發者可以在倉庫中內建符合應用程式精確需求的方法(如撰寫測試時發現的)。 例如,我們可能經常需要用 ID 值定位單一實體,因此可以在儲存庫介面中加入 FindById 方法。 我們的 IRepository<T> 定義如下。
public interface IRepository<T>
where T : class, IEntity {
IQueryable<T> FindAll();
IQueryable<T> FindWhere(Expression<Func\<T, bool>> predicate);
T FindById(int id);
void Add(T newEntity);
void Remove(T entity);
}
注意我們會回到使用 IQueryable<T> 介面來揭露實體集合。 IQueryable<T> 允許 LINQ 表達式樹流入 EF4 提供者,並讓提供者能全面檢視查詢。 第二個選項是回傳 IEnumerable<T>,這表示 EF4 的 LINQ 提供者只會看到倉庫內建的表達式。 任何在資料庫外進行的分組、排序與投影,都不會被整合進傳送到資料庫的 SQL 指令中,這可能會影響效能。 另一方面,一個只回傳 IEnumerable<T> 結果的儲存庫,永遠不會讓你突然收到新的 SQL 指令。 兩種方法都能運作,且都可測試。
使用泛型和 EF4 ObjectContext API 提供單一 IRepository<T> 介面的實作非常簡單。
public class SqlRepository<T> : IRepository<T>
where T : class, IEntity {
public SqlRepository(ObjectContext context) {
_objectSet = context.CreateObjectSet<T>();
}
public IQueryable<T> FindAll() {
return _objectSet;
}
public IQueryable<T> FindWhere(
Expression<Func\<T, bool>> predicate) {
return _objectSet.Where(predicate);
}
public T FindById(int id) {
return _objectSet.Single(o => o.Id == id);
}
public void Add(T newEntity) {
_objectSet.AddObject(newEntity);
}
public void Remove(T entity) {
_objectSet.DeleteObject(entity);
}
protected ObjectSet<T> _objectSet;
}
IRepository<T> 方法讓我們對查詢有額外的控制權,因為客戶端必須呼叫某個方法才能存取實體。 在方法內,我們可以提供額外的檢查和 LINQ 運算子來強制執行應用程式的約束。 請注意,介面對通用型態參數有兩個限制。 第一個限制是 ObjectSet<T> 要求的類別限制條件,第二個限制則強制我們的實體實現 IEntity——一個為應用程式建立的抽象。 IEntity 介面強制實體必須擁有可讀的 Id 屬性,我們就可以在 FindById 方法中使用這個屬性。 IEntity 的定義如下程式碼所示。
public interface IEntity {
int Id { get; }
}
IEntity 可以被視為持久性忽略的輕微違規,因為我們的實體必須實作此介面。 請記住,持久性無知是取捨的問題,對許多人來說,FindById 的功能會超過介面所施加的限制。 介面對可測試性沒有影響。
實例化一個即時的IRepository<T> 需要一個 EF4 的 ObjectContext,因此,具體的工作單元實作應管理這個實例化過程。
public class SqlUnitOfWork : IUnitOfWork {
public SqlUnitOfWork() {
var connectionString =
ConfigurationManager
.ConnectionStrings[ConnectionStringName]
.ConnectionString;
_context = new ObjectContext(connectionString);
_context.ContextOptions.LazyLoadingEnabled = true;
}
public IRepository<Employee> Employees {
get {
if (_employees == null) {
_employees = new SqlRepository<Employee>(_context);
}
return _employees;
}
}
public IRepository<TimeCard> TimeCards {
get {
if (_timeCards == null) {
_timeCards = new SqlRepository<TimeCard>(_context);
}
return _timeCards;
}
}
public void Commit() {
_context.SaveChanges();
}
SqlRepository<Employee> _employees = null;
SqlRepository<TimeCard> _timeCards = null;
readonly ObjectContext _context;
const string ConnectionStringName = "EmployeeDataModelContainer";
}
使用自訂儲存庫
使用我們的自訂儲存庫與使用 IObjectSet<T> 的儲存庫並無顯著差異。 我們不是直接對屬性套用 LINQ 運算子,而是先呼叫儲存庫的方法來取得 IQueryable<T> 參考。
public ViewResult Index() {
var model = _repository.FindAll()
.Include("TimeCards")
.OrderBy(e => e.HireDate);
return View(model);
}
請注意,我們之前實作的自訂包含運算子不需任何改動即能運作。 該儲存庫的 FindById 方法會移除嘗試擷取單一實體時重複的邏輯。
public ViewResult Details(int id) {
var model = _repository.FindById(id);
return View(model);
}
我們檢視的兩種方法在可測試性上並無顯著差異。 我們可以透過建置由 HashSet
使用模擬物件進行測試
建立馬丁·福勒所稱的「測試替身」有不同的方法。 測試替身(像電影特技替身)是你在測試時用來「代替」真實製作物件的物件。 我們建立的記憶體儲存庫是與 SQL Server 互動的測試替身。 我們已經看過如何在單元測試中使用這些測試雙重測試來隔離程式碼並保持測試快速執行。
我們建造的測試替身都有真實且可運作的實際實作。 在幕後,每一個都儲存著一個具體的物件集,而在測試操作儲存庫時,會從中新增或移除物件。 有些開發者喜歡用這種方式來建立測試雙重模擬——用真實程式碼和可運作的實作。 我們稱這些測試替代品為假替身。 它們有可運作的實作,但還不夠真實,無法用於生產環境。 假倉庫其實並沒有寫入資料庫。 假的 SMTP 伺服器其實不會透過網路發送電子郵件。
模擬與假的
還有另一種稱為 mock 的測試替身。 雖然假模型有可運作的實作,但模擬模型則沒有實作。 利用模擬物件框架,我們在執行時建構這些模擬物件,並用作測試雙重元件。 在本節中,我們將使用開源的模擬框架 Moq。 這裡有一個簡單的範例,演示如何用 Moq 動態建立員工儲存庫的測試替身。
Mock<IRepository<Employee>> mock =
new Mock<IRepository<Employee>>();
IRepository<Employee> repository = mock.Object;
repository.Add(new Employee());
var employee = repository.FindById(1);
我們請 Moq 做一個 IRepository<Employee> 的實作,它會動態地建立一個。 我們可以透過存取 Mock<T> 物件的 Object 屬性,進入實作 IRepository<Employee> 的物件。 這是可以傳遞給我們控制器的內部物件,而控制器無法辨識這是測試替身還是真正的資料庫。 我們可以對物件呼叫方法,就像我們在具有實體實作的物件上呼叫方法一樣。
你一定很好奇當我們呼叫 Add 方法時,模擬倉庫會做什麼。 由於模擬物件背後沒有實作,Add 什麼都做不了。 不像我們寫假稿那樣,幕後沒有具體的募款,所以員工就被拋棄了。 那 FindById 的回傳值呢? 在這種情況下,模擬物件唯一能做的就是回傳預設值。 由於我們回傳的是參考型別(Employee),回傳值為空值。
模擬物件聽起來可能無用;不過,模擬物件還有兩個我們還沒提到的特性。 首先,Moq 框架記錄了在模擬物件上所做的所有呼叫。 在程式碼後面,我們可以問 Moq 是否有人呼叫了 Add 方法,或是有人呼叫了 FindById 方法。 我們稍後會看到如何在測試中使用這個「黑盒子」錄音功能。
第二個很棒的功能是我們可以用 Moq 來編程一個帶有 期望的模擬物件。 期望告訴模擬物件如何回應任何給定的互動。 例如,我們可以在模擬中編寫一個期望值,並告訴它在有人呼叫 FindById 時回傳一個員工物件。 Moq 框架使用 Setup API 和 lambda 表達式來編程這些期望。
[TestMethod]
public void MockSample() {
Mock<IRepository<Employee>> mock =
new Mock<IRepository<Employee>>();
mock.Setup(m => m.FindById(5))
.Returns(new Employee {Id = 5});
IRepository<Employee> repository = mock.Object;
var employee = repository.FindById(5);
Assert.IsTrue(employee.Id == 5);
}
在這個範例中,我們請 Moq 動態建置一個儲存庫,然後我們對該儲存庫設定預期行為。 當有人呼叫 FindById 方法,傳遞 5 的值時,期望會告訴模擬物件回傳一個 Id 值為 5 的新員工物件。 這個測試通過了,我們不需要建立完整的實作來偽造 IRepository<T>。
讓我們重新檢視之前寫的測驗,改用模擬測驗取代假測驗。 就像之前一樣,我們會用基底類別來設定所有控制器測試所需的共用基礎設施。
public class EmployeeControllerTestBase {
public EmployeeControllerTestBase() {
_employeeData = EmployeeObjectMother.CreateEmployees()
.AsQueryable();
_repository = new Mock<IRepository<Employee>>();
_unitOfWork = new Mock<IUnitOfWork>();
_unitOfWork.Setup(u => u.Employees)
.Returns(_repository.Object);
_controller = new EmployeeController(_unitOfWork.Object);
}
protected IQueryable<Employee> _employeeData;
protected Mock<IUnitOfWork> _unitOfWork;
protected EmployeeController _controller;
protected Mock<IRepository<Employee>> _repository;
}
設定程式碼大致相同。 我們不會用假物件,而是用 Moq 來建構模擬物件。 基底類別安排當程式碼呼叫 Employees 屬性時,模擬工作單元回傳一個模擬儲存庫。 其餘的模擬設置將在專門為每個特定情境設置的測試治具中進行。 例如,Index 動作的測試裝置會在動作呼叫 FindAll 方法時,設定模擬儲存庫回傳一份員工名單。
[TestClass]
public class EmployeeControllerIndexActionTests
: EmployeeControllerTestBase {
public EmployeeControllerIndexActionTests() {
_repository.Setup(r => r.FindAll())
.Returns(_employeeData);
}
// .. tests
[TestMethod]
public void ShouldBuildModelWithAllEmployees() {
var result = _controller.Index();
var model = result.ViewData.Model
as IEnumerable<Employee>;
Assert.IsTrue(model.Count() == _employeeData.Count());
}
// .. and more tests
}
除了期待之外,我們的考試看起來和之前的差不多。 然而,透過模擬框架的記錄能力,我們可以從不同的角度來進行測試。 我們將在下一節探討這個新觀點。
狀態與互動測試
你可以使用不同的技術來藉助模擬物件測試軟體。 一種方法是使用基於狀態的測試,這正是我們目前在本文中所做的。 基於狀態的測試會對軟體的狀態做出斷言。 在上一次測試中,我們對控制器啟動了一個動作方法,並對它應該建立的模型做出斷言。 以下是一些測試狀態的其他例子:
- 在 Create 執行後,請確認倉庫中包含新的 employee 物件。
- 驗證模型在 Index 執行後會保留所有員工的清單。
- 執行刪除後,確認儲存庫中沒有特定員工。
使用模擬物件時,您會看到另一種方法是驗證互動操作。 狀態式測試是對物件的狀態做出斷言,而基於互動的測試則是對物件如何互動做出斷言。 例如:
- 驗證控制器在執行 Create 時會呼叫儲存庫的 Add 方法。
- 驗證控制器在執行索引時會呼叫儲存庫的 FindAll 方法。
- 驗證控制器在執行編輯時,會呼叫工作單元的 Commit 方法來儲存變更。
互動測試通常需要較少測試資料,因為我們不需要深入檢視集合並驗證計數。 例如,如果我們知道 Details 動作會喚用一個儲存庫的 FindById 方法,且值正確,那麼該動作很可能是正常的。 我們可以在不設定任何用於FindById返回的測試資料的情況下驗證此行為。
[TestClass]
public class EmployeeControllerDetailsActionTests
: EmployeeControllerTestBase {
// ...
[TestMethod]
public void ShouldInvokeRepositoryToFindEmployee() {
var result = _controller.Details(_detailsId);
_repository.Verify(r => r.FindById(_detailsId));
}
int _detailsId = 1;
}
上述測試夾具中唯一需要的設定是基類所提供的設定。 當我們呼叫控制器動作時,Moq 會記錄與模擬資料庫的互動。 利用 Moq 的 Verify API,我們可以詢問 Moq 控制器是否以正確的 ID 值呼叫了 FindById。 如果控制器未呼叫該方法,或是以意外參數值呼叫該方法,Verify 方法會拋出例外,測試將失敗。
這裡有另一個範例,用以驗證 Create 動作是否在當前工作單元中喚起 Commit。
[TestMethod]
public void ShouldCommitUnitOfWork() {
_controller.Create(_newEmployee);
_unitOfWork.Verify(u => u.Commit());
}
互動測試的一個危險是過度指定互動的傾向。 模擬物件能夠記錄並驗證與模擬物件的每一次互動,並不代表測試應該嘗試驗證每一次互動。 有些互動是實作細節,你應該只驗證符合當前測試 所需的 互動。
模擬或假模擬的選擇很大程度取決於你測試的系統以及你個人(或團隊)的偏好。 模擬物件可以大幅減少實作測試替身所需的程式碼量,但並非每個人都習慣設定期望和驗證交互過程。
結論
本文展示了多種利用 ADO.NET Entity Framework 進行資料持久化時,建立可測試程式碼的方法。 我們可以利用內建的抽象,如 IObjectSet<T>,或自行建立像 IRepository<T>。 在這兩種情況下,ADO.NET 實體框架 4.0 中的 POCO 支援讓這些抽象的使用者能夠持續無知且高度可測試。 額外的 EF4 功能如隱含的懶惰載入,讓商業及應用服務程式碼能在不需擔心關聯式資料儲存細節的情況下運作。 最後,我們所創造的抽象在單元測試中容易被模擬或偽造,我們能利用這些測試雙重運算來達成快速執行、高度隔離且可靠的測試。
其他資源
- Martin Fowler,《企業應用架構模式目錄》
- 格里芬·卡皮歐,《 依賴注射》
- 資料可程式化部落格,「 攻略:以實體框架 4.0 進行測試驅動開發」。
- 資料可程式化部落格,「 利用 Entity Framework 4.0 使用資料庫與工作單元模式」
- Aaron Jensen,《 介紹機器規格》
- Eric Lee,《 BDD with MSTest》
- Eric Evans,《 領域驅動設計》
- 馬丁·福勒,《 模擬不是短截》
- 馬丁·福勒,《 測試替身》
- Moq
傳記
Scott Allen 是 Pluralsight 的技術團隊成員,也是 OdeToCode.com 的創辦人。 在15年的商業軟體開發中,Scott 曾參與從8位元嵌入式裝置到高度可擴展 ASP.NET 網路應用的解決方案。 你可以在他的部落格 OdeToCode 或 Twitter https://twitter.com/OdeToCode上聯絡他。