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

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

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

本文将描述一种以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这一入口来看:

    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/MvcTests.BestPractice.git

时间: 2024-10-15 10:48:14

领域驱动设计之单元测试最佳实践(一)的相关文章

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

领域驱动设计之单元测试最佳实践(一) 介绍完了DDD案例,我们终于可以进入主题了,本方案的测试代码基于Xunit编写,断言组件采用了FluentAssertions,类似的组件还有Shouldly.另外本案例使用了Code Contracts for .NET,如果不安装此插件,可能有个别测试不能正确Pass. 为了实现目标中的第二点:"尽量不Mock,包括数据库读取部分”,我尝试过3种方案: 1.测试代码连接真实数据库,只需要将测试数据库配置到测试项目中的web.config中,即可达到这一目

我的“第一次”,就这样没了:DDD(领域驱动设计)理论结合实践

写在前面 插一句:本人超爱落网-<平凡的世界>这一期,分享给大家. 阅读目录: 关于DDD 前期分析 框架搭建 代码实现 开源-发布 后记 第一次听你,清风吹送,田野短笛:第一次看你,半弯新湖,鱼跃翠堤:第一次念你,燕飞巢冷,释怀记忆:第一次梦你,云翔海岛,轮渡迤逦:第一次认你,怨江别续,草桥知己:第一次怕你,命悬一线,遗憾禁忌:第一次悟你,千年菩提,生死一起. 人生有很多的第一次:小时候第一次牙牙学语.第一次学蹒跚学步...长大后第一次上课.第一次逃课.第一次骑自行车.第一次懂事.第一次和喜

《领域驱动设计的原则与实践》读书笔记(一)

   Chapter 1 什么是DDD: 1.介绍领域驱动设计思想体系 和传统开发方式比起来,领域驱动是一种新的软件架构设计,它主要用来解决传统开发中代码杂乱无章,任意拼贴等最终导致程序难以维护而诞生的. 它提出软件变得复杂和难以管理的主要原因是,领域复杂性和技术复杂性混合在了一起. 2.DDD如何管理复杂性 提炼问题重点.创建模型解决问题.使用公共语言建模协作.理解上下文关系. DDD的侧重点:核心领域.协作.与领域专家探讨.复杂域模型的上下文理解. 3.DDD常见误区 DDD是框架.DDD是

Re:从零开始的领域驱动设计

领域驱动的火爆程度不用我赘述,但是即便其如此得耳熟能详,但大多数人对其的认识,还只是停留在知道它的缩写是DDD,知道它是一种软件思想,或者知道它和微服务有千丝万缕的关系.Eric Evans对DDD的诠释是那么地惜字如金,而我所认识的领域驱动设计的专家又都是行业中的资深前辈,他们擅长于对软件设计进行高屋建瓴的论述,如果没有丰富的互联网从业经验,是不能从他们的分享中获取太多的营养的,可以用曲高和寡来形容.1000个互联网从业者,100个懂微服务,10个人懂领域驱动设计. 可能有很多和我一样的读者,

拨开迷雾,找回自我:DDD(领域驱动设计)应对具体业务场景,Domain Model(领域模型)到底如何设计?

写在前面 阅读目录: 迷雾森林 找回自我 开源地址 后记 毫无疑问,领域驱动设计的核心是领域模型,领域模型的核心是实现业务逻辑,也就是说,在应对具体的业务场景的时候,实现业务逻辑是领域驱动设计最重要的一环,在写这篇博文之前,先总结下之前关于 DDD(领域驱动设计)的三篇博文: 我的“第一次”,就这样没了:DDD(领域驱动设计)理论结合实践:伪领域驱动设计,只是用 .NET 实现的一个“空壳”,仅此而已. 一缕阳光:DDD(领域驱动设计)应对具体业务场景,如何聚焦 Domain Model(领域模

【系统架构理论】一篇文章搞掂:领域驱动设计

一.什么是领域驱动设计 1.1.面向业务的设计 当我们需要构建一个业务复杂的系统,我们不仅要从技术角度去构建一个稳健的系统,还要从业务角度出发,保证系统能满足业务需求. 架构设计的考虑点:不仅面向技术,更应该面向业务:面对不同的业务复杂度,选择的架构可能不同. 架构师的工作:面对复杂的业务逻辑,需要整合业务和技术才能很好地解决.业务架构驱动技术架构. 一个典型开发团队:新手.中级开发者.高级开发者/架构师(技术架构).领域专家/产品经理(业务架构).项目经理 要解决的问题:将复杂的业务架构梳理好

.NET领域驱动设计—实践(穿过迷雾走向光明)

阅读目录 开篇介绍 1.1示例介绍 (OnlineExamination在线考试系统介绍) 1.2分析.建模 (对真实业务进行分析.模型化) 1.2.1 用例分析 (提取系统的所有功能需求) 1.3系统设计.建模 (技术化业务模型) 1.3.1 枚举类型的使用 (别让枚举类型成为数值型对象) 1.3.2 基础数据.业务数据 (显示实体和隐式过程) 1.3.3 模型在数据库中的主外键关联问题 (面向对象模型与关系模型的天然抗阻) 1.3.4 角色.类型 (区分类型与面向对象概念) 1.3.5 名词

EntityFramework之领域驱动设计实践

EntityFramework之领域驱动设计实践 - 前言 EntityFramework之领域驱动设计实践 (一):从DataTable到EntityObject EntityFramework之领域驱动设计实践 (二):分层架构 EntityFramework之领域驱动设计实践 (三):案例:一个简易的销售系统 EntityFramework之领域驱动设计实践 (四):存储过程 - 领域驱动的反模式 EntityFramework之领域驱动设计实践 (五):聚合 EntityFramewor

(转)EntityFramework之领域驱动设计实践

EntityFramework之领域驱动设计实践 - 前言 EntityFramework之领域驱动设计实践 (一):从DataTable到EntityObject EntityFramework之领域驱动设计实践 (二):分层架构 EntityFramework之领域驱动设计实践 (三):案例:一个简易的销售系统 EntityFramework之领域驱动设计实践 (四):存储过程 - 领域驱动的反模式 EntityFramework之领域驱动设计实践 (五):聚合 EntityFramewor