web-dev-qa-db-ja.com

EntityFunctions.TruncateTimeと単体テスト

クエリで日時の日付部分を取得するためにSystem.Data.Objects.EntityFunctions.TruncateTimeメソッドを使用しています。

if (searchOptions.Date.HasValue)
    query = query.Where(c => 
        EntityFunctions.TruncateTime(c.Date) == searchOptions.Date);

このメソッド(他のEntityFunctionsメソッドにも同じことが当てはまると思います)は、LINQ toEntitiesの外部では実行できません。ユニットテストでこのコードを実行すると、事実上オブジェクトに対してLINQになり、NotSupportedExceptionがスローされます。

System.NotSupportedException:この関数は、LINQからエンティティに対してのみ呼び出すことができます。

テストで偽のDbSetsを含むリポジトリのスタブを使用しています。

では、クエリをユニットテストするにはどうすればよいですか?

29
Jakub Konecki

ユニットテストでメモリ内の偽のリポジトリを使用しているため、LINQ toObjectsを使用している場合はできません。 LINQ to Objectsを使用してクエリをテストする場合、アプリケーションはテストせず、偽のリポジトリのみをテストします。

あなたの例外は、あなたが赤いテストをしていることを示しているので、それほど危険ではないケースですが、おそらく実際には動作しているアプリケーションです。

より危険なのは、その逆の場合です。グリーンテストを実行しているが、アプリケーションまたはクエリがクラッシュしていて、テストと同じ結果が返されない場合です。のようなクエリ...

context.MyEntities.Where(e => MyBoolFunction(e)).ToList()

または

context.MyEntities.Select(e => new MyEntity { Name = e.Name }).ToList()

...テストでは正常に機能しますが、アプリケーションのLINQ toEntitiesでは機能しません。

次のようなクエリ...

context.MyEntities.Where(e => e.Name == "abc").ToList()

... LINQ toObjectsとLINQtoEntitiesで異なる結果を返す可能性があります。

アプリケーションのLINQto Entitiesプロバイダーと実際のデータベースを使用する統合テストを構築することによってのみ、これと質問のクエリをテストできます。

編集

それでも単体テストを作成したい場合は、クエリ自体または少なくともクエリ内の式を偽造する必要があると思います。次のコードの行に沿った何かが機能するかもしれないと想像することができました:

Where式のインターフェースを作成します。

public interface IEntityExpressions
{
    Expression<Func<MyEntity, bool>> GetSearchByDateExpression(DateTime date);
    // maybe more expressions which use EntityFunctions or SqlFunctions
}

アプリケーションの実装を作成します...

public class EntityExpressions : IEntityExpressions
{
    public Expression<Func<MyEntity, bool>>
        GetSearchByDateExpression(DateTime date)
    {
       return e => EntityFunctions.TruncateTime(e.Date) == date;
       // Expression for LINQ to Entities, does not work with LINQ to Objects
    }
}

...そしてユニットテストプロジェクトの2番目の実装:

public class FakeEntityExpressions : IEntityExpressions
{
    public Expression<Func<MyEntity, bool>>
        GetSearchByDateExpression(DateTime date)
    {
        return e => e.Date.Date == date;
       // Expression for LINQ to Objects, does not work with LINQ to Entities
    }
}

クエリを使用しているクラスで、このインターフェイスのプライベートメンバーと2つのコンストラクターを作成します。

public class MyClass
{
    private readonly IEntityExpressions _entityExpressions;

    public MyClass()
    {
        _entityExpressions = new EntityExpressions(); // "poor man's IOC"
    }

    public MyClass(IEntityExpressions entityExpressions)
    {
        _entityExpressions = entityExpressions;
    }

    // just an example, I don't know how exactly the context of your query is
    public IQueryable<MyEntity> BuildQuery(IQueryable<MyEntity> query,
        SearchOptions searchOptions)
    {
        if (searchOptions.Date.HasValue)
            query = query.Where(_entityExpressions.GetSearchByDateExpression(
                searchOptions.Date));
        return query;
    }
}

アプリケーションで最初の(デフォルトの)コンストラクターを使用します。

var myClass = new MyClass();
var searchOptions = new SearchOptions { Date = DateTime.Now.Date };

var query = myClass.BuildQuery(context.MyEntities, searchOptions);

var result = query.ToList(); // this is LINQ to Entities, queries database

