Manipulating objects with DbSet<T> and IQueryable<T> with NSubstitute returns error

会有一股神秘感。 提交于 2019-12-06 08:54:30

问题


I'd like to use NSubstitute to unit test Entity Framework 6.x by mocking DbSet. Fortunately, Scott Xu provides a good unit testing library, EntityFramework.Testing.Moq using Moq. So, I modified his code to be suitable for NSubstitute and it's been looking good so far, until I wanted to test DbSet<T>.Add(), DbSet<T>.Remove() methods. Here's my code bits:

public static class NSubstituteDbSetExtensions
{
  public static DbSet<TEntity> SetupData<TEntity>(this DbSet<TEntity> dbset, ICollection<TEntity> data = null, Func<object[], TEntity> find = null) where TEntity : class
  {
    data = data ?? new List<TEntity>();
    find = find ?? (o => null);

    var query = new InMemoryAsyncQueryable<TEntity>(data.AsQueryable());

    ((IQueryable<TEntity>)dbset).Provider.Returns(query.Provider);
    ((IQueryable<TEntity>)dbset).Expression.Returns(query.Expression);
    ((IQueryable<TEntity>)dbset).ElementType.Returns(query.ElementType);
    ((IQueryable<TEntity>)dbset).GetEnumerator().Returns(query.GetEnumerator());

#if !NET40
    ((IDbAsyncEnumerable<TEntity>)dbset).GetAsyncEnumerator().Returns(new InMemoryDbAsyncEnumerator<TEntity>(query.GetEnumerator()));
    ((IQueryable<TEntity>)dbset).Provider.Returns(query.Provider);
#endif

    ...

    dbset.Remove(Arg.Do<TEntity>(entity =>
                                 {
                                   data.Remove(entity);
                                   dbset.SetupData(data, find);
                                 }));

    ...

    dbset.Add(Arg.Do<TEntity>(entity =>
                              {
                                data.Add(entity);
                                dbset.SetupData(data, find);
                              });

    ...

    return dbset;
  }
}

And I created a test method like:

[TestClass]
public class ManipulationTests
{
  [TestMethod]
  public void Can_remove_set()
  {
    var blog = new Blog();
    var data = new List<Blog> { blog };

    var set = Substitute.For<DbSet<Blog>, IQueryable<Blog>, IDbAsyncEnumerable<Blog>>()
                        .SetupData(data);

    set.Remove(blog);

    var result = set.ToList();

    Assert.AreEqual(0, result.Count);
  }
}

public class Blog
{
   ...
}

The issue arises when the test method calls set.Remove(blog). It throws an InvalidOperationException with error message of

Collection was modified; enumeration operation may not execute.

This is because the fake data object has been modified when the set.Remove(blog) method is called. However, the original Scott's way using Moq doesn't result in the issue.

Therefore, I wrapped the set.Remove(blog) method with a try ... catch (InvalidOperationException ex) block and let the catch block do nothing, then the test doesn't throw an exception (of course) and does get passed as expected.

I know this is not the solution, but how can I achieve my goal to unit test DbSet<T>.Add() and DbSet<T>.Remove() methods?


回答1:


What's happening here?

  1. set.Remove(blog); - this calls the previously configured lambda.
  2. data.Remove(entity); - The item is removed from the list.
  3. dbset.SetupData(data, find); - We call SetupData again, to reconfigure the Substitute with the new list.
  4. SetupData runs...
  5. In there, dbSetup.Remove is being called, in order to reconfigure what happens when Remove is called next time.

Okay, we have a problem here. dtSetup.Remove(Arg.Do<T.... doesn't reconfigure anything, it rather adds a behavior to the Substitute's internal list of things that should happen when you call Remove. So we're currently running the previously configured Remove action (1) and at the same time, down the stack, we're adding an action to the list (5). When the stack returns and the iterator looks for the next action to call, the underlying list of mocked actions has changed. Iterators don't like changes.

This leads to the conclusion: We can't modify what a Substitute does while one of its mocked actions is running. If you think about it, nobody who reads your test would assume this to happen, so you shouldn't do this at all.

How can we fix it?

public static DbSet<TEntity> SetupData<TEntity>(
    this DbSet<TEntity> dbset,
    ICollection<TEntity> data = null,
    Func<object[], TEntity> find = null) where TEntity : class
{
    data = data ?? new List<TEntity>();
    find = find ?? (o => null);

    Func<IQueryable<TEntity>> getQuery = () => new InMemoryAsyncQueryable<TEntity>(data.AsQueryable());

    ((IQueryable<TEntity>) dbset).Provider.Returns(info => getQuery().Provider);
    ((IQueryable<TEntity>) dbset).Expression.Returns(info => getQuery().Expression);
    ((IQueryable<TEntity>) dbset).ElementType.Returns(info => getQuery().ElementType);
    ((IQueryable<TEntity>) dbset).GetEnumerator().Returns(info => getQuery().GetEnumerator());

#if !NET40
    ((IDbAsyncEnumerable<TEntity>) dbset).GetAsyncEnumerator()
                                            .Returns(info => new InMemoryDbAsyncEnumerator<TEntity>(getQuery().GetEnumerator()));
    ((IQueryable<TEntity>) dbset).Provider.Returns(info => getQuery().Provider);
#endif

    dbset.Remove(Arg.Do<TEntity>(entity => data.Remove(entity)));
    dbset.Add(Arg.Do<TEntity>(entity => data.Add(entity)));

    return dbset;
}
  1. The getQuery lambda creates a new query. It always uses the captured list data.
  2. All .Returns configuration calls use a lambda. In there, we create a new query instance and delegate our call there.
  3. Remove and Add only modify our captured list. We don't have to reconfigure our Substitute, because every call reevaluates the query using the lambda expressions.

While I really like NSubstitute, I would strongly recommend looking into Effort, the Entity Framework Unit Testing Tool.

You would use it like this:

// DbContext needs additional constructor:
public class MyDbContext : DbContext
{
    public MyDbContext(DbConnection connection) 
        : base(connection, true)
    {
    }
}

// Usage:
DbConnection connection = Effort.DbConnectionFactory.CreateTransient();    
MyDbContext context = new MyDbContext(connection);

And there you have an actual DbContext that you can use with everything that Entity Framework gives you, including migrations, using a fast in-memory-database.



来源:https://stackoverflow.com/questions/27308190/manipulating-objects-with-dbsett-and-iqueryablet-with-nsubstitute-returns-er

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!