領域驅動設計之單元測試最佳實踐(一)

領域驅動設計之單元測試最佳實踐(二)

 

一直以來,我試圖找到一種有效的單元測試模式,使得「單元測試」真正可以在團隊中流行起來,讓單元測試再也不是走過場,而是讓單元測試切切實實成爲提升代碼質量的途徑。html

本文將描述一種以EF Code First模式實現的領域驅動項目實施單元測試的方案。git

在描述這一方案以前,讓咱們看看這一最佳實踐源於何種考慮和最終實現的目標:數據庫

一、以MVC項目爲例,若是將單元測試的重心放在如何測試一個Controller或Action將收效甚微,緣由有二:架構

  • 從原則上講Controller中不包含業務邏輯,理論上大部分代碼都是ViewModel和DTO之間的賦值或者Service的調用,對這樣的代碼編寫單元測試收效甚微,性價比極低。
  • Controller的代碼對UI的依賴度很高,也就意味着Controller的代碼不夠穩定,這將迫使單元測試的變化頻率太高,容易給開發人員形成單元測試是一種負擔的心理。

基於這樣的緣由,我將不建議人手緊張的團隊對Controller編寫單元測試。單元測試

二、一個軟件項目真正須要測試的重心是業務邏輯,對一個領域驅動項目來講,領域邏輯纔是重心。可是咱們知道領域邏輯離不開數據的支撐,也就是說咱們須要跟Repository打交道。測試

對於這樣的一個測試場景,大多數教程會提示你Mock Repository,從單元測試的角度來說,這樣的方案無疑是正確的,可是這樣的方案存在兩個問題:ui

  • 實際經驗告訴咱們這樣的測試不能真實的反應出代碼的問題,甚至出現單元測試是經過的,但是Debug起來卻有問題。緣由在於咱們忽略了數據庫部分,這一部分邏輯處於失控狀態。
  • 須要Mock的數據太多,有時候爲了測試一個邏輯,Mock的代碼比測試還要多,給開發人員形成單元測試其實就是在玩Mock的錯誤認識。

因此我心目中理想的單元測試應該具有如下條件:加密

  • 測試從Service->Repository->Domain一條線測試完畢,測試可以準確反應出代碼是如何運行的。因此準確來說我這個方案應該叫「領域驅動設計之集成測試」。
  • 儘可能不Mock,包括讀取數據庫部分。
  • 測試須要的數據應該是可複用的,對測試「註冊用戶」、「搜索用戶」這樣的業務邏輯應該可以複用測試所提供的數據。
  • 任何測試均可以獨立運行,同一個測試屢次執行的效果應該是一致的,測試的執行速度儘量快。

爲了可以儘量的貼近這一目標,我實現了一個很簡單的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

相關文章
相關標籤/搜索