Is there a way to Mock a View in EF Core?

Viewed 860

I'm using EF Core "In Memory" database but having trouble mocking a view.

        var options = new DbContextOptionsBuilder<MyContext>()
            .UseInMemoryDatabase(databaseName: "TestDatabase")
            .Options;

        // Insert seed data into the database using one instance of the context
        using (var context = new MyContext(options))
        {
            // insert test values into entities here (omitted)...

            // I want some test values for a View
            context.MyView.AddRange( viewEntity1, viewEntity, ... etc );

            context.SaveChanges();
        }

At runtime, when attempting to add the view entities, the following exception is raised:

System.InvalidOperationException: Unable to track an instance of type 'MyView' because it does not have a primary key. Only entity types with primary keys may be tracked.

The reason I want to mock the View is that I want to test some LINQ queries that join to the View.

Any ideas?

    // Entity 
    public class MyView
    {
       public string UserId {get;set;}
       public int CentreId {get;set;}
    }

    // Context
    ...
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
         ...
         modelBuilder.Entity<MyView>()
                .HasNoKey()
                .ToView("MyView");
    }
3 Answers

No, the in-memory provider doesn't support views. It's a relational operation, same as the FromSql* and ExecuteSql* methods.

AddRange won't work on a view regardless of the provider.

You can test views; there are a couple of options:

  1. Do integration tests with a real database
  2. Use a better provider, possibly SQLite
  3. Use a testing package that allows you to mock the view

I maintain one of said testing packages, EntityFrameworkCore.Testing, which does allow you to easily unit test SUTs that depend on views.

You should not mock anything at the Entity Framework level. Don't mock what you don't own.

In general, you should not be mocking implementation detail, which is what a view is - The consumer of your code should not need to know that it's interacting with a view, but that it's interacting with some data source. Otherwise your tests will end up breaking when you change the implementation detail, which is not what should be happening.

If you want to test how your controllers (or similar) interact with Entity Framework, you should use the Repository pattern and mock the Repository instead. If that's the case, you should not be mocking anything and instead should use a real database to test against.

If you want to test how your repositories interact with the database, you can use an integration test to test this. Repositories should be simple with very little business logic, so there is not much reason to unit test them to begin with.

Yes, you sure can....

Here is what I am using for my setup

  • Moq (for...mocks)
  • Lamar (for Dependency Injection)

STANDARD EF CORE VIEW:
Here the code for one of my view-definitions.

// EntityConfiguration
internal class ProjectStatusDetailConfiguration : IEntityTypeConfiguration<ProjectStatusDetail>
{
    public void Configure(EntityTypeBuilder<ProjectStatusDetail> builder)
    {
        // SQL View
        builder.ToView("vProjectStatus", "dbo")
            .HasKey(e => e.ProjectId);
    }
}

// Concrete DbContext
public class ProjectDbContext : DbContext
{
    private IConfigurationRoot _settings;

    public ProjectDbContext()
    {
        // NOTE: You must have this constructor for Migrations to work
        BuildAppConfiguration();
    }

    public ProjectDbContext(DbContextOptions<ProjectDbContext> options) : base(options)
    {
        // NOTE: This constructor is used at the IServiceProvider layer
        BuildAppConfiguration();
    }

    public virtual DbSet<Project> Project { get; set; }

    public virtual DbSet<ProjectStatusDetail> vProjectStatusDetail { get; set; }

    private void BuildAppConfiguration()
    {
        var builder = new ConfigurationBuilder();
        builder.SetBasePath(Directory.GetCurrentDirectory())
               .AddJsonFile("appsettings.json");

        _settings = builder.Build();
    }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        // Default Configuration (if none is provided by the app)
        if (!optionsBuilder.IsConfigured)
            optionsBuilder.UseSqlServer(_settings.GetConnectionString(JsonSettings.ConnectionStrings.DefaultDb));
    }

    protected override void OnModelCreating(ModelBuilder builder)
    {
        base.OnModelCreating(builder);

        // GLOBAL Configurations
        builder.ApplyGlobalEntityConfiguration(new AuditableStandardUserNameGlobalConfiguration());

        // ENTITY Configurations
        builder.ApplyConfiguration(new ProjectConfiguration());

        // SQL VIEW Configurations
        builder.ApplyConfiguration(new ProjectStatusDetailConfiguration());
    }
}

// Lamar ServiceRegistry (for the business-layer)
public class ContainerRegistry : ServiceRegistry
{
    public ContainerRegistry()
    {
        Scan(scan =>
        {
            scan.TheCallingAssembly();
            scan.WithDefaultConventions();
            scan.LookForRegistries();
            scan.SingleImplementationsOfInterface();
        });

        // --------
        // DATABASE
        For<DbContext>().Use<ProjectDbContext>();
        For(typeof(IAuditableRepository<>)).Use(typeof(GenericAuditableRepository<>));
        ForConcreteType<ProjectUnitOfWork>().Configure.Setter<DbContext>().Is<ProjectDbContext>();

        // Policies
        Policies.Add<GenericAuditableRepositoryConfiguredInstancePolicy>(); //<-- Sets CTOR at Runtime
        Policies.Add<UnitOfWorkConfiguredInstancePolicy>(); //<-- Sets CTOR at Runtime
    }
}

// SINGLE POINT OF ENTRY: Consumes & "serves-up" your App's Registry
public static class IoC
{
    public static ServiceRegistry Build()
    {
        var registry = new ServiceRegistry();

        // Registration
        registry.IncludeRegistry<ContainerRegistry>();

        return new ServiceRegistry(registry);
    }
}

UNIT TESTING SETUP:
Here the code for my unit testing setup. Obviously, I am still working-on the DbSetProxy's query...but here it is anyway.

