領域驅動設計之單元測試最佳實踐(二)
一直以來,我試圖找到一種有效的單元測試模式,使得“單元測試”真正能夠在團隊中流行起來,讓單元測試不再是走過場,而是讓單元測試切切實實成為提高代碼質量的途徑。
本文將描述一種以EF Code First模式實現的領域驅動項目實施單元測試的方案。
在描述這一方案之前,讓我們看看這一最佳實踐源於何種考慮和最終實現的目標:
1、以MVC項目為例,如果將單元測試的重心放在如何測試一個Controller或Action將收效甚微,原因有二:
- 從原則上講Controller中不包含業務邏輯,理論上大部分代碼都是ViewModel和DTO之間的賦值或者Service的調用,對這樣的代碼編寫單元測試收效甚微,性價比極低。
- Controller的代碼對UI的依賴度很高,也就意味着Controller的代碼不夠穩定,這將迫使單元測試的變化頻率過高,容易給開發人員造成單元測試是一種負擔的心理。
基於這樣的原因,我將不建議人手緊張的團隊對Controller編寫單元測試。
2、一個軟件項目真正需要測試的重心是業務邏輯,對一個領域驅動項目來說,領域邏輯才是重心。但是我們知道領域邏輯離不開數據的支撐,也就是說我們需要跟Repository打交道。
對於這樣的一個測試場景,大多數教程會提示你Mock Repository,從單元測試的角度來講,這樣的方案無疑是正確的,但是這樣的方案存在兩個問題:
- 實際經驗告訴我們這樣的測試不能真實的反應出代碼的問題,甚至出現單元測試是通過的,可是Debug起來卻有問題。原因在於我們忽略了數據庫部分,這一部分邏輯處於失控狀態。
- 需要Mock的數據太多,有時候為了測試一個邏輯,Mock的代碼比測試還要多,給開發人員造成單元測試其實就是在玩Mock的錯誤認識。
所以我心目中理想的單元測試應該具備以下條件:
- 測試從Service->Repository->Domain一條線測試完畢,測試能夠准確反應出代碼是如何運行的。所以准確來講我這個方案應該叫“領域驅動設計之集成測試”。
- 盡量不Mock,包括讀取數據庫部分。
- 測試需要的數據應該是可復用的,對測試“注冊用戶”、“搜索用戶”這樣的業務邏輯應該能夠復用測試所提供的數據。
- 任何測試都可以獨立運行,同一個測試多次執行的效果應該是一致的,測試的執行速度盡可能快。
為了能夠盡可能的貼近這一目標,我實現了一個很簡單的DDD案例用來做測試用,這一案例描述了兩個重要的領域模型:User領域模型描述了“注冊用戶”、“更改密碼”、“登錄”等邏輯;BookManageProcess領域模型描述了“借書”、“歸還圖書”等邏輯,你可以理解為這是一個圖書館借書及還書的模型。
為了能夠理解此測試方案,我將對該測試案例做一個簡單描述:
該案例基於EF Code First和Castle實現的一個DDD案例,這一測試方案也是為DDD量身定制,並不適合於傳統的三層架構。
為什么說這一案例是一個領域驅動案例?
以“用戶注冊”這一功能為例,我們來分析一下:
1、從UserService這一入口來看:
public class UserService : ApplicationService, IUserService
{
private readonly IUserRepository _userRepository;
private readonly IEmailUniqueChecker _emailUniqueChecker;
public UserService(IRepositoryContext context, IUserRepository userRepository,IEmailUniqueChecker emailUniqueChecker)
: base(context)
{
_userRepository = userRepository;
_emailUniqueChecker = emailUniqueChecker;
}
public Guid Register(UserModel userModel)
{
var user = User.Register(userModel,_emailUniqueChecker);
_userRepository.Add(user);
Context.Commit();
return user.Id;
}
}
Register()方法中幾乎只是對領域模型User.Register()方法的調用,其余的代碼都可以忽略不計,這說明了這樣一個事實:Service層沒有任何業務邏輯,所有的邏輯都應該在Domain。
2、User領域模型中Register()方法的實現:
public partial class User
{
public static User Register(UserModel userModel, IEmailUniqueChecker emailUniqueChecker)
{
Contract.Requires(!userModel.Name.IsNullOrEmpty(), "invalid username");
if (emailUniqueChecker.IsExist(userModel.Email))
{
throw new DuplicateEmailException("email already exist, please input another one");
}
var password=new Password(userModel.Password);
var user = new User()
{
Id = Guid.NewGuid(),
Name = userModel.Name,
Password = password.HashedPassword,
Salt = password.Salt,
Email = userModel.Email,
RegisterDateTime = DateTime.Now,
LastLoginDateTime = DateTime.Now
};
return user;
}
}
首先這是一個Patial類,因為另一部分描述屬性的內容被EF用來操作數據庫。這一方法主要存在兩個邏輯:
對Email的檢查,以及對password的加密處理,正如你所見:這些邏輯反應出了注冊一個用戶的實際邏輯是什么,而這些邏輯全部都應該歸屬於Domain。
由於在Domain中無法進行依賴注入,所以我們從Service層通過方法傳入了IEmailUniqueChecker組件,具體實現如下:
public class EmailUniqueChecker:IEmailUniqueChecker
{
private readonly IUserRepository _userRepository;
public EmailUniqueChecker(IUserRepository userRepository)
{
_userRepository = userRepository;
}
public bool IsExist(string email)
{
var user = _userRepository.Find(x => x.Email.ToLower() == email.ToLower()).FirstOrDefault();
return user != null;
}
}
而Password類測抽象了“密碼”的業務規則,同樣這一抽象應該屬於Domain,讓我們來看看他的部分實現:
public class Password
{
public byte[] HashedPassword { get; private set; }
public byte[] Salt { get; }
public Password(string password)
{
AssertPasswordMatchesPolicy(password);
Salt = Guid.NewGuid().ToByteArray();
HashedPassword = HashPassword(salt: Salt, password: password);
}
private void AssertPasswordMatchesPolicy(string password)
{
if (password == null)
{
var error = Seq.Create("password can not be null");
throw new PasswordDoesNotMatchPolicyException(error);
}
var errors = new List<string>();
if (password.Trim().Length < 6)
{
errors.Add("password shorter than six characters");
}
if (password.ToLower() == password)
{
errors.Add("password missing uppercase characters");
}
if (password.ToUpper() == password)
{
errors.Add("password missing lowercase characters");
}
if (errors.Any())
{
throw new PasswordDoesNotMatchPolicyException(errors);
}
}
}
如果不是由於Password類的存在,所有這些代碼都應該寫在User領域模型的Register()方法中。
繼續分析“用戶登錄”這一過程:
1、UserService中的入口:
public bool Login(string email, string password)
{
var user = _userRepository.Find(x => x.Email.ToLower() == email.ToLower()).FirstOrDefault();
if (user == null)
{
throw new ApplicationServiceException("no such user");
}
if (!user.Login(password))
{
return false;
}
_userRepository.Update(user);
Context.Commit();
return true;
}
第一部分代碼我們可以認為通過Email來獲取User領域模型,讀取到領域模型后調用user.Login()方法。這同樣說明了這樣一個事實:Service層沒有任何業務邏輯,所有的邏輯都應該在Domain。
2、User領域模型中的Login實現:
public bool Login(string password)
{
Contract.Requires(!password.IsNullOrEmpty(), "password can not be empty");
var hashedPassword = new Password(Password, Salt);
if (hashedPassword.IsCorrectPassword(password))
{
LastLoginDateTime = DateTime.Now;
return true;
}
return false;
}
正如你所見:這些邏輯反應出了一個用戶登錄的實際邏輯是什么,而這些邏輯全部都應該歸屬於Domain。
整個方案代碼提供下載:https://git.oschina.net/richieyangs/BookLibrary.git
