使用IdleTest进行TDD单元测试驱动开发演练(2)
【前言】
1. 有关上篇请参见《使用IdleTest进行TDD单元测试驱动开发演练(1)》,有关本篇用到Entity Framework Code First请参见《使用NuGet助您玩转代码生成数据————Entity Framework 之 Code First》,而用的个人类库参照IdleTest。
2. 本文只用了简单的Entity Framework演练单元测试,着重于Testing,而不是实现,并不会涉及事务、效率等问题。
3. 回顾上一篇里面讲到的是针对业务层的测试,正如敏捷中厉行的多与用户沟通,在书《C# 测试驱动开发(Professional Test Driven Development with C#)》中作者就推荐TDD中单元测试的编写应有业务人员与需求人员参与,不是参与编码,而是参与单元测试的用例制定,当然了不涉及业务层面的代码也不需要如此。比如注册功能有多少种场景都可以在单元测试中体现出来,这时就要针对每种场景编写至少一个单元测试的方法,其命名也就尤为重要,因为要让他们看懂每个方法对应什么样的场景。以下就是我改造后的对UserService进行测试的代码,其中每个类对应一个功能模块,类中的每个方法则对应该功能的每一种场景,这样以便于与需求以及相关业务人员确定开发需求后再编码,减少了开发中的需求变更。
public abstract class BaseUserServiceTest { protected UserTestHelper UserTestHelper = new UserTestHelper(); private IUserRepository userRepository; protected IList<UserModel> ExistedUsers; protected abstract IUserService UserService { get; } /// <summary> /// IUserRepository模拟对象 /// </summary> public virtual IUserRepository UserRepository { get { if (this.userRepository == null) { StubIUserRepository stubUserRepository = new StubIUserRepository(); //模拟Get方法 stubUserRepository.GetExpressionOfFuncOfUserModelBooleanFuncOfIQueryableOfUserModelIOrderedQueryableOfUserModelString = (x, y, z) => { return this.ExistedUsers.Where<UserModel>(x.Compile()); }; //模拟GetSingle方法 stubUserRepository.GetSingleString = p => this.ExistedUsers.FirstOrDefault<UserModel>(o => o.LoginName == p); //模拟Insert方法 stubUserRepository.InsertUserModel = (p) => this.ExistedUsers.Add(p); this.userRepository = stubUserRepository; } return this.userRepository; } } [TestInitialize] public void InitUserList() { //每次测试前都初始化 this.ExistedUsers = new List<UserModel> { UserTestHelper.ExistedUser }; } #region Login Test [TestCategory("登陆场景")] public virtual void 当用户信息全部为空或账户为空或密码为空或账户错误或密码错误或账户密码均错误都登陆失败() { //验证登陆失败的场景 AssertCommon.AssertBoolean<UserModel>( new UserModel[] { null, new UserModel(), new UserModel { LoginName = string.Empty, Password = UserTestHelper.ExistedPassword }, //账户为空 new UserModel { LoginName = UserTestHelper.ExistedLoginName, Password = string.Empty }, //密码为空 new UserModel { LoginName = UserTestHelper.ExistedLoginName, Password = UserTestHelper.NotExistedPassword }, //密码错误 new UserModel { LoginName = UserTestHelper.NotExistedLoginName, Password = UserTestHelper.NotExistedPassword }, //账户密码错误 new UserModel { LoginName = UserTestHelper.NotExistedLoginName, Password = UserTestHelper.ExistedLoginName } //账户错误 }, false, p => UserService.Login(p)); } [TestCategory("登陆场景")] public virtual void 当账户密码全部正确时登陆成功() { //账户密码正确,验证成功,这里假设正确的账户密码是"zhangsan"、"123456" UserModel model = new UserModel { LoginName = UserTestHelper.ExistedLoginName, Password = UserTestHelper.ExistedPassword }; AssertCommon.AssertBoolean(true, UserService.Login(model)); } #endregion #region RegisterTest [TestCategory("注册场景")] public virtual void 当用户信息全为空或账户为空或密码为空或账户已存在时注册失败() { //验证注册失败的场景 AssertCommon.AssertBoolean<UserModel>( new UserModel[] { null, new UserModel(), new UserModel { LoginName = string.Empty, Password = UserTestHelper.NotExistedPassword }, //账户为空 new UserModel { LoginName = UserTestHelper.NotExistedLoginName, Password = string.Empty }, //密码为空 new UserModel { LoginName = UserTestHelper.ExistedLoginName, Password = UserTestHelper.NotExistedPassword }, //账户已存在 }, false, p => UserService.Register(p)); } [TestCategory("注册场景")] public virtual void 当账号密码均不为空且账号未存在则注册成功并且注册后的用户信息与注册输入的保持完全一致() { //验证注册成功的场景 //密码与他人相同也可注册 UserModel register1 = new UserModel { LoginName = "register1", Password = UserTestHelper.ExistedPassword }; UserModel register2 = new UserModel { LoginName = "register2", Password = UserTestHelper.NotExistedPassword }; UserModel register3 = new UserModel { LoginName = "register3", Password = UserTestHelper.NotExistedPassword, Age = 18 }; AssertCommon.AssertBoolean<UserModel>( new UserModel[] { register1, register2, register3 }, true, p => UserService.Register(p)); //获取用户且应与注册的信息保持一致 UserModel actualRegister1 = UserService.GetModel(register1.LoginName); UserTestHelper.AssertEqual(register1, actualRegister1); UserModel actualRegister2 = UserService.GetModel(register2.LoginName); UserTestHelper.AssertEqual(register2, actualRegister2); UserModel actualRegister3 = UserService.GetModel(register3.LoginName); UserTestHelper.AssertEqual(register3, actualRegister3); } #endregion //该方法可不需要业务人员参与 public virtual void GetModelTest() { AssertCommon.AssertIsNull<string, UserModel>(TestCommon.GetEmptyStrings(), true, p => UserService.GetModel(p)); AssertCommon.AssertIsNull(true, UserService.GetModel(UserTestHelper.NotExistedLoginName)); UserModel actual = UserService.GetModel(UserTestHelper.ExistedLoginName); UserTestHelper.AssertEqual(UserTestHelper.ExistedUser, actual); } }
[TestClass] public class UserServiceTest : BaseUserServiceTest { protected override IUserService UserService { get { return new UserService(this.UserRepository); } } [TestMethod] public override void GetModelTest() { base.GetModelTest(); } [TestMethod] public override void 当用户信息全部为空或账户为空或密码为空或账户错误或密码错误或账户密码均错误都登陆失败() { base.当用户信息全部为空或账户为空或密码为空或账户错误或密码错误或账户密码均错误都登陆失败(); } [TestMethod] public override void 当账户密码全部正确时登陆成功() { base.当账户密码全部正确时登陆成功(); } [TestMethod] public override void 当用户信息全为空或账户为空或密码为空或账户已存在时注册失败() { base.当用户信息全为空或账户为空或密码为空或账户已存在时注册失败(); } [TestMethod] public override void 当账号密码均不为空且账号未存在则注册成功并且注册后的用户信息与注册输入的保持完全一致() { base.当账号密码均不为空且账号未存在则注册成功并且注册后的用户信息与注册输入的保持完全一致(); } }
4. 这里我已经在上一篇的基础上进行了一些重构:
在解决方案文件夹“Tests”下新建类库项目“IdleTest.TDDEntityFramework.TestUtilities”,并添加引用“IdleTest.dll”、“IdleTest.MSTest.dll”
(参考上一篇)和“IdleTest.TDDEntityFramework.Models”。接着在项目下添加类“UserTestHelper”。
public class UserTestHelper { public string ExistedLoginName = "zhangsan"; public string ExistedPassword = "123456"; public string NotExistedLoginName = "zhangsan1"; public string NotExistedPassword = "123"; public UserModel ExistedUser { get { return new UserModel { LoginName = ExistedLoginName, Password = ExistedPassword }; } } public UserModel NotExistedUser { get { return new UserModel { LoginName = NotExistedLoginName, Password = NotExistedPassword, Age = 30 }; } } public void AssertEqual(UserModel expected, UserModel actual) { AssertCommon.AssertIsNull(false, expected); AssertCommon.AssertIsNull(false, actual); AssertCommon.AssertEqual<string>(expected.LoginName, actual.LoginName); AssertCommon.AssertEqual<string>(expected.Password, actual.Password); AssertCommon.AssertEqual<int>(expected.Age, actual.Age); } }
5. 再在项目“IdleTest.TDDEntityFramework.ServiceTest”引用刚添加的项目“IdleTest.TDDEntityFramework.TestUtilities”。
6. 接着生成并运行测试,在测试资源管理器中单击右键,滑动鼠标到“分组依据”后选中“特征”,如下图所示,此时便可以看到比较适合非开发人员的测试方法名。
使用“[TestCategory]”声明的测试方法可以在测试资源管理器中按照特征来排列。
我这里为了简便,把一个细分功能只划分为成功与失败两个方法,其实应该还可以划分得更细些,比如账户名为空登陆失败、密码为空登陆失
败分为两个测试方法。当然了,如果在需求并不复杂的情况下,也可以不用这么划分,比如上述的登陆与注册需求就很简单,完全可以不用细化,这
里只作为演示下罢了。
【一】对Repository层做测试前准备
(本篇将使用与上篇类似的方式完成仓储层(Repository)开发)
7. 由于使用Entity Framework Code First,因而需对Model增加一些特性(Attribute)声明。在编写以下代码前需在项
目“IdleTest.TDDEntityFramework.Models”添加引用“System.ComponentModel.DataAnnotations”。
[Table("UserInfo")] public class UserModel { [Key] [MaxLength(50)] public string LoginName { get; set; } [MaxLength(50)] public string Password { get; set; } public int Age { get; set; } }
8. 项目“IdleTest.TDDEntityFramework.Repositories”的变动:添加引用“IdleTest.TDDEntityFramework.Models”;打开程序包管理器控制台,如下图所示在默认项目选择“IdleTest.TDDEntityFramework.Repositories”,并在命令中输入“Install-Package EntityFramework”(PS 现在才发现Entity Framework已经到了6.0了,不过原有功能应该都还在);
在项目下添加类“SqlFileContext”。
public class SqlFileContext : DbContext { public DbSet<UserModel> Users { get; set; } public SqlFileContext() : base("DefaultConnectionString") { } }
【二】、编写UserRepository的测试UserRepositoryTest
(由于以下的测试不需要业务人员参与,故我又可以按照我喜欢的方式来命名单元测试了)
9. 在解决方案文件夹“Tests”下创建单元测试项目“IdleTest.TDDEntityFramework.RepositoryTest”,添加引用 “IdleTest.TDDEntityFramework.TestUtilities”、“IdleTest.TDDEntityFramework.IRepositories”、“IdleTest.TDDEntityFramework.Models” 和 “IdleTest.TDDEntityFramework.Repositories” 以及 “IdleTest”、“IdleTest.MSTest”(类似上一篇);继续添加“EntityFramework.dll”的引用如下图所示。
10. 对刚添加的“IdleTest.TDDEntityFramework.Repositories”与“EntityFramework”引用“添加Fakes程序集”。
11. 由于 “IdleTest.TDDEntityFramework.IRepositories” 有两个接口 “IUserRepository”、“IRepository”,因而我这里也创建两个对应的测试类“RepositoryTest”、“BaseUserRepositoryTest”。
public abstract class RepositoryTest<TEntity, TKey> where TEntity : class { protected abstract IRepository<TEntity, TKey> Repository { get; } public virtual void GetSingleTest() { AssertCommon.AssertIsNull(true, Repository.GetSingle(default(TKey))); } public virtual void InsertTest() { AssertCommon.ThrowException(true, () => Repository.Insert(default(TEntity))); AssertCommon.ThrowException(true, () => Repository.Insert(null)); } }
12. 限于篇幅,本文只对IRepository的“GetSingle”和“Insert”方法进行测试,其他方法类似,后续完成所有测试再将代码上传至http://idletest.codeplex.com/。
13. 继续编写类 “BaseUserRepositoryTest”,它与上一篇的 “BaseUserServiceTest” 非常相似。
public abstract class BaseUserRepositoryTest : RepositoryTest<UserModel, string> { protected UserTestHelper UserTestHelper = new UserTestHelper(); protected abstract IUserRepository UserRepository { get;} protected IList<UserModel> ExistedUsers; [TestInitialize] public virtual void Init() { this.ExistedUsers = new List<UserModel> { UserTestHelper.ExistedUser }; } public override void GetSingleTest() { base.GetSingleTest(); AssertCommon.AssertIsNull<string, UserModel>( TestCommon.GetEmptyStrings(), true, p => UserRepository.GetSingle(p)); AssertCommon.AssertIsNull(true, UserRepository.GetSingle(UserTestHelper.NotExistedLoginName)); UserModel actual = UserRepository.GetSingle(UserTestHelper.ExistedLoginName); UserTestHelper.AssertEqual(UserTestHelper.ExistedUser, actual); } public override void InsertTest() { base.InsertTest(); //验证添加成功的场景 //密码与他人相同也可添加 UserModel register1 = new UserModel { LoginName = "register1", Password = UserTestHelper.ExistedPassword }; UserModel register2 = UserTestHelper.NotExistedUser; AssertCommon.ThrowException<UserModel>( new UserModel[] { register1, register2 }, false, p => UserRepository.Insert(p)); //获取用户且应与注册的信息保持一致 UserModel actualRegister1 = UserRepository.GetSingle(register1.LoginName); UserTestHelper.AssertEqual(register1, actualRegister1); UserModel actualRegister2 = UserRepository.GetSingle(register2.LoginName); UserTestHelper.AssertEqual(register2, actualRegister2); //验证添加失败的场景,使用ThrowException来验证添加 AssertCommon.ThrowException<UserModel>( new UserModel[] { register1, //不能重复添加 //由于LoginName对应数据库字段为主键,故不能为空 new UserModel { LoginName = string.Empty, Password = UserTestHelper.NotExistedPassword }, }, true, p => UserRepository.Insert(p)); } }
14. 在项目 “IdleTest.TDDEntityFramework.RepositoryTest” 下添加类“UserRepositoryTest”
[TestClass] public class UserRepositoryTest : BaseUserRepositoryTest { protected SqlFileContext dbContext; protected IDisposable TestContext; private IUserRepository userRepository { get { return new UserRepository(dbContext); } } protected override IRepository<UserModel, string> Repository { get { return userRepository; } } protected override IUserRepository UserRepository { get { return userRepository; } } [TestInitialize] public override void Init() { base.Init(); if (dbContext == null) { TestContext = ShimsContext.Create(); //注意使用shim时必须先调用此方法(非全局可使用using) ShimSqlFileContext context = new ShimSqlFileContext(); ShimDbSet<UserModel> shimDbSet = new ShimDbSet<UserModel>(); shimDbSet.AddT0 = p => { if (this.ExistedUsers.Select(o => o.LoginName).Contains(p.LoginName) || string.IsNullOrEmpty(p.LoginName)) { throw new Exception(); } this.ExistedUsers.Add(p); return p; }; shimDbSet.FindObjectArray = p => { if (p != null && p.Length > 0) { return this.ExistedUsers.FirstOrDefault(o => o.LoginName.Equals(p[0])); } return null; }; context.UsersGet = () => shimDbSet; dbContext = context; } } [TestCleanup] public virtual void Dispose() { this.TestContext.Dispose(); } [TestMethod] public override void InsertTest() { base.InsertTest(); } [TestMethod] public override void GetSingleTest() { base.GetSingleTest(); } }
15. 编写测试类“UserRepositoryTest”时使用自动生成类生成“UserRepository”,并修改相应代码使编译通过
public class UserRepository : IUserRepository { public IEnumerable<UserModel> Get( Expression<Func<Models.UserModel, bool>> filter = null, Func<IQueryable<Models.UserModel>, IOrderedQueryable<Models.UserModel>> orderBy = null, string includeProperties = "") { throw new NotImplementedException(); } public UserModel GetSingle(string id) { throw new NotImplementedException(); } public void Insert(UserModel entity) { throw new NotImplementedException(); } public void Update(UserModel entityToUpdate) { throw new NotImplementedException(); } public void Delete(string id) { throw new NotImplementedException(); } public void Delete(UserModel entityToDelete) { throw new NotImplementedException(); } }
16. 继续修改直至测试通过(前面说过这里只对其中两个方法进行测试)。然后按照上一篇文中的做法,再将UserRepository.cs文件移动到项目 “IdleTest.TDDEntityFramework.Repositories”并添加引用“IdleTest.TDDEntityFramework.IRepositories”,记得要修改命名空间是解决方案编译通过。
public class UserRepository : IUserRepository, IDisposable { private SqlFileContext dbContext; private DbSet<UserModel> UserModelSet; public UserRepository(SqlFileContext dbContext) { this.dbContext = dbContext; this.UserModelSet = this.dbContext.Users; } public IEnumerable<UserModel> Get( Expression<Func<UserModel, bool>> filter = null, Func<IQueryable<UserModel>, IOrderedQueryable<UserModel>> orderBy = null, string includeProperties = "") { IQueryable<UserModel> query = UserModelSet; if (filter != null) { query = query.Where(filter); } foreach (var includeProperty in includeProperties.Split (new char[] { ',' }, StringSplitOptions.RemoveEmptyEntries)) { query = query.Include(includeProperty); } if (orderBy != null) { return orderBy(query).ToList(); } else { return query.ToList(); } } public UserModel GetSingle(string id) { return this.UserModelSet.Find(id); } public void Insert(UserModel entity) { this.UserModelSet.Add(entity); this.dbContext.SaveChanges(); } public void Update(UserModel entityToUpdate) { UserModelSet.Attach(entityToUpdate); dbContext.Entry(entityToUpdate).State = EntityState.Modified; this.dbContext.SaveChanges(); } public void Delete(string id) { var entityToDelete = GetSingle(id); Delete(entityToDelete); } public void Delete(UserModel entityToDelete) { if (dbContext.Entry(entityToDelete).State == EntityState.Detached) { UserModelSet.Attach(entityToDelete); } UserModelSet.Remove(entityToDelete); this.dbContext.SaveChanges(); } public void Dispose() { if (this.dbContext != null) { this.dbContext.Dispose(); } } }
【总结】
本文啰啰嗦嗦写了一大堆,其重点在于编写服务层(业务层)的测试时通过改变一些编码习惯以便于业务人员的参与;其次则是UserRepositoryTest中的Init方法,对DbContext和DbSet进行了模拟,而我自己编写的继承DbContext的SqlFileContext类将不会被测试。
其实再写本文前我也没有编写类似的单元测试,算是个人边实践边做的笔记,感觉对数据仓储(或者说数据访问层)的测试做到面面俱到仍然
还是有难度。甚至我认为这种只对Entity Framework框架提供的操作进行封装的测试可能不太有必要。