1、前言
最近团队要尝试TDD(测试驱动开发)的实践,不少人习惯了先代码后测试的流程,对于TDD总心存恐惧,认为没有代码的状况下写测试代码时被架空了,无法写下来,其实,根据我的实践经验,TDD并不可怕,还很可爱,只要你真正去实践了几十个测试用例以后,你会爱上这种形式方式的。微软对于TDD的开发方式是大力支持和推荐的,新发布的VS2012的团队模板就是根据。新的Visual Studio 2012给咱们带来了Fakes框架,这是一个针对代码测试时对测试的外界依赖(如数据库,文件等)进行模拟的Mock框架,用上了以后,我当即从Moq的阵营中叛变了^_^。截止到写此文的时间,网上尚未一篇关于Fakes框架的文章(除了“VS11将拥有更好的单元测试工具和Fakes框架”这篇介绍性的以外),就让咱们来慢慢摸索着用吧。废话少说,下面咱们就来一步一步的使用Visual Studio 2012的Fakes框架来实战一把TDD。数据库
2、需求说明
咱们要作的是一个普通的用户注册中“检查用户名是否存在”的功能,需求以下:数据结构
- 用户名不能重复
- 可设置是否启用邮件激活,若是不启用邮件激活,则直接在“正式用户信息表”中检查,反之则还要进入“未激活用户信息表”中进行查询
3、项目结构

先分解一下项目的结构,仍是传统的三层结构,从底层到上层:框架
- Liuliu.Components.Tools:通用工具组件
- Liuliu.Components.Data:通用数据访问组件,目前只定义了一个数据访问接口的通用基接口IRepository
- Liuliu.Demo.Core.Models:数据实体类,分两个模块,帐户模块(Account)与通用模块(Common)
- Liuliu.Demo.Core:业务核心层,里面包含Business与DataAccess两个子层,DataAccess实现实体类的数据访问,Business层实现模块的业务逻辑,由于测试的过程当中数据访问层的数据库实现会用Fakes框架来模拟,因此数据访问层只提供了接口,不提供实现,Business只调用了DataAccess的接口。咱们要作的工做就是用Fakes框架来模拟数据访问层,用TDD的方式来编写Business中的业务实现
- Liuliu.Demo.Core.Business.UnitTest:单元测试项目,存放着测试Business实现的测试用例。
- Liuliu.Demo.Consoles:用户操做控制台,功能实现后进行用户操做的UI项目
其余的项目与测试无关,略过。函数
4、开发准备
(一) 应用代码准备
Entity:实体类的通用数据结构工具
-
-
-
- public abstract class Entity
- {
-
-
-
- public int Id { get; set; }
-
-
-
-
- public bool IsDelete { get; set; }
-
-
-
-
- public DateTime AddDate { get; set; }
- }
-
IRepository:通用数据访问接口,简单起见,只写了几个增删改查的接口单元测试
-
-
-
-
- public interface IRepository<TEntity> where TEntity : Entity
- {
- #region 公用方法
-
-
-
-
-
-
-
- int Insert(TEntity entity, bool isSave = true);
-
-
-
-
-
-
-
- int Delete(TEntity entity, bool isSave = true);
-
-
-
-
-
-
-
- int Update(TEntity entity, bool isSave = true);
-
-
-
-
-
- int Commit();
-
-
-
-
-
-
- TEntity GetById(object id);
-
-
-
-
-
-
-
- TEntity GetByName(string name);
-
- #endregion
- }
Member:实体类——用户信息测试
-
-
-
- public class Member : Entity
- {
- public string UserName { get; set; }
-
- public string Password { get; set; }
-
- public string Email { get; set; }
- }
MemberInactive:实体类——未激活用户信息优化
-
-
-
- public class MemberInactive : Entity
- {
- public string UserName { get; set; }
-
- public string Password { get; set; }
-
- public string Email { get; set; }
- }
ConfigInfo:实体类——系统配置信息spa
-
-
-
- public class ConfigInfo : Entity
- {
- public ConfigInfo()
- {
- RegisterConfig = new RegisterConfig();
- }
-
- public RegisterConfig RegisterConfig { get; set; }
- }
-
-
- public class RegisterConfig
- {
-
-
-
- public bool NeedActive { get; set; }
-
-
-
-
- public int ActiveTimeout { get; set; }
-
-
-
-
- public bool EmailRepeat { get; set; }
- }
-
IMemberDao:数据访问接口——用户信息,仅添加IRepository不知足的接口翻译
-
-
-
- public interface IMemberDao : IRepository<Member>
- {
-
-
-
-
-
- IEnumerable<Member> GetByEmail(string email);
- }
IMemberInactiveDao:数据访问接口——未激活用户信息,仅添加IRepository不知足的接口
-
-
-
- public interface IMemberInactiveDao : IRepository<MemberInactive>
- {
-
-
-
-
-
- IEnumerable<MemberInactive> GetByEmail(string email);
- }
IConfigInfoDao:数据访问接口——系统配置,无额外需求的接口,因此为空接口
-
-
-
- public interface IConfigInfoDao : IRepository<ConfigInfo>
- { }
IAccountContract:帐户模块业务契约——定义了三个操做,用做注册前的数据检查和注册提交
-
-
-
- public interface IAccountContract
- {
-
-
-
-
-
-
- bool UserNameExistsCheck(string userName, string configName);
-
-
-
-
-
-
-
- bool EmailExistsCheck(string email, string configName);
-
-
-
-
-
-
-
- RegisterResults Register(Member model, string configName);
- }
以上代码原本想收起来的,但测试时代码展开老失效,因此辛苦你们划了那麽长的鼠标来看下面的正题了\(^o^)/
(二) 测试类准备
- 添加测试项目的引用

