使用扩展方法中定义的查询进行单元测试

本文关键字:查询 单元测试 定义 扩展 方法 | 更新日期: 2023-09-27 18:29:34

在我的项目中,我使用以下方法来查询数据库中的数据:

  1. 使用可以返回任何类型且不绑定到一种类型的通用存储库,即 IRepository.Get<T>而不是IRepository<T>.Get.NHibernates ISession就是这种存储库的一个例子。
  2. 在具有特定TIQueryable<T>上使用扩展方法来封装重复查询,例如

    public static IQueryable<Invoice> ByInvoiceType(this IQueryable<Invoice> q,
                                                    InvoiceType invoiceType)
    {
        return q.Where(x => x.InvoiceType == invoiceType);
    }
    

用法是这样的:

var result = session.Query<Invoice>().ByInvoiceType(InvoiceType.NormalInvoice);

现在假设我有一个要测试的公共方法,它使用此查询。我想测试三种可能的情况:

  1. 查询返回 0 张发票
  2. 查询返回 1 张发票
  3. 查询返回多张发票

我现在的问题是:嘲笑什么?

  • 我不能嘲笑ByInvoiceType,因为它是一种扩展方法,或者我可以吗?
  • 出于同样的原因,我什至不能嘲笑Query

使用扩展方法中定义的查询进行单元测试

经过更多的研究,并根据这里的答案和这些链接,我决定完全重新设计我的API。

基本概念是完全禁止业务代码中的自定义查询。这解决了两个问题:

  1. 提高了可测试性
  2. 马克博客文章中概述的问题不再会发生。业务层不再需要有关正在使用的数据存储的隐式知识来了解IQueryable<T>允许哪些操作,不允许哪些操作。

在业务代码中,查询现在如下所示:

IEnumerable<Invoice> inv = repository.Query
                                     .Invoices.ThatAre
                                              .Started()
                                              .Unfinished()
                                              .And.WithoutError();
// or
IEnumerable<Invoice> inv = repository.Query.Invoices.ThatAre.Started();
// or
Invoice inv = repository.Query.Invoices.ByInvoiceNumber(invoiceNumber);

在实践中,这是这样实现的:

正如维陶塔斯·麦科尼斯(Vytautas Mackonis(在他的回答中所建议的那样,我不再直接依赖NHibernate的ISession,而是现在依靠IRepository

此接口具有一个名为 Query 的属性,类型为 IQueries 。对于业务层需要查询的每个实体,IQueries中都有一个属性。每个属性都有自己的接口,用于定义实体的查询。每个查询接口都实现了通用IQuery<T>接口,而通用接口又实现了IEnumerable<T>,从而产生了非常干净的DSL语法。

一些代码:

public interface IRepository
{
    IQueries Queries { get; }
}
public interface IQueries
{
    IInvoiceQuery Invoices { get; }
    IUserQuery Users { get; }
}
public interface IQuery<T> : IEnumerable<T>
{
    T Single();
    T SingleOrDefault();
    T First();
    T FirstOrDefault();
}
public interface IInvoiceQuery : IQuery<Invoice>
{
    IInvoiceQuery Started();
    IInvoiceQuery Unfinished();
    IInvoiceQuery WithoutError();
    Invoice ByInvoiceNumber(string invoiceNumber);
}

这种流畅的查询语法允许业务层组合提供的查询,以充分利用底层ORM的功能,让数据库尽可能多地过滤。

NHibernate的实现如下所示:

public class NHibernateInvoiceQuery : IInvoiceQuery
{
    IQueryable<Invoice> _query;
    public NHibernateInvoiceQuery(ISession session)
    {
        _query = session.Query<Invoice>();
    }
    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<Invoice> GetEnumerator()
    {
        return _query.GetEnumerator();
    }
    // ...
} 

在我的实际实现中,我将大部分基础结构代码提取到基类中,以便为新实体创建新的查询对象变得非常容易。向现有实体添加新查询也非常简单。

这样做的好处是业务层完全没有查询逻辑,因此可以轻松切换数据存储。或者,可以使用条件 API 实现其中一个查询,或者从另一个数据源获取数据。业务层将忽略这些细节。

在这种情况下,

ISession将是你应该嘲笑的东西。但真正的问题是,你不应该把它作为一个直接的依赖关系。它杀死可测试性的方式与在类中使用 SqlConnection 的方式相同 - 然后您必须"模拟"数据库本身。

用一些界面包装ISession,一切都变得容易:

public interface IDataStore
{
    IQueryable<T> Query<T>();
}
public class NHibernateDataStore : IDataStore
{
    private readonly ISession _session;
    public NHibernateDataStore(ISession session)
    {
        _session = session;
    }
    public IQueryable<T> Query<T>()
    {
        return _session.Query<T>();
    }
}

然后,您可以通过返回一个简单的列表来模拟IDataStore。

为了将测试隔离到扩展方法,我不会嘲笑任何东西。 在 List(( 中创建一个发票列表,其中包含 3 个测试中每个测试的预定义值,然后在 fakeInvoiceList.AsQueryable(( 上调用扩展方法并测试结果。

在假列表的内存中创建实体。

var testList = new List<Invoice>();
testList.Add(new Invoice {...});
var result = testList().AsQueryable().ByInvoiceType(enumValue).ToList();
// test results

根据您对 Repository.Get 的实现,您可以模拟 NHibernate ISession。

如果它适合您的条件,您可以劫持泛型以重载扩展方法。让我们以以下示例为例:

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>(TTest test) where TTest : FakeSession
{
    test.Query(); // calls the fake method
}

我认为您的独立性是"工作单元",而您的IQueries是"存储库"(也许是一个流畅的存储库!因此,只需遵循工作单元和存储库模式即可。这是 EF 的好做法,但你可以轻松实现自己的做法。

我知道

这早已得到解答,我确实喜欢公认的答案,但对于遇到类似问题的任何人来说,我建议研究实现此处描述的规范模式

我们已经在当前的项目中这样做了一年多,每个人都喜欢它。在大多数情况下,您的存储库只需要一种方法,例如

IEnumerable<MyEntity> GetBySpecification(ISpecification<MyEntity> spec)

这很容易被嘲笑。

编辑:

模式与 OR-Mapper (如 NHibernate(一起使用的关键是让您的规范公开一个表达式树,ORM 的 Linq 提供程序可以解析该表达式树。请点击我上面提到的文章的链接以获取更多详细信息。

public interface ISpecification<T>
{
   Expression<Func<T, bool>> SpecExpression { get; }
   bool IsSatisfiedBy(T obj);
}

答案是(IMO(:你应该嘲笑Query()

需要注意的是:我这样说完全不知道查询在这里是如何定义的 - 我什至不知道 NHibernate,以及它是否被定义为虚拟的。

但这可能无关紧要!基本上我要做的是:

-模拟查询以返回模拟IQueryable。(如果因为 Query 不是虚拟的而无法模拟它,请创建自己的接口 ISession,该接口公开可模拟的查询,依此类推。-模拟 IQueryable 实际上并不分析它传递的查询,它只是返回您在创建模拟时指定的一些预定结果

总而言之,这基本上可以让您随时模拟扩展方法。

有关执行扩展方法查询的一般思想和简单的模拟 IQueryable 实现的更多信息,请参阅此处:

http://blogs.msdn.com/b/tilovell/archive/2014/09/12/how-to-make-your-ef-queries-testable.aspx