一直以来,我试图找到一种有效的单元测试模式,使得“单元测试”真正可以在团队中流行起来,让单元测试再也不是走过场,而是让单元测试切切实实成为提升代码质量的途径。html
本文将描述一种以EF Code First模式实现的领域驱动项目实施单元测试的方案。git
在描述这一方案以前,让咱们看看这一最佳实践源于何种考虑和最终实现的目标:数据库
一、以MVC项目为例,若是将单元测试的重心放在如何测试一个Controller或Action将收效甚微,缘由有二:架构
基于这样的缘由,我将不建议人手紧张的团队对Controller编写单元测试。单元测试
二、一个软件项目真正须要测试的重心是业务逻辑,对一个领域驱动项目来讲,领域逻辑才是重心。可是咱们知道领域逻辑离不开数据的支撑,也就是说咱们须要跟Repository打交道。测试
对于这样的一个测试场景,大多数教程会提示你Mock Repository,从单元测试的角度来说,这样的方案无疑是正确的,可是这样的方案存在两个问题:ui
因此我心目中理想的单元测试应该具有如下条件:加密
为了可以尽量的贴近这一目标,我实现了一个很简单的DDD案例用来作测试用,这一案例描述了两个重要的领域模型:User领域模型描述了“注册用户”、“更改密码”、“登陆”等逻辑;BookManageProcess领域模型描述了“借书”、“归还图书”等逻辑,你能够理解为这是一个图书馆借书及还书的模型。.net
为了可以理解此测试方案,我将对该测试案例作一个简单描述:设计
该案例基于EF Code First和Castle实现的一个DDD案例,这一测试方案也是为DDD量身定制,并不适合于传统的三层架构。
为何说这一案例是一个领域驱动案例?
以“用户注册”这一功能为例,咱们来分析一下:
一、从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。
二、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()方法中。
继续分析“用户登陆”这一过程:
一、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。
二、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