単体テストでFakeEntityExpressionsを指定して2番目のコンストラクターを使用します。

IEntityExpressions entityExpressions = new FakeEntityExpressions();
var myClass = new MyClass(entityExpressions);
var searchOptions = new SearchOptions { Date = DateTime.Now.Date };
var fakeList = new List<MyEntity> { new MyEntity { ... }, ... };

var query = myClass.BuildQuery(fakeList.AsQueryable(), searchOptions);

var result = query.ToList(); // this is LINQ to Objects, queries in memory

依存性注入コンテナーを使用している場合は、コンストラクターにIEntityExpressionsがあり、デフォルトのコンストラクターを必要としない場合、適切な実装を注入することでそれを活用できます。

上記のサンプルコードをテストしましたが、機能しました。

19
Slauma

新しい静的関数を定義できます(必要に応じて拡張メソッドとして使用できます)。

    [EdmFunction("Edm", "TruncateTime")]
    public static DateTime? TruncateTime(DateTime? date)
    {
        return date.HasValue ? date.Value.Date : (DateTime?)null;
    }

次に、その関数をLINQ toEntitiesおよびLINQto Objectsで使用すると、機能します。ただし、そのメソッドは、EntityFunctionsへの呼び出しを新しいクラスへの呼び出しに置き換える必要があることを意味します。

もう1つのより良い(しかしより複雑な)オプションは、式ビジターを使用し、メモリ内DbSetのカスタムプロバイダーを記述して、EntityFunctionsへの呼び出しをメモリ内実装への呼び出しに置き換えることです。

15
Jean Hominal

私の答え から EntityFunctions.AddDays関数を含むGetNewValues()の単体テスト方法 で概説されているように、クエリ式ビジターを使用して、EntityFunctions関数の呼び出しを独自のものに置き換えることができます、LINQ ToObjects互換の実装。

実装は次のようになります。

using System;
using System.Data.Objects;
using System.Linq;
using System.Linq.Expressions;

static class EntityFunctionsFake
{
    public static DateTime? TruncateTime(DateTime? original)
    {
        if (!original.HasValue) return null;
        return original.Value.Date;
    }
}
public class EntityFunctionsFakerVisitor : ExpressionVisitor
{
    protected override Expression VisitMethodCall(MethodCallExpression node)
    {
        if (node.Method.DeclaringType == typeof(EntityFunctions))
        {
            var visitedArguments = Visit(node.Arguments).ToArray();
            return Expression.Call(typeof(EntityFunctionsFake), node.Method.Name, node.Method.GetGenericArguments(), visitedArguments);
        }

        return base.VisitMethodCall(node);
    }
}
class VisitedQueryProvider<TVisitor> : IQueryProvider
    where TVisitor : ExpressionVisitor, new()
{
    private readonly IQueryProvider _underlyingQueryProvider;
    public VisitedQueryProvider(IQueryProvider underlyingQueryProvider)
    {
        if (underlyingQueryProvider == null) throw new ArgumentNullException();
        _underlyingQueryProvider = underlyingQueryProvider;
    }

    private static Expression Visit(Expression expression)
    {
        return new TVisitor().Visit(expression);
    }

    public IQueryable<TElement> CreateQuery<TElement>(Expression expression)
    {
        return new VisitedQueryable<TElement, TVisitor>(_underlyingQueryProvider.CreateQuery<TElement>(Visit(expression)));
    }

    public IQueryable CreateQuery(Expression expression)
    {
        var sourceQueryable = _underlyingQueryProvider.CreateQuery(Visit(expression));
        var visitedQueryableType = typeof(VisitedQueryable<,>).MakeGenericType(
            sourceQueryable.ElementType,
            typeof(TVisitor)
            );

        return (IQueryable)Activator.CreateInstance(visitedQueryableType, sourceQueryable);
    }

    public TResult Execute<TResult>(Expression expression)
    {
        return _underlyingQueryProvider.Execute<TResult>(Visit(expression));
    }

    public object Execute(Expression expression)
    {
        return _underlyingQueryProvider.Execute(Visit(expression));
    }
}
public class VisitedQueryable<T, TExpressionVisitor> : IQueryable<T>
    where TExpressionVisitor : ExpressionVisitor, new()
{
    private readonly IQueryable<T> _underlyingQuery;
    private readonly VisitedQueryProvider<TExpressionVisitor> _queryProviderWrapper;
    public VisitedQueryable(IQueryable<T> underlyingQuery)
    {
        _underlyingQuery = underlyingQuery;
        _queryProviderWrapper = new VisitedQueryProvider<TExpressionVisitor>(underlyingQuery.Provider);
    }

    public IEnumerator<T> GetEnumerator()
    {
        return _underlyingQuery.GetEnumerator();
    }

    IEnumerator IEnumerable.GetEnumerator()
    {
        return GetEnumerator();
    }

    public Expression Expression
    {
        get { return _underlyingQuery.Expression; }
    }

    public Type ElementType
    {
        get { return _underlyingQuery.ElementType; }
    }

    public IQueryProvider Provider
    {
        get { return _queryProviderWrapper; }
    }
}

