使用扩展方法定义的查询进行unit testing
在我的项目中,我使用以下方法从数据库中查询数据:
- 使用可返回任何类型且不绑定到一种类型的通用存储库,即
IRepository.Get
而不是IRepository.Get
。 NHibernatesISession
就是这样一个存储库的一个例子。 -
使用具有特定
T
IQueryable
上的扩展方法来封装重复查询,例如public static IQueryable ByInvoiceType(this IQueryable q, InvoiceType invoiceType) { return q.Where(x => x.InvoiceType == invoiceType); }
用法如下:
var result = session.Query().ByInvoiceType(InvoiceType.NormalInvoice);
现在假设我有一个我要测试的公共方法使用此查询。 我想测试三种可能的情况:
- 查询返回0个发票
- 查询返回1张发票
- 查询返回多个发票
我现在的问题是:要嘲笑什么?
- 我不能模拟
ByInvoiceType
因为它是一个扩展方法,或者我可以吗? - 我出于同样的原因甚至无法模拟
Query
。
经过一些研究并根据这里的答案和这些 链接 ,我决定完全重新设计我的API。
基本概念是完全禁止业务代码中的自定义查询。 这解决了两个问题:
- 可测试性得到改善
- Mark的博客文章中列出的问题不再可能发生。 业务层不再需要有关用于了解
IQueryable
上允许哪些操作以及哪些操作不允许的数据存储的隐式知识。
在业务代码中,查询现在看起来像这样:
IEnumerable inv = repository.Query .Invoices.ThatAre .Started() .Unfinished() .And.WithoutError(); // or IEnumerable inv = repository.Query.Invoices.ThatAre.Started(); // or Invoice inv = repository.Query.Invoices.ByInvoiceNumber(invoiceNumber);
在实践中,这是这样实现的:
正如Vytautas Mackonis在他的回答中所说,我不再直接依赖于NHibernate的ISession
,而是依赖于IRepository
。
此接口具有名为Query
of IQueries
类型的IQueries
。 对于业务层需要查询的每个实体, IQueries
都有一个属性。 每个属性都有自己的接口,用于定义实体的查询。 每个查询接口都实现了通用的IQuery
接口,该接口又实现了IEnumerable
,从而产生了如上所述的非常干净的DSL语法。
一些代码:
public interface IRepository { IQueries Queries { get; } } public interface IQueries { IInvoiceQuery Invoices { get; } IUserQuery Users { get; } } public interface IQuery : IEnumerable { T Single(); T SingleOrDefault(); T First(); T FirstOrDefault(); } public interface IInvoiceQuery : IQuery { IInvoiceQuery Started(); IInvoiceQuery Unfinished(); IInvoiceQuery WithoutError(); Invoice ByInvoiceNumber(string invoiceNumber); }
这种流畅的查询语法允许业务层组合提供的查询,以充分利用底层ORM的function,让数据库尽可能地过滤。
NHibernate的实现看起来像这样:
public class NHibernateInvoiceQuery : IInvoiceQuery { IQueryable _query; public NHibernateInvoiceQuery(ISession session) { _query = session.Query (); } public IInvoiceQuery Started() { _query = _query.Where(x => x.IsStarted); return this; } public IInvoiceQuery WithoutError() { _query = _query.Where(x => !x.HasError); return this; } public Invoice ByInvoiceNumber(string invoiceNumber) { return _query.SingleOrDefault(x => x.InvoiceNumber == invoiceNumber); } public IEnumerator GetEnumerator() { return _query.GetEnumerator(); } // ... }
在我的实际实现中,我将大部分基础结构代码提取到基类中,因此为新实体创建新的查询对象变得非常容易。 向现有实体添加新查询也非常简单。
关于这一点的好处是业务层完全没有查询逻辑,因此可以轻松切换数据存储。 或者可以使用条件API实现其中一个查询,或者从另一个数据源获取数据。 业务层将忽略这些细节。
在这种情况下,你应该嘲笑ISession。 但真正的问题是你不应该将它作为直接依赖。 它像在类中使用SqlConnection一样杀死可测试性 – 然后你必须“模拟”数据库本身。
用一些界面包装ISession,一切都变得简单:
public interface IDataStore { IQueryable Query (); } public class NHibernateDataStore : IDataStore { private readonly ISession _session; public NHibernateDataStore(ISession session) { _session = session; } public IQueryable Query () { return _session.Query (); } }
然后你可以通过返回一个简单的列表来模拟IDataStore。
为了将测试仅仅分离到扩展方法,我不会模仿任何东西。 在List()中创建一个发票列表,其中包含3个测试中每个测试的预定义值,然后在fakeInvoiceList.AsQueryable()上调用扩展方法并测试结果。
在fakeList中创建内存中的实体。
var testList = new List(); testList.Add(new Invoice {...}); var result = testList().AsQueryable().ByInvoiceType(enumValue).ToList(); // test results
根据你的Repository.Get的实现,你可以模拟NHibernate ISession。
如果它符合您的条件,您可以劫持generics来重载扩展方法。 让我们看看以下示例:
interface ISession { // session members } class FakeSession : ISession { public void Query() { Console.WriteLine("fake implementation"); } } static class ISessionExtensions { public static void Query(this ISession test) { Console.WriteLine("real implementation"); } } static void Stub1(ISession test) { test.Query(); // calls the real method } static void Stub2(TTest test) where TTest : FakeSession { test.Query(); // calls the fake method }
我将您的IRepository视为“UnitOfWork”,将您的IQueries视为“存储库”(也许是一个流畅的存储库!)。 因此,只需遵循UnitOfWork和Repository模式即可。 这是EF的一个好习惯,但您可以轻松实现自己的。
我知道这已经得到了很长时间的回答,我确实喜欢接受的答案,但是对于任何遇到类似问题的人,我建议我们考虑实现这里描述的规范模式 。
我们已经在我们当前的项目中做了一年多了,现在每个人都喜欢它。 在大多数情况下,您的存储库只需要一个方法
IEnumerable GetBySpecification(ISpecification spec)
这很容易嘲笑。
编辑:
使用像NHibernate这样的OR-Mapper模式的关键是让你的规范公开一个表达式树,ORM的Linq提供者可以解析它。 请点击上面提到的文章的链接以获取更多详细信息。
public interface ISpecification { Expression> SpecExpression { get; } bool IsSatisfiedBy(T obj); }
答案是(IMO):你应该模拟Query()
。
需要注意的是:我完全忽略了Query在这里的定义 – 我甚至不知道NHibernate,以及它是否被定义为虚拟。
但它可能没关系!基本上我会做的是:
-Mock Query返回模拟IQueryable。 (如果你不能模拟Query,因为它不是虚拟的,那么创建你自己的接口ISession,它暴露一个可模拟的查询,依此类推。) – 模拟IQueryable实际上并不分析它传递的查询,它只返回一些您在创建模拟时指定的预定结果 。
所有这些基本上让你可以随时模拟你的扩展方法。
有关执行扩展方法查询和简单模拟IQueryable实现的一般概念的更多信息,请参见此处:
http://blogs.msdn.com/b/tilovell/archive/2014/09/12/how-to-make-your-ef-queries-testable.aspx