You want to test something that uses an Entity Framework DbContext
as a dependency.
Can I replace this easily with a fake DbContext
?
Using MOQ? Read this
Let us say you have this simple DbContext:
public class SomedbContext : DbContext
{
public virtual DbSet<Blog> Blogs => Set<Blog>();
public virtual DbSet<Post> Posts => Set<Post>();
}
Do note I am using NSubstitute to build fake objects, and that I have made these properties virtual
to allow NSubstitute to override their implementation.
Now I need some fake data, and turn it into IQueryable<T>
:
IQueryable<Blog> blogs = FakeData.FakeBlogs.AsQueryable();
IQueryable<Post> posts = FakeData.FakePosts.AsQueryable();
This allows me to build a fake DbContext
as follows:
SomedbContext db =
new SomedbContext()
.WithTable(db => db.Blogs, blogs)
.WithTable(db => db.Posts, posts)
.Build();
Now I can pass this fake DbContext
to the intended class as a dependency,
and it will use my fake data during the test, and no real database is needed.
You might also consider simply using the InMemory database provided by EF.
So how does this work?
Let us examine following piece of code:
SomedbContext db =
new SomedbContext()
.WithTable(db => db.Blogs, blogs)
.WithTable(db => db.Posts, posts)
.Build();
The first WithTable
method is an extension method which returns a fluid builder instance:
public static class DbContextExtensions
{
public static FakeDbContextBuilder<TDB> WithTable<TDB, E>(
this TDB dbContext,
Expression<Func<TDB, DbSet<E>>> exp,
IQueryable<E> data)
where TDB : DbContext
where E : class
{
FakeDbContextBuilder<TDB> builder = new();
return builder.WithTable(exp, data);
}
Using the power of generics, I can apply this method to any DbContext
, passing the DbSet<E>
I want to replace, and the data that it should be replaced with.
new SomedbContext().WithTable(db => db.Blogs, blogs)
There are three arguments involved:
- The
WithTable
method is an extension method, so a SomedbContext
instance is passed as the first argument.
- The second argument in an
Expression<Func<SomedbContext, DbSet<Blog>>>
, and db => db.Blogs
will be passed allows us to replace Blogs
with the fake data.
- The third argument is the fake data passed as an
IQueryable<Blog>
.
All of this gets passed to the FakeDbContextBuilder<TDB>
fluent builder's WithTable
extension method. Let us have a look at this:
public static FakeDbContextBuilder<TDB> WithTable<TDB, E>(
this FakeDbContextBuilder<TDB> builder,
Expression<Func<TDB, DbSet<E>>> exp,
IQueryable<E> data)
where TDB : DbContext
where E : class
{
Mock<DbSet<E>> mockSet = new();
mockSet.As<IQueryable<E>>()
.Setup(m => m.Provider)
.Returns(data.Provider);
mockSet.As<IQueryable<E>>()
.Setup(m => m.Expression)
.Returns(data.Expression);
mockSet.As<IQueryable<E>>()
.Setup(m => m.ElementType)
.Returns(data.ElementType);
mockSet.As<IQueryable<E>>()
.Setup(m => m.GetEnumerator())
.Returns(data.GetEnumerator);
builder.Replace(exp, mockSet.Object);
return builder;
}
This method has the same arguments as the first WithTable
method.
What this method does it creates a fake DbSet<E>
using MOQ, and then proceeds to relay all of its properties to the IQueryable<T>
fake data.
Once this is done, we tell the fluent builder to replace a property in the DbContext
, for example replace db => db.Blogs
with the fake DbSet<E>
.
Now it is time to examine the FakeDbContextBuilder<TDB>
class:
public class FakeDbContextBuilder<TDB>
where TDB : DbContext
{
private Mock<TDB> moq = new();
public TDB Build()
=> moq.Object;
public void Replace<E>(Expression<Func<TDB, DbSet<E>>> exp, DbSet<E> e)
where E : class
=> exp.Compile()(this.fake).Returns(e);
}
This class keeps track of a Mock<SomedbContext>
, and replaces each DbSet<E>
property if needed. This is done in the Replace
method.
At the end of the call chain, you should call Build
, which returns the fake DbContext
.
You don't even have to replace each DbSet
, pick you ones you need.
In this blog post I wanted to demonstrate how easy it is to replace any DbContext
with fake data, and show you an implementation that make the life of the user (you) easy and hides away the details on how to do this...