そして、これがTruncateTimeの使用例です。

var linq2ObjectsSource = new List<DateTime?>() { null }.AsQueryable();
var visitedSource = new VisitedQueryable<DateTime?, EntityFunctionsFakerVisitor>(linq2ObjectsSource);
// If you do not use a lambda expression on the following line,
// The LINQ To Objects implementation is used. I have not found a way around it.
var visitedQuery = visitedSource.Select(dt => EntityFunctions.TruncateTime(dt));
var results = visitedQuery.ToList();
Assert.AreEqual(1, results.Count);
Assert.AreEqual(null, results[0]);
3
Jean Hominal

EntityExpressionsクラスを使用してSmaulaが提供する回答は気に入っていますが、少しやりすぎだと思います。基本的に、エンティティ全体をメソッドにスローし、比較を実行して、ブール値を返します。

私の場合、group byを実行するためにこのEntityFunctions.TruncateTime()が必要だったので、比較する日付やブール値がなく、日付部分を取得するための適切な実装を取得したかっただけです。だから私は書いた:

private static Expression<Func<DateTime?>> GetSupportedDatepartMethod(DateTime date, bool isLinqToEntities)
    {
        if (isLinqToEntities)
        {
            // Normal context
            return () => EntityFunctions.TruncateTime(date);
        }
        else
        {
            // Test context
            return () => date.Date;
        }
    } 

私の場合、2つの別々の実装とのインターフェースは必要ありませんでしたが、それはまったく同じように機能するはずです。

私はこれを共有したかったのです。なぜなら、それは可能な限り小さなことをするからです。日付部分を取得するための正しい方法を選択するだけです。

2
Leyenda

これは古いスレッドだと思いますが、とにかく答えを投稿したかったのです。

次の解決策は、 Shims を使用して実行されます。

Visual Studioのどのバージョン(2013、2012、2010)とフレーバー(エクスプレス、プロ、プレミアム、アルティメット)の組み合わせでシムを使用できるかわからないため、すべての人が利用できるわけではない可能性があります。

OPが投稿したコードは次のとおりです

// some method that returns some testable result
public object ExecuteSomething(SearchOptions searchOptions)
{
   // some other preceding code 

    if (searchOptions.Date.HasValue)
        query = query.Where(c => 
            EntityFunctions.TruncateTime(c.Date) == searchOptions.Date);

   // some other stuff and then return some result
}

以下は、いくつかのユニットテストプロジェクトといくつかのユニットテストファイルにあります。これがShimsを使用する単体テストです。

// Here is the test method
public void ExecuteSomethingTest()
{
    // arrange
    var myClassInstance = new SomeClass();
    var searchOptions = new SearchOptions();

    using (ShimsContext.Create())
    {
        System.Data.Objects.Fakes.ShimEntityFunctions.TruncateTimeNullableOfDateTime = (dtToTruncate) 
            => dtToTruncate.HasValue ? (DateTime?)dtToTruncate.Value.Date : null;

        // act
        var result = myClassInstance.ExecuteSomething(searchOptions);
        // assert
        Assert.AreEqual(something,result);
     }
}

これはおそらく、NotSupportedExceptionを生成せずにEntityFunctionsを利用するコードをテストするための最もクリーンで邪魔にならない方法だと思います。

1
Igor

次の方法で確認することもできます。

var dayStart = searchOptions.Date.Date;
var dayEnd = searchOptions.Date.Date.AddDays(1);

if (searchOptions.Date.HasValue)
    query = query.Where(c => 
        c.Date >= dayStart &&
        c.Date < dayEnd);