• 领域驱动设计之单元测试最佳实践


    领域驱动设计之单元测试最佳实践

    领域驱动设计之单元测试最佳实践(二)

    一直以来,我试图找到一种有效的单元测试模式,使得“单元测试”真正能够在团队中流行起来,让单元测试不再是走过场,而是让单元测试切切实实成为提高代码质量的途径。

    本文将描述一种以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量身定制,并不适合于传统的三层架构。

    正如解决方案的截图所示,这是一个非常简单的案例,我给他起了一个还算霸气的名字:MvcTests.BestPractice,至于为什么叫MvcTests,是因为该测试方案可以用在Mvc+DDD的架构中,但是由于对Controller编写测试的性价比极低,所以该方案中并为出现Controller的测试。

    为什么说这一案例是一个领域驱动案例?

    以“用户注册”这一功能为例,我们来分析一下:

    1、从UserService这一入口来看:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
        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()方法的实现:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
        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组件,具体实现如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    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,让我们来看看他的部分实现:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    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中的入口:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    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实现:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    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/MvcTests.BestPractice.git

     
    分类: .NET
  • 相关阅读:
    树上倍增求LCA(最近公共祖先)
    NOI题库分治算法刷题记录
    NOI题库刷题日志 (贪心篇题解)
    O(nlogn)LIS及LCS算法
    BLOG搬家
    『素数 Prime判定和线性欧拉筛法 The sieve of Euler』
    『扩展欧几里得算法 Extended Euclid』
    『NOIP2018普及组题解』
    P25、面试题1:赋值运算符函数
    字符串转成整数
  • 原文地址:https://www.cnblogs.com/Leo_wl/p/5455971.html
Copyright © 2020-2023  润新知