一、前言
二、需求说明
三、项目结构
四、开发准备
(一) 应用代码准备
(二) 测试类准备
(三) TDD正式开始
五、总结
六、源码下载
七、参考资料
最近团队要尝试TDD(测试驱动开发)的实践,很多人习惯了先代码后测试的流程,对于TDD总心存恐惧,认为没有代码的情况下写测试代码时被架空了,没法写下来,其实,根据个人实践经验,TDD并不可怕,还很可爱,只要你真正去实践了几十个测试用例之后,你会爱上这种开发方式的。微软对于TDD的开发方式是大力支持和推荐的,新发布的VS2012的团队模板就是根据。新的Visual Studio 2012给我们带来了Fakes框架,这是一个针对代码测试时对测试的外界依赖(如数据库,文件等)进行模拟的Mock框架,用上了之后,我立即从Moq的阵营中叛变了^_^。截止到写此文的时间,网上还没有一篇关于Fakes框架的文章(除了“VS11将拥有更好的单元测试工具和Fakes框架”这篇介绍性的之外),就让我们来慢慢摸索着用吧。废话少说,下面我们就来一步一步的使用Visual Studio 2012的Fakes框架来实战一把TDD。
我们要做的是一个普通的用户注册中“检查用户名是否存在”的功能,需求如下:
先分解一下项目的结构,还是传统的三层结构,从底层到上层:
其他的项目与测试无关,略过。
Entity:实体类的通用数据结构
1 /// <summary> 2 /// 数据实体类基类,定义数据库存储的数据结构的通用部分 3 /// </summary> 4 public abstract class Entity 5 { 6 /// <summary> 7 /// 编号 8 /// </summary> 9 public int Id { get; set; } 10 11 /// <summary> 12 /// 是否逻辑删除(相当于回收站,非物理删除) 13 /// </summary> 14 public bool IsDelete { get; set; } 15 16 /// <summary> 17 /// 添加时间 18 /// </summary> 19 public DateTime AddDate { get; set; } 20 }
IRepository:通用数据访问接口,简单起见,只写了几个增删改查的接口
1 /// <summary> 2 /// 定义仓储模式中的数据标准操作,其实现类是仓储类型。 3 /// </summary> 4 /// <typeparam name="TEntity">要实现仓储的类型</typeparam> 5 public interface IRepository<TEntity> where TEntity : Entity 6 { 7 #region 公用方法 8 9 /// <summary> 10 /// 插入实体记录 11 /// </summary> 12 /// <param name="entity"> 实体对象 </param> 13 /// <param name="isSave"> 是否执行保存 </param> 14 /// <returns> 操作影响的行数 </returns> 15 int Insert(TEntity entity, bool isSave = true); 16 17 /// <summary> 18 /// 删除实体记录 19 /// </summary> 20 /// <param name="entity"> 实体对象 </param> 21 /// <param name="isSave"> 是否执行保存 </param> 22 /// <returns> 操作影响的行数 </returns> 23 int Delete(TEntity entity, bool isSave = true); 24 25 /// <summary> 26 /// 更新实体记录 27 /// </summary> 28 /// <param name="entity"> 实体对象 </param> 29 /// <param name="isSave"> 是否执行保存 </param> 30 /// <returns> 操作影响的行数 </returns> 31 int Update(TEntity entity, bool isSave = true); 32 33 /// <summary> 34 /// 提交当前的Unit Of Work事务,作用与 IUnitOfWork.Commit() 相同。 35 /// </summary> 36 /// <returns>提交事务影响的行数</returns> 37 int Commit(); 38 39 /// <summary> 40 /// 查找指定编号的实体记录 41 /// </summary> 42 /// <param name="id"> 指定编号 </param> 43 /// <returns> 符合编号的记录,不存在返回null </returns> 44 TEntity GetById(object id); 45 46 /// <summary> 47 /// 查找指定名称的实体记录,注意:如实体无名称属性则不支持 48 /// </summary> 49 /// <param name="name">名称</param> 50 /// <returns>符合名称的记录,不存在则返回null</returns> 51 /// <exception cref="NotSupportedException">当对应实体无名称时引发将引发异常</exception> 52 TEntity GetByName(string name); 53 54 #endregion 55 }
Member:实体类——用户信息
1 /// <summary> 2 /// 实体类——用户信息 3 /// </summary> 4 public class Member : Entity 5 { 6 public string UserName { get; set; } 7 8 public string Password { get; set; } 9 10 public string Email { get; set; } 11 }
MemberInactive:实体类——未激活用户信息
1 /// <summary> 2 /// 实体类——未激活用户信息 3 /// </summary> 4 public class MemberInactive : Entity 5 { 6 public string UserName { get; set; } 7 8 public string Password { get; set; } 9 10 public string Email { get; set; } 11 }
ConfigInfo:实体类——系统配置信息
1 /// <summary> 2 /// 实体类——系统配置信息 3 /// </summary> 4 public class ConfigInfo : Entity 5 { 6 public ConfigInfo() 7 { 8 RegisterConfig = new RegisterConfig(); 9 } 10 11 public RegisterConfig RegisterConfig { get; set; } 12 } 13 14 15 public class RegisterConfig 16 { 17 /// <summary> 18 /// 注册时是否需要Email激活 19 /// </summary> 20 public bool NeedActive { get; set; } 21 22 /// <summary> 23 /// 激活邮件有效期,单位:分钟 24 /// </summary> 25 public int ActiveTimeout { get; set; } 26 27 /// <summary> 28 /// 允许同一Email注册不同会员 29 /// </summary> 30 public bool EmailRepeat { get; set; } 31 }
IMemberDao:数据访问接口——用户信息,仅添加IRepository不满足的接口
1 /// <summary> 2 /// 数据访问接口——用户信息 3 /// </summary> 4 public interface IMemberDao : IRepository<Member> 5 { 6 /// <summary> 7 /// 由电子邮箱查找用户信息 8 /// </summary> 9 /// <param name="email"> 电子邮箱地址 </param> 10 /// <returns> </returns> 11 IEnumerable<Member> GetByEmail(string email); 12 }
IMemberInactiveDao:数据访问接口——未激活用户信息,仅添加IRepository不满足的接口
1 /// <summary> 2 /// 数据访问接口——未激活用户信息 3 /// </summary> 4 public interface IMemberInactiveDao : IRepository<MemberInactive> 5 { 6 /// <summary> 7 /// 由电子邮箱获取未激活的用户信息 8 /// </summary> 9 /// <param name="email"> 电子邮箱地址 </param> 10 /// <returns> </returns> 11 IEnumerable<MemberInactive> GetByEmail(string email); 12 }
IConfigInfoDao:数据访问接口——系统配置,无额外需求的接口,所以为空接口
1 /// <summary> 2 /// 数据访问接口——系统配置信息 3 /// </summary> 4 public interface IConfigInfoDao : IRepository<ConfigInfo> 5 { }
IAccountContract:账户模块业务契约——定义了三个操作,用作注册前的数据检查和注册提交
1 /// <summary> 2 /// 核心业务契约——账户模块 3 /// </summary> 4 public interface IAccountContract 5 { 6 /// <summary> 7 /// 用户名重复检查 8 /// </summary> 9 /// <param name="userName">用户名</param> 10 /// <param name="configName">系统配置名称</param> 11 /// <returns></returns> 12 bool UserNameExistsCheck(string userName, string configName); 13 14 /// <summary> 15 /// 电子邮箱重复检查 16 /// </summary> 17 /// <param name="email">电子邮箱</param> 18 /// <param name="configName">系统配置名称</param> 19 /// <returns></returns> 20 bool EmailExistsCheck(string email, string configName); 21 22 /// <summary> 23 /// 用户注册 24 /// </summary> 25 /// <param name="model">注册信息模型</param> 26 /// <param name="configName">系统配置名称</param> 27 /// <returns></returns> 28 RegisterResults Register(Member model, string configName); 29 }
以上代码本来想收起来的,但测试时代码展开老失效,所以辛苦大家划了那麽长的鼠标来看下面的正题了\(^o^)/
1 <Fakes xmlns="http://schemas.microsoft.com/fakes/2011/"> 2 <Assembly Name="Liuliu.Demo.Core"/> 3 </Fakes>
这个配置默认会把测试程序集中的所有接口、类都生成模拟类,当然也可以配置生成指定的类型的模拟,相关知识这里就不讲了,请参阅官方文档:Microsoft Fakes 中的代码生成、编译和命名约定
1 [TestMethod] 2 public void UserNameExistsCheck_用户名不存在() 3 { 4 var userName = "柳柳英侠"; 5 var configName = "configName"; 6 var accountService = new AccountService(); 7 Assert.IsFalse(accountService.UserNameExistsCheck(userName, configName)); 8 }
当然,此时运行测试是编译不过的,因为AccountService类根本还没有创建。在Liuliu.Demo.Core.Business.Impl文件夹下添加AccountService类,并实现IAccountContract接口
1 /// <summary> 2 /// 账户模块业务实现类 3 /// </summary> 4 public class AccountService : IAccountContract 5 { 6 /// <summary> 7 /// 用户名重复检查 8 /// </summary> 9 /// <param name="userName">用户名</param> 10 /// <param name="configName">系统配置名称</param> 11 /// <returns></returns> 12 public bool UserNameExistsCheck(string userName, string configName) 13 { 14 throw new NotImplementedException(); 15 } 16 17 /// <summary> 18 /// 电子邮箱重复检查 19 /// </summary> 20 /// <param name="email">电子邮箱</param> 21 /// <param name="configName">系统配置名称</param> 22 /// <returns></returns> 23 public bool EmailExistsCheck(string email, string configName) 24 { 25 throw new NotImplementedException(); 26 } 27 28 /// <summary> 29 /// 用户注册 30 /// </summary> 31 /// <param name="model">注册信息模型</param> 32 /// <param name="configName">系统配置名称</param> 33 /// <returns></returns> 34 public RegisterResults Register(Member model, string configName) 35 { 36 throw new NotImplementedException(); 37 } 38 }
再次运行测试,是通不过,TDD的基本做法就是让测试尽快通过,所以修改方法UserNameExistsCheck为如下:
1 /// <summary> 2 /// 用户名重复检查 3 /// </summary> 4 /// <param name="userName">用户名</param> 5 /// <param name="configName">系统配置名称</param> 6 /// <returns></returns> 7 public bool UserNameExistsCheck(string userName, string configName) 8 { 9 return false; 10 }
再次运行测试用例,红叉终于变成绿勾了,我敢打赌,如果你真正实践TDD的话,绿色将是你一定会喜欢的颜色
参数的字符串,值的有效性一定要检查的,所以添加以下两个测试用例,通过ExpectedException特性可能确定抛出异常的类型
1 [TestMethod] 2 [ExpectedException(typeof(ArgumentNullException))] 3 public void UserNameExistsCheck_引发ArgumentNullException异常_参数userName为空() 4 { 5 string userName = null; 6 var configName = "configName"; 7 var accountService = new AccountService(); 8 accountService.UserNameExistsCheck(userName, configName); 9 } 10 11 [TestMethod] 12 [ExpectedException(typeof(ArgumentNullException))] 13 public void UserNameExistsCheck_引发ArgumentNullException异常_参数configName为空() 14 { 15 var userName = "柳柳英侠"; 16 string configName = null; 17 var accountService = new AccountService(); 18 accountService.UserNameExistsCheck(userName, configName); 19 }
运行测试,结果如下,原因为还没有写异常代码,期望的异常没有引发。└(^o^)┘平常我们很怕出异常,现在要去期望出异常
1 public bool UserNameExistsCheck(string userName, string configName) 2 { 3 if (string.IsNullOrEmpty(userName)) 4 { 5 throw new ArgumentNullException("userName"); 6 } 7 if (string.IsNullOrEmpty(configName)) 8 { 9 throw new ArgumentNullException("configName"); 10 } 11 return false; 12 }
给AccountService类添加如下属性,以便在接下来的操作中能模拟调用数据访问层的操作
1 #region 属性 2 3 /// <summary> 4 /// 获取或设置 数据访问对象——用户信息 5 /// </summary> 6 public IMemberDao MemberDao { get; set; } 7 8 /// <summary> 9 /// 获取或设置 数据访问对象——未激活用户信息 10 /// </summary> 11 public IMemberInactiveDao MemberInactiveDao { get; set; } 12 13 /// <summary> 14 /// 获取或设置 数据访问对象——系统配置信息 15 /// </summary> 16 public IConfigInfoDao ConfigInfoDao { get; set; } 17 18 #endregion
接下来该进行用户名存在的判断了,即为在用户信息数据库中(MemberDao)存在相同用户名的用户信息,在这里的查询实际并不是到数据库中查询,而是通过Fakes框架生成的模拟类模拟出一个查询过程与获得查询结果。添加的测试用例如下:
1 [TestMethod] 2 public void UserNameExistsCheck_用户名存在_该用户名在用户数据库中已存在记录() 3 { 4 var userName = "柳柳英侠"; 5 var configName = "configName"; 6 var accountService = new AccountService(); 7 var memberDao = new StubIMemberDao(); 8 memberDao.GetByNameString = str => new Member(); 9 accountService.MemberDao = memberDao; 10 Assert.IsTrue(accountService.UserNameExistsCheck(userName, configName)); 11 }
StubIMemberDao类即为Fakes框架由IMemberDao接口生成的一个模拟类,第7行实例化了一个该类的对象, 这个对象有一个委托类型的字段GetByNameString开放出来,我们就可以通过这个字段给接口的GetByName方法赋一个执行结果,即第8行的操作。再把这个对象赋给AccountService类中的IMemberDao类型的属性(第9行),即相当于给AccountService类添加了一个操作用户信息数据层的实现。
修改UserNameExistsCheck方法使测试通过
1 public bool UserNameExistsCheck(string userName, string configName) 2 { 3 if (string.IsNullOrEmpty(userName)) 4 { 5 throw new ArgumentNullException("userName"); 6 } 7 if (string.IsNullOrEmpty(configName)) 8 { 9 throw new ArgumentNullException("configName"); 10 } 11 var member = MemberDao.GetByName(userName); 12 if (member != null) 13 { 14 return true; 15 } 16 return false; 17 }
运行测试,上面这个测试通过了,但第一个测试却失败了。
这不合乎TDD的要求了,TDD要求后面添加的功能不能影响原来的功能。看代码实现是没有问题的,看来问题是出在测试用例上。
当我们走到“UserNameExistsCheck_用户名存在_该用户名在用户数据库中已存在记录”这个测试用例的时候,添加了一些属性,而这些属性在第一个测试用例“UserNameExistsCheck_用户名不存在”并没有进行初始化,所以报了一个NullReferenceException异常。
接下来我们来优化测试类的结构来解决这些问题:
a. 每个测试用例的先决条件都要从0开始初始化,太麻烦
b. 测试环境没有初始化,新增条件会影响到旧的测试用例的运行
1 #region 字段 2 3 private readonly AccountService _accountService = new AccountService(); 4 private readonly StubIMemberDao _memberDao = new StubIMemberDao(); 5 private readonly StubIMemberInactiveDao _memberInactiveDao = new StubIMemberInactiveDao(); 6 private readonly StubIConfigInfoDao _configInfoDao = new StubIConfigInfoDao(); 7 8 private int _num = 1; 9 private Member _member = new Member(); 10 private readonly List<Member> _memberList = new List<Member>(); 11 private MemberInactive _memberInactive = new MemberInactive(); 12 private readonly List<MemberInactive> _memberInactiveList = new List<MemberInactive>(); 13 private ConfigInfo _configInfo = new ConfigInfo(); 14 15 #endregion
1 // 在运行每个测试之前,使用 TestInitialize 来运行代码 2 [TestInitialize()] 3 public void MyTestInitialize() 4 { 5 _memberDao.Commit = () => _num; 6 _memberDao.DeleteMemberBoolean = (@member, @bool) => _num; 7 _memberDao.GetByEmailString = @string => _memberList; 8 _memberDao.GetByIdObject = @id => _member; 9 _memberDao.GetByNameString = @string => _member; 10 _memberDao.InsertMemberBoolean = (@member, @bool) => _num; 11 _accountService.MemberDao = _memberDao; 12 13 _memberInactiveDao.Commit = () => _num; 14 _memberInactiveDao.DeleteMemberInactiveBoolean = (@memberInactive, @bool) => _num; 15 _memberInactiveDao.GetByEmailString = @string => _memberInactiveList; 16 _memberInactiveDao.GetByIdObject = @id => _memberInactive; 17 _memberInactiveDao.GetByNameString = @string => _memberInactive; 18 _memberInactiveDao.InsertMemberInactiveBoolean = (@memberInactive, @bool) => _num; 19 _accountService.MemberInactiveDao = _memberInactiveDao; 20 21 _configInfoDao.Commit = () => _num; 22 _configInfoDao.DeleteConfigInfoBoolean = (@configInfo, @bool) => _num; 23 _configInfoDao.GetByIdObject = @id => _configInfo; 24 _configInfoDao.GetByNameString = @string => _configInfo; 25 _configInfoDao.InsertConfigInfoBoolean = (@configInfo, @bool) => _num; 26 _accountService.ConfigInfoDao = _configInfoDao; 27 28 }
有了初始化以后,原来的测试用例就可以如此的简单,只需要初始化不成立的条件即可
1 #region UserNameExistsCheck 2 [TestMethod] 3 public void UserNameExistsCheck_用户名不存在() 4 { 5 var userName = "柳柳英侠"; 6 var configName = "configName"; 7 _member = null; 8 Assert.IsFalse(_accountService.UserNameExistsCheck(userName, configName)); 9 } 10 11 [TestMethod] 12 [ExpectedException(typeof(ArgumentNullException))] 13 public void UserNameExistsCheck_引发ArgumentNullException异常_参数userName为空() 14 { 15 string userName = null; 16 var configName = "configName"; 17 _accountService.UserNameExistsCheck(userName, configName); 18 } 19 20 [TestMethod] 21 [ExpectedException(typeof(ArgumentNullException))] 22 public void UserNameExistsCheck_引发ArgumentNullException异常_参数configName为空() 23 { 24 var userName = "柳柳英侠"; 25 string configName = null; 26 _accountService.UserNameExistsCheck(userName, configName); 27 } 28 29 [TestMethod] 30 public void UserNameExistsCheck_用户名存在_该用户名在用户数据库中已存在记录() 31 { 32 var userName = "柳柳英侠"; 33 var configName = "configName"; 34 Assert.IsTrue(_accountService.UserNameExistsCheck(userName, configName)); 35 } 36 37 #endregion
所有条件都初始化好了,继续研究需求,就可以把测试用例的所有情况都写出来
1 [TestMethod] 2 [ExpectedException(typeof(NullReferenceException))] 3 public void UserNameExistsCheck_引发NullReferenceException异常_系统配置信息无法找到() 4 { 5 var userName = "柳柳英侠"; 6 var configName = "configName"; 7 _member = null; 8 _configInfo = null; 9 _accountService.UserNameExistsCheck(userName, configName); 10 } 11 12 [TestMethod] 13 public void UserNameExistsCheck_用户不存在_用户在用户数据库中不存在_and_注册不需要激活() 14 { 15 var userName = "柳柳英侠"; 16 var configName = "configName"; 17 _member = null; 18 _configInfo.RegisterConfig.NeedActive = false; 19 Assert.IsFalse(_accountService.UserNameExistsCheck(userName, configName)); 20 } 21 22 [TestMethod] 23 public void UserNameExistsCheck_用户不存在_用户在用户数据库中不存在_and_注册需要激活_and_用户名在未激活用户数据库中不存在() 24 { 25 var userName = "柳柳英侠"; 26 var configName = "configName"; 27 _member = null; 28 _configInfo.RegisterConfig.NeedActive = true; 29 _memberInactive = null; 30 Assert.IsFalse(_accountService.UserNameExistsCheck(userName, configName)); 31 }
编写代码让测试通过
1 public bool UserNameExistsCheck(string userName, string configName) 2 { 3 if (string.IsNullOrEmpty(userName)) 4 { 5 throw new ArgumentNullException("userName"); 6 } 7 if (string.IsNullOrEmpty(configName)) 8 { 9 throw new ArgumentNullException("configName"); 10 } 11 var member = MemberDao.GetByName(userName); 12 if (member != null) 13 { 14 return true; 15 } 16 var configInfo = ConfigInfoDao.GetByName(configName); 17 if (configInfo == null) 18 { 19 throw new NullReferenceException("系统配置信息为空。"); 20 } 21 if (!configInfo.RegisterConfig.NeedActive) 22 { 23 return false; 24 } 25 var memberInactive = MemberInactiveDao.GetByName(userName); 26 if (memberInactive != null) 27 { 28 return true; 29 } 30 return false; 31 }
看起来文章写得挺长了,其实内容并没有多少,篇幅都被代码拉开了。我们来总结一下使用Fakes框架进行TDD开发的步骤:
另外有几点经验之谈:
本篇只对底层的接口进行了模拟,在下篇将对测试类中的私有方法,静态方法等进行模拟,敬请期待^_^o~ 努力!
1.Microsoft Fakes 中的代码生成、编译和命名约定:
http://msdn.microsoft.com/zh-cn/library/hh708916
2.使用存根隔离对单元测试方法中虚拟函数的调用
http://msdn.microsoft.com/zh-cn/library/hh549174
3.使用填充码隔离对单元测试方法中非虚拟函数的调用
http://msdn.microsoft.com/zh-cn/library/hh549176