// Lamar ServiceRegistry (for the unit test)
internal class ContainerRegistry : ServiceRegistry
{
    public ContainerRegistry()
    {
        // This is where you will SHIM all your Stubs/Fakes
        For<IWindowsIdentityHelper>().Use<UnitTests.WindowsIdentityHelperStub>(); //<-- Stub
    }
}

// SINGLE POINT OF ENTRY: Consume all Registry's (for the unit testing-layer)
public static class IoC
{
    public static ServiceRegistry Build()
    {
        var registry = new ServiceRegistry();

        // Notice you can bring-in many ServiceRegistry's
        // Just be sure to apply your "stubbing" registry last
        registry.IncludeRegistry<Bushido.WorkflowComponent.DependencyResolution.ContainerRegistry>();
        registry.IncludeRegistry<Bushido.Project.Business.DependencyResolution.ContainerRegistry>();
        registry.IncludeRegistry<Bushido.WorkflowComponent.UnitTests.DependencyResolution.ContainerRegistry>();

        return new ServiceRegistry(registry);
    }
}

// This base class class does the heavy-lifting
public class UnitTestBase
{
    protected IContainer Container { get; private set; }

    protected Builder Builder { get; set; } = new Builder();

    [TestInitialize()]
    public virtual void Initialize()
    {
        IHostBuilder hostBuilder = CreateHostBuilder(null);
        IHost host = hostBuilder.Build();
        IServiceScope serviceScope = host.Services.CreateScope();
        IServiceProvider serviceProvider = serviceScope.ServiceProvider;

        Container = serviceProvider.GetRequiredService<IContainer>();
    }

    [TestCleanup()]
    public virtual void Cleanup()
    {
        Container.Dispose();
        Container = null;
    }

    private IHostBuilder CreateHostBuilder(string[] args)
    {
        var builder = new HostBuilder()
                            .ConfigureAppConfiguration((hostingContext, config) =>
                            {
                                config.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true);
                            })
                            // You must apply the Lamar Factory
                            .UseServiceProviderFactory<ServiceRegistry>(new LamarServiceProviderFactory())
                            .ConfigureServices((hostContext, services) =>
                            {
                                var databaseName = Guid.NewGuid().ToString();

                                // Add Lamar & Apply Scoping here (not in your ServiceRegistry's)
                                services.AddLamar(IoC.Build());
                                services.AddScoped<ProjectUnitOfWork>();
                                services.AddScoped<WorkflowComponentUnitOfWork>();
                                services.AddScoped<ProjectDbContext>();
                                services.AddScoped<WorkflowComponentDbContext>();
                                services.AddDbContext<ProjectDbContext>((provider, options) => options.UseInMemoryDatabase(databaseName), ServiceLifetime.Singleton);
                                services.AddDbContext<WorkflowComponentDbContext>((provider, options) => options.UseInMemoryDatabase(databaseName), ServiceLifetime.Singleton);
                            });
        return builder;
    }
}

// This base class contains the common setup for your VIEWS DbSetProxy
public class DataScenarioBase
{
    protected static DbSet<TEntity> CreateDbSet<TEntity>(IQueryable<TEntity> collection) where TEntity : class
    {
        var stubDbSet = new Mock<DbSet<TEntity>>();

        stubDbSet.As<IQueryable<TEntity>>().Setup(m => m.Provider).Returns(collection.Provider);
        stubDbSet.As<IQueryable<TEntity>>().Setup(m => m.Expression).Returns(collection.Expression);
        stubDbSet.As<IQueryable<TEntity>>().Setup(m => m.ElementType).Returns(collection.ElementType);
        stubDbSet.As<IQueryable<TEntity>>().Setup(m => m.GetEnumerator()).Returns(collection.GetEnumerator());

        return stubDbSet.Object;
    }
}

// This is a sample DataScenario
public class ProjectDefaultDataScenario : DataScenarioBase
{
    public void Load(IContainer container)
    {
        var unitOfWork = container.GetInstance<ProjectUnitOfWork>();

        // DATA
        LoadDefaultData(unitOfWork) //<-- you can pre-load default data into the UnitOfWork here too

        // VIEWS
        var dbContext = ((ProjectDbContext)unitOfWork.DbContext);
        
        dbContext.vProjectStatusDetail = CreateDbSet<ProjectStatusDetail>(ProjectStatusDetailQuery(unitOfWork));

        // Inject
        container.GetNestedContainer().Inject(unitOfWork); //<-- This (along with scoping) ensures the right instance is always passed back
    }

    private IQueryable<ProjectStatusDetail> ProjectStatusDetailQuery(ProjectUnitOfWork unitOfWork)
    {
        var query = (from project in unitOfWork.Project
                     select new ProjectStatusDetail
                     {
                         ProjectId = project.Id,
                         ProjectName = project.ProjectName,
                         ObjectStateName = "Testing 123",
                         ObjectStateDateTime = DateTime.Now,
                     });
        return query;
    }
}

// Here is a sample Unit Test
[TestMethod]
public void ProjectProvider_WorkflowProvider_ObjectState_New()
{
    // -----
    // ARRANGE
    Builder.WorkflowDefaultSetup.Load(Container);
    Builder.ProjectDefaultSetup.Load(Container);

    var project = new Project();
    project.ProjectName = "Red Barchetta";
    project.Description = "To keep it as new has been our dearest dream";

    // -----
    // ACT
    var provider = Container.GetRequiredService<ProjectProvider>();
    provider.CreateProject(project);

    // -----
    // ASSERT
    Assert.IsTrue(project.Id > 0);
    Assert.IsNotNull(project.ObjectState);
    Assert.IsInstanceOfType(project.ObjectState, typeof(Bushido.Project.Business.StateManagement.Project.New));
}
Related