在测试驱动开发中,对数据库特别是ORM的测试,有的时候不好做,这里介绍我们的做法。
本文的方案是基于Entity Framework 4.0 Code First, Autofac的。
由于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对测试驱动的支持已经很好了,但为什么还需要再封装一层呢?主要是出于两个目的:
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: }