代码改变世界

.NET 测试驱动开发(TDD)之封装数据库以便Mock测试

2012-09-14 22:17  知平软件  阅读(2476)  评论(2编辑  收藏  举报

在测试驱动开发中,对数据库特别是ORM的测试,有的时候不好做,这里介绍我们的做法。

本文的方案是基于Entity Framework 4.0 Code First, Autofac的。

Entity Framework 4.0 Code First对测试驱动的支持

由于Entity Framework 4.0 Code First可以从业务层的简单C#对象(POCO)反向生成数据库以及数据库相应的表,如果数据简单的话,那么就直接实行TDD模式:

1、 首先创建测试用例,这里我们以一个客户关系管理系统为例讲解,用例是测试保存客户资料的功能:

   1:      [TestMethod]
   2:      public void 测试保存客户资料功能()
   3:      {
   4:          using (var rep = new CrmContext())
   5:          {
   6:              var customer = Customer.New<Customer>();
   7:              customer.Name = "上海知平";
   8:              rep.Customers.Add(customer);
   9:   
  10:              rep.SaveChanges();
  11:          }
  12:      }

2、 补充几个必要的类型:

   1:   
   2:  public class Customer : ISecret, INamedTable
   3:  {
   4:      public Guid Id { get; set; }
   5:   
   6:      public int Permission
   7:      {
   8:          get;
   9:          set;
  10:      }
  11:   
  12:      public string Name
  13:      {
  14:          get;
  15:          set;
  16:      }
  17:   
  18:      public static T New<T>() where T : Customer, new()
  19:      {
  20:          var ret = new T()
  21:          {
  22:              Id = Guid.NewGuid()
  23:          };
  24:   
  25:          return ret;
  26:      }
  27:  }
  28:   
  29:  public class CrmContext : DbContext
  30:  {
  31:      public CrmContext() { }
  32:   
  33:      public CrmContext(string nameOrConnectionString)
  34:   
  35:          : base(nameOrConnectionString)
  36:      {
  37:      }
  38:   
  39:      public DbSet<Customer> Customers { get; set; }
  40:  }

其中Customer是OR映射过的类型,用以在数据库和业务层之间加载和保存客户资料;而CrmContext就可以理解成数据库,里面有一个表Customers,当然这个是经过Entity Framework做OR映射后的结果。

3、 在测试工程的app.config文件里,添加CrmContext的链接字符串:

   1:  <?xml version="1.0" encoding="utf-8"?>
   2:  <configuration>
   3:    <connectionStrings>
   4:      <add name="Vowei.Data.VoweiContextImpl" connectionString="Data Source=.\SQLEXPRESS;Integrated Security=SSPI;Database=TaskConnect1" providerName="System.Data.SqlClient" />
   5:    </connectionStrings>
   6:  </configuration>

4、 这个时候运行测试用例就可以看到数据库已经自动生成了,而且也可以看到数据已经插入。

在EF的基础上再封装一层

从上文中可以看到,EF对测试驱动的支持已经很好了,但为什么还需要再封装一层呢?主要是出于两个目的:

1、 有些数据表,比如跟别的数据有比较复杂的联系,代码在设计时,一时半会不好确定对数据的建模是否合理,API设计是否流畅,因此为了保险起见,先在内存里模拟一个数据库,确定API设计合理之后,再将数据之间的关系通过EF映射到数据库上。

2、 将业务层和数据层的细节分离开来,比如业务层可能在后续版本使用RESTful API获取数据。

下面是我们再封装的过程。

1、 首先将CrmContext的属性和方法提成一个接口:

   1:  public interface IContext : IDisposable
   2:  {
   3:      IRepository<Customer> Customers { get; }
   4:   
   5:      int SaveChanges();
   6:  }

因为需要完全和EF分离出来,需要把CrmContext里的DbSet<Customer>类型的Customers属性也分离出一个接口。

   1:  /// <summary>
   2:  /// 代表数据库中的一个表
   3:  /// </summary>
   4:  /// <typeparam name="T">OR映射里的类型</typeparam>
   5:  public interface IRepository<T> where T : class
   6:  {
   7:      /// <summary>
   8:      /// 获取数据表的名称(在数据库中对应的表) 
   9:      /// </summary>
  10:      string Name { get; }
  11:   
  12:      /// <summary>
  13:      /// 返回一个组合后的IQueryable查询
  14:      /// </summary>
  15:      IQueryable<T> Query { get; }
  16:   
  17:      /// <summary>
  18:      /// 添加外键查询
  19:      /// </summary>
  20:      /// <param name="navigationProperty">OR映射中对象的外键属性</param>
  21:      /// <returns>返回对象本身,以达到IRepository.Include("Property1").Include("Property2").Query的效果</returns>
  22:      IRepository<T> Include(string navigationProperty);
  23:   
  24:      /// <summary>
  25:      /// 往数据层中添加一个新的对象
  26:      /// </summary>
  27:      /// <param name="item">新对象</param>
  28:      void Add(T item);
  29:   
  30:      /// <summary>
  31:      /// 在数据层中删除一个对象
  32:      /// </summary>
  33:      /// <param name="item">要删除的对象</param>
  34:      void Remove(T item);
  35:   
  36:      /// <summary>
  37:      /// 用于更新操作时,将尚未和数据库关联的对象关联
  38:      /// </summary>
  39:      /// <param name="entity">尚未和数据库关联的对象</param>
  40:      /// <returns>一个已经和数据库关联的对象</returns>
  41:      T Attach(T entity);
  42:   
  43:      IQueryable<T> SqlQuery(string sql, params object[] parameters);
  44:   
  45:      /// <summary>
  46:      /// 获取所属的数据库
  47:      /// </summary>
  48:      IContext Context { get; }
  49:  }

2、 实现接口IContext,并且将具体的实现隐藏。

   1:   
   2:  public class CrmContext : IContext
   3:  {
   4:      private CrmContextImpl _contextImpl;
   5:   
   6:      internal CrmContextImpl Impl { get { return _contextImpl; } }
   7:   
   8:      static CrmContext()
   9:      {
  10:          Database.SetInitializer(new CrmContextInitializer());
  11:      }
  12:   
  13:      public CrmContext() :
  14:          this(new CrmContextImpl())
  15:      {
  16:      }
  17:   
  18:      public CrmContext(string nameOrConnectionString)
  19:          : this(new CrmContextImpl(nameOrConnectionString))
  20:      {
  21:      }
  22:   
  23:      public IRepository<Customer> Customers
  24:      {
  25:          get;
  26:          private set;
  27:      }
  28:   
  29:      public int SaveChanges()
  30:      {
  31:          return _contextImpl.SaveChanges();
  32:      }
  33:   
  34:      public void Dispose()
  35:      {
  36:          if (_contextImpl != null)
  37:          {
  38:              _contextImpl.Dispose();
  39:              _contextImpl = null;
  40:          }
  41:      }
  42:  }

3、 为了避免对每个数据表都重复实现IRepository这个接口,做了一个通用的接口实现类型,通过反射将IContext的数据表属性和具体实现的数据表属性关联起来。

   1:   
   2:  internal class RepositoryImpl<T, U> : IRepository<U>
   3:      where T : class
   4:      where U : class, T
   5:  {
   6:      // 被封装的数据表实现方式
   7:      private DbSet<U> _table;
   8:      // 保存上一次类似Where等Lambda调用保存的表达式树
   9:      private IQueryable<U> _query;
  10:      // 保存新数据的回调函数
  11:      private Func<T, U> _persistRouing;
  12:      private string _tableName;
  13:      private VoweiContext _context;
  14:   
  15:      // 这个变量仅仅是用来在实现继承类型,避免编译器混乱用的
  16:      public IRepository<T> IfImplementation { get; private set; }
  17:   
  18:      private RepositoryImpl(IQueryable<U> query, Func<T, U> persistRouing, string tableName)
  19:      {
  20:          _query = query;
  21:          _persistRouing = persistRouing;
  22:          _tableName = tableName;
  23:      }
  24:   
  25:      public RepositoryImpl(VoweiContext context, Func<T, U> persistRouing, string tableName)
  26:      {
  27:          var property = typeof(VoweiContextImpl).GetProperty(tableName);
  28:          // 通过反射的机制,根据“tableName”参数给出的属性名,获取实现IContext某个数据表的具体对象引用
  29:          // 并保存到类型变量里,以便将所有的查询、Include、增删改等操作传递给这个对象。
  30:          _table = (DbSet<U>)property.GetValue(context._contextImpl, new object[] { });
  31:          _query = _table;
  32:          _persistRouing = persistRouing;
  33:          _tableName = tableName;
  34:          _context = context;
  35:   
  36:          // 如果类型T和U不是同一个类型,说明要么U继承与T,或者U实现了T这个接口
  37:          // 这样一来,为了规避编译器编译错误,需要再封一层
  38:          if (typeof(T) != typeof(U))
  39:              IfImplementation = new RepositoryIfImpl(this, tableName);
  40:          else // 否则就很简单了
  41:              IfImplementation = (IRepository<T>)this;
  42:      }
  43:   
  44:      public RepositoryImpl(VoweiContext context)
  45:          : this(context, null, typeof(U).Name)
  46:      {
  47:      }
  48:   
  49:      /// <summary>
  50:      /// 获取该Repository对应的数据库里的表名
  51:      /// </summary>
  52:      public string Name { get { return _tableName; } }
  53:   
  54:      public IContext Context { get { return _context; } }
  55:   
  56:      public IQueryable<U> Query
  57:      {
  58:          get
  59:          {
  60:              if (_query == null)
  61:                  return _table;
  62:              else
  63:                  return _query;
  64:          }
  65:      }
  66:   
  67:      public IRepository<U> Include(string navigationProperty)
  68:      {
  69:          if (_query == null)
  70:              return new RepositoryImpl<T, U>(_table.Include(navigationProperty), _persistRouing, _tableName);
  71:          else
  72:              return new RepositoryImpl<T, U>(_query.Include(navigationProperty), _persistRouing, _tableName) { _table = _table };
  73:      }
  74:   
  75:      public IQueryable<U> SqlQuery(string sql, params object[] parameters)
  76:      {
  77:          return _table.SqlQuery(sql, parameters).AsQueryable<U>();
  78:      }
  79:   
  80:      public virtual void Add(U item)
  81:      {
  82:          _table.Add(item);
  83:      }
  84:   
  85:      public virtual void Remove(U item)
  86:      {
  87:          _table.Remove(item);
  88:      }
  89:   
  90:      public U Attach(U entity)
  91:      {
  92:          return _table.Attach(entity);
  93:      }
  94:   
  95:      class RepositoryIfImpl : IRepository<T>
  96:      {
  97:          private RepositoryImpl<T, U> _outer;
  98:          private IQueryable<U> _tmpQuery;
  99:          private string _tableName;
 100:   
 101:          public RepositoryIfImpl(RepositoryImpl<T, U> outer, string tableName)
 102:          {
 103:              _outer = outer;
 104:              _tableName = tableName;
 105:          }
 106:   
 107:          public string Name { get { return _tableName; } }
 108:   
 109:          public IContext Context { get { return _outer._context; } }
 110:   
 111:          public IQueryable<T> Query
 112:          {
 113:              get
 114:              {
 115:                  if (_tmpQuery == null)
 116:                      return _outer._table;
 117:                  else
 118:                      return _tmpQuery;
 119:              }
 120:          }
 121:   
 122:          public IQueryable<T> SqlQuery(string sql, params object[] parameters)
 123:          {
 124:              return _outer._table.SqlQuery(sql, parameters).AsQueryable<U>();
 125:          }
 126:   
 127:          public IRepository<T> Include(string navigationProperty)
 128:          {
 129:              var result = new RepositoryIfImpl(_outer, _tableName);
 130:              result._tmpQuery = _tmpQuery == null ? _outer._table.Include(navigationProperty)
 131:                                                   : _tmpQuery.Include(navigationProperty);
 132:   
 133:              return result;
 134:          }
 135:   
 136:          public virtual void Add(T item)
 137:          {
 138:              if (_outer._persistRouing == null)
 139:                  throw new InvalidOperationException("当需要从基类T的对象实例生成一个派生类型U的实例时,需要指明转换的方式!");
 140:              _outer._table.Add(_outer._persistRouing(item));
 141:          }
 142:   
 143:          public virtual void Remove(T item)
 144:          {
 145:              _outer._table.Remove((U)item);
 146:          }
 147:   
 148:          public T Attach(T entity)
 149:          {
 150:              return _outer.Attach((U)entity);
 151:          }
 152:      }
 153:  }

从上面的代码里可以看到,RepositoryImpl和RepositoryIfImpl两个类是内部类,而且是CrmContext的内部类,避免了被系统其他代码调用到的机会。

4、 因为IRepository这个接口是一个通用接口,所以需要一个机制映射IContext的数据表属性和CrmContextImpl的数据表属性,下面两个函数就是用来做映射的。

   1:      // 如果封装的类型和数据库里的其他类型没有继承关系,则使用这个函数执行映射
   2:      private IRepository<T> RegisterTable<T>(string tableName)
   3:      where T : class
   4:      {
   5:          var result = new RepositoryImpl<T, T>(this, null, tableName);
   6:          _tableMap.Add(typeof(T), result);
   7:          return result;
   8:      }
   9:   
  10:      // 如果封装的类型和数据库里的其他类型有继承关系,则使用这个函数执行映射,
  11:      // 需要指定类型和类型的基类
  12:      private IRepository<T> RegisterDeliveredTable<T, U>(Func<T, U> persistRouting, string tableName)
  13:          where T : class
  14:          where U : class, T
  15:      {
  16:          var result = new RepositoryImpl<T, U>(this, persistRouting, tableName);
  17:          _tableMap.Add(typeof(T), result.IfImplementation);
  18:          _tableMap.Add(typeof(U), result);
  19:          return result.IfImplementation;
  20:      }

5、 通用的接口实现封装好了以后,加一个新的数据表就是一个注册的过程,这个过程在构造函数里就做了。

   1:      internal VoweiContext(VoweiContextImpl impl)
   2:      {
   3:          _contextImpl = impl;
   4:          Customers = RegisterTable<Customer>("Customers");
   5:      }

6、 再在测试用例或者程序启动合适的地方,通过Ioc机制将数据库接口IContext和实现接口的对象注册一番就可以用了。

   1:  var builder = new ContainerBuilder();
   2:  builder.RegisterType<CrmContext>().AsImplementedInterfaces();
   3:  IocHelper.Container = builder.Build();

7、 最后用的时候很简单,本文开头的例子就改成使用Ioc的方式从容器里获取一个接口实现:

   1:      [TestMethod]
   2:      public void 测试保存客户资料功能()
   3:      {
   4:          using (var rep = IocHelper.Container.Resolve<IContext>())
   5:          {
   6:              var customer = Customer.New<Customer>();
   7:              customer.Name = "上海知平";
   8:              rep.Customers.Add(customer);
   9:              rep.SaveChanges();
  10:          }
  11:      }

本文由知平软件 施懿民编写,请关注我们的微博