- 添加要模拟实现接口的Fakes程序集,要模拟的接口在Liuliu.Demo.Core程序集中,因此在该程序集上点右键,选择“添加Fakes程序集”菜单项

- 添加好了以后,Fakes框架会在测试项目中添加一个Fakes文件夹和一个配置文件,并自动生成引用一个 模拟程序集.Fakes 的程序集和Fakes框架的运行环境Microsoft.QualityTools.Testing.Fakes

- 打开对象查看器,可看到生成的Fakes程序集的内容,全部的接口都生成了一个对应的模拟类

- 经过ILSpy对Fakes程序集进行反向,能够看到生成的模拟类以下所示,StubIMemberDao实现了接口IMemberDao,而接口中的公共成员都生成了“方法名+参数类型名”的委托模拟,用以接收外部给模拟方法的执行结果赋值,这样每一个方法的返回值均可以被控制

- 另外生成的Fakes文件夹中的配置文件Liuliu.Demo.Core.fakes内容以下所示
1 <Fakes xmlns="http://schemas.microsoft.com/fakes/2011/">
2 <Assembly Name="Liuliu.Demo.Core"/>
3 </Fakes>
这个配置默认会把测试程序集中的全部接口、类都生成模拟类,固然也能够配置生成指定的类型的模拟,相关知识这里就不讲了,请参阅官方文档:Microsoft Fakes 中的代码生成、编译和命名约定
- 须要特别说明的是,每次生成,Fakes程序集都会从新生成,因此测试类有更改后想刷新Fakes程序集,只须要把原来的程序集删除再进行生成,或者在测试项目能编译的时候从新编译测试项目便可。
(三) TDD正式开始
- 给测试项目添加一个单元测试类文件,添加新项 -> Visual C#项 -> 测试 -> 单元测试,命名为AccountServiceTest.cs,推荐命名方式为“测试类名+Test”的方式
- 添加一个测试方法,关于测试方法的命名,各人有各人的方案,这里推荐一种方案:“测试方法名_执行结果_获得此结果的条件/缘由”,而且测试方法是可使用中文的,好比“UserNameExistsCheck_用户名已存在_用户名在用户信息表中已存在记录”,这种方式好不少好处,特别是团队成员英文水平不太好的时候,若是翻译成英文的方式,颇有可能会不知所云,而且中文与需求文档一一对应,很是明了,如下的测试用例中都会运用这种方式,若是不适应请在脑中自行翻译\(^o^)/,创建测试方法以下:
- [TestMethod]
- public void UserNameExistsCheck_用户名不存在()
- {
- var userName = "柳柳英侠";
- var configName = "configName";
- var accountService = new AccountService();
- Assert.IsFalse(accountService.UserNameExistsCheck(userName, configName));
- }
固然,此时运行测试是编译不过的,由于AccountService类根本尚未建立。在Liuliu.Demo.Core.Business.Impl文件夹下添加AccountService类,并实现IAccountContract接口
-
-
-
- public class AccountService : IAccountContract
- {
-
-
-
-
-
-
- public bool UserNameExistsCheck(string userName, string configName)
- {
- throw new NotImplementedException();
- }
-
-
-
-
-
-
-
- public bool EmailExistsCheck(string email, string configName)
- {
- throw new NotImplementedException();
- }
-
-
-
-
-
-
-
- public RegisterResults Register(Member model, string configName)
- {
- throw new NotImplementedException();
- }
- }
再次运行测试,是通不过,TDD的基本作法就是让测试尽快经过,因此修改方法UserNameExistsCheck为以下:
-
-
-
-
-
-
- public bool UserNameExistsCheck(string userName, string configName)
- {
- return false;
- }
再次运行测试用例,红叉终于变成绿勾了,我敢打赌,若是你真正实践TDD的话,绿色将是你必定会喜欢的颜色

参数的字符串,值的有效性必定要检查的,因此添加如下两个测试用例,经过ExpectedException特性可能肯定抛出异常的类型
-
运行测试,结果以下,缘由为尚未写异常代码,指望的异常没有引起。└(^o^)┘日常咱们很怕出异常,如今要去指望出异常

异常代码编写很简单,修改成以下便可经过:
- public bool UserNameExistsCheck(string userName, string configName)
- {
- if (string.IsNullOrEmpty(userName))
- {
- throw new ArgumentNullException("userName");
- }
- if (string.IsNullOrEmpty(configName))
- {
- throw new ArgumentNullException("configName");
- }
- return false;
- }
给AccountService类添加以下属性,以便在接下来的操做中能模拟调用数据访问层的操做
- #region 属性
-
-
-
-
- public IMemberDao MemberDao { get; set; }
-
-
-
-
- public IMemberInactiveDao MemberInactiveDao { get; set; }
-
-
-
-
- public IConfigInfoDao ConfigInfoDao { get; set; }
-
- #endregion
接下来该进行用户名存在的判断了,即为在用户信息数据库中(MemberDao)存在相同用户名的用户信息,在这里的查询实际并非到数据库中查询,而是经过Fakes框架生成的模拟类模拟出一个查询过程与得到查询结果。添加的测试用例以下:
- [TestMethod]
- public void UserNameExistsCheck_用户名存在_该用户名在用户数据库中已存在记录()
- {
- var userName = "柳柳英侠";
- var configName = "configName";
- var accountService = new AccountService();
- var memberDao = new StubIMemberDao();
- memberDao.GetByNameString = str => new Member();
- accountService.MemberDao = memberDao;
- Assert.IsTrue(accountService.UserNameExistsCheck(userName, configName));
- }
StubIMemberDao类即为Fakes框架由IMemberDao接口生成的一个模拟类,第7行实例化了一个该类的对象, 这个对象有一个委托类型的字段GetByNameString开放出来,咱们就能够经过这个字段给接口的GetByName方法赋一个执行结果,即第8行的操做。再把这个对象赋给AccountService类中的IMemberDao类型的属性(第9行),即至关于给AccountService类添加了一个操做用户信息数据层的实现。
修改UserNameExistsCheck方法使测试经过
- public bool UserNameExistsCheck(string userName, string configName)
- {
- if (string.IsNullOrEmpty(userName))
- {
- throw new ArgumentNullException("userName");
- }
- if (string.IsNullOrEmpty(configName))
- {
- throw new ArgumentNullException("configName");
- }
- var member = MemberDao.GetByName(userName);
- if (member != null)
- {
- return true;
- }
- return false;
- }
运行测试,上面这个测试经过了,但第一个测试却失败了。

这不合乎TDD的要求了,TDD要求后面添加的功能不能影响原来的功能。看代码实现是没有问题的,看来问题是出在测试用例上。
当咱们走到“UserNameExistsCheck_用户名存在_该用户名在用户数据库中已存在记录”这个测试用例的时候,添加了一些属性,而这些属性在第一个测试用例“UserNameExistsCheck_用户名不存在”并无进行初始化,因此报了一个NullReferenceException异常。
接下来咱们来优化测试类的结构来解决这些问题:
a. 每一个测试用例的先决条件都要从0开始初始化,太麻烦
b. 测试环境没有初始化,新增条件会影响到旧的测试用例的运行
-
根据以上提出的问题,给出下面的解决方案
a. 进行公共环境的初始化,即让全部测试用例在相同的环境下运行
b. 全部的模拟环境都初始化为“正确的”,结合现有场景,即认为:数据访问层的全部操做是可用的,而且能提供运行结果的,即查询能查到数据,增删改能操做成功。
c. 当须要不正确的环境时再单独进行覆盖设置(即从新给模拟方法的执行结果赋值)
根据以上方案对测试类初始化为以下:给测试类添加字段和每一个方法运行前都运行的公共方法
- #region 字段
-
- private readonly AccountService _accountService = new AccountService();
- private readonly StubIMemberDao _memberDao = new StubIMemberDao();
- private readonly StubIMemberInactiveDao _memberInactiveDao = new StubIMemberInactiveDao();
- private readonly StubIConfigInfoDao _configInfoDao = new StubIConfigInfoDao();
-
- private int _num = 1;
- private Member _member = new Member();
- private readonly List<Member> _memberList = new List<Member>();
- private MemberInactive _memberInactive = new MemberInactive();
- private readonly List<MemberInactive> _memberInactiveList = new List<MemberInactive>();
- private ConfigInfo _configInfo = new ConfigInfo();
-
- #endregion
-
- [TestInitialize()]
- public void MyTestInitialize()
- {
- _memberDao.Commit = () => _num;
- _memberDao.DeleteMemberBoolean = (@member, @bool) => _num;
- _memberDao.GetByEmailString = @string => _memberList;
- _memberDao.GetByIdObject = @id => _member;
- _memberDao.GetByNameString = @string => _member;
- _memberDao.InsertMemberBoolean = (@member, @bool) => _num;
- _accountService.MemberDao = _memberDao;
-
- _memberInactiveDao.Commit = () => _num;
- _memberInactiveDao.DeleteMemberInactiveBoolean = (@memberInactive, @bool) => _num;
- _memberInactiveDao.GetByEmailString = @string => _memberInactiveList;
- _memberInactiveDao.GetByIdObject = @id => _memberInactive;
- _memberInactiveDao.GetByNameString = @string => _memberInactive;
- _memberInactiveDao.InsertMemberInactiveBoolean = (@memberInactive, @bool) => _num;
- _accountService.MemberInactiveDao = _memberInactiveDao;
-
- _configInfoDao.Commit = () => _num;
- _configInfoDao.DeleteConfigInfoBoolean = (@configInfo, @bool) => _num;
- _configInfoDao.GetByIdObject = @id => _configInfo;
- _configInfoDao.GetByNameString = @string => _configInfo;
- _configInfoDao.InsertConfigInfoBoolean = (@configInfo, @bool) => _num;
- _accountService.ConfigInfoDao = _configInfoDao;
-
- }
有了初始化之后,原来的测试用例就能够如此的简单,只须要初始化不成立的条件便可
- #region UserNameExistsCheck
- [TestMethod]
- public void UserNameExistsCheck_用户名不存在()
- {
- var userName = "柳柳英侠";
- var configName = "configName";
- _member = null;
- Assert.IsFalse(_accountService.UserNameExistsCheck(userName, configName));
- }
-
- [TestMethod]
- [ExpectedException(typeof(ArgumentNullException))]
- public void UserNameExistsCheck_引起ArgumentNullException异常_参数userName为空()
- {
- string userName = null;
- var configName = "configName";
- _accountService.UserNameExistsCheck(userName, configName);
- }
-
- [TestMethod]
- [ExpectedException(typeof(ArgumentNullException))]
- public void UserNameExistsCheck_引起ArgumentNullException异常_参数configName为空()
- {
- var userName = "柳柳英侠";
- string configName = null;
- _accountService.UserNameExistsCheck(userName, configName);
- }
-
- [TestMethod]
- public void UserNameExistsCheck_用户名存在_该用户名在用户数据库中已存在记录()
- {
- var userName = "柳柳英侠";
- var configName = "configName";
- Assert.IsTrue(_accountService.UserNameExistsCheck(userName, configName));
- }
-
- #endregion
[TestMethod]
- [ExpectedException(typeof(ArgumentNullException))]
- public void UserNameExistsCheck_引起ArgumentNullException异常_参数userName为空()
- {
- string userName = null;
- var configName = "configName";
- var accountService = new AccountService();
- accountService.UserNameExistsCheck(userName, configName);
- }
-
- [TestMethod]
- [ExpectedException(typeof(ArgumentNullException))]
- public void UserNameExistsCheck_引起ArgumentNullException异常_参数configName为空()
- {
- var userName = "柳柳英侠";
- string configName = null;
- var accountService = new AccountService();
- accountService.UserNameExistsCheck(userName, configName);
- }
-
全部条件都初始化好了,继续研究需求,就能够把测试用例的全部状况都写出来
- [TestMethod]
- [ExpectedException(typeof(NullReferenceException))]
- public void UserNameExistsCheck_引起NullReferenceException异常_系统配置信息没法找到()
- {
- var userName = "柳柳英侠";
- var configName = "configName";
- _member = null;
- _configInfo = null;
- _accountService.UserNameExistsCheck(userName, configName);
- }
-
- [TestMethod]
- public void UserNameExistsCheck_用户不存在_用户在用户数据库中不存在_and_注册不须要激活()
- {
- var userName = "柳柳英侠";
- var configName = "configName";
- _member = null;
- _configInfo.RegisterConfig.NeedActive = false;
- Assert.IsFalse(_accountService.UserNameExistsCheck(userName, configName));
- }
-
- [TestMethod]
- public void UserNameExistsCheck_用户不存在_用户在用户数据库中不存在_and_注册须要激活_and_用户名在未激活用户数据库中不存在()
- {
- var userName = "柳柳英侠";
- var configName = "configName";
- _member = null;
- _configInfo.RegisterConfig.NeedActive = true;
- _memberInactive = null;
- Assert.IsFalse(_accountService.UserNameExistsCheck(userName, configName));
- }
编写代码让测试经过
- public bool UserNameExistsCheck(string userName, string configName)
- {
- if (string.IsNullOrEmpty(userName))
- {
- throw new ArgumentNullException("userName");
- }
- if (string.IsNullOrEmpty(configName))
- {
- throw new ArgumentNullException("configName");
- }
- var member = MemberDao.GetByName(userName);
- if (member != null)
- {
- return true;
- }
- var configInfo = ConfigInfoDao.GetByName(configName);
- if (configInfo == null)
- {
- throw new NullReferenceException("系统配置信息为空。");
- }
- if (!configInfo.RegisterConfig.NeedActive)
- {
- return false;
- }
- var memberInactive = MemberInactiveDao.GetByName(userName);
- if (memberInactive != null)
- {
- return true;
- }
- return false;
- }

5、总结
看起来文章写得挺长了,其实内容并无多少,篇幅都被代码拉开了。咱们来总结一下使用Fakes框架进行TDD开发的步骤:
- 创建底层接口
- 建立测试接口的Fakes程序集
- 建立环境彻底初始化的测试类(这点比较麻烦,能够配合T4模板进行生成)
- 分析需求写测试用例
- 编写代码让测试用例经过
- 重构代码,并保证重构的代码仍然能让测试用例经过
另外有几点经验之谈:
- 测试用例的方法名彻底能够包含中文,清晰明了
- 因为测试类的环境已彻底初始化,能够根据需求把全部的测试用例一次写出来,不肯定的能够留为空方法,也不会影响测试经过
- 当你习惯了TDD以后,你会离不开它的└(^o^)┘
本篇只对底层的接口进行了模拟,在下篇将对测试类中的私有方法,静态方法等进行模拟,敬请期待^_^o~ 努力!
6、源码下载
LiuliuTDDFakesDemo01.rar
7、参考资料
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