9

Environment: .Net Core 3.1 REST API / EntityFrameworkCore.InMemory 3.1.6 / XUnit 2.4.1

In a Database First Setup I have a model mapped to a Sql View. During Code Generation (with EF Core PowerTools 2.4.51) this entity is marked in DbContext with .HasNoKey()

When I try to test the endpoint accessing the DbSet mapped to the Sql View it throws exception: Unable to track an instance of type '*' because it does not have a primary key. Only entity types with primary keys may be tracked.

Follows some code snippets with highlights of what I have tries so far.

Auto generated DbContext: ViewDemoAccountInfo is the entity mapped to a Sql View. Other entities are mapped to regular Sql Tables

// <auto-generated> This file has been auto generated by EF Core Power Tools. </auto-generated>
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata;

namespace Demo.Data.Entities
{
    public partial class DemoDbContext : DbContext
    {
        public DemoDbContext(){}

        public DemoDbContext(DbContextOptions<DemoDbContext> options): base(options){}

        public virtual DbSet<ViewDemoAccountInfo> ViewDemoAccountInfo { get; set; }
        
        // multiple other entities

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<ViewDemoAccountInfo>(entity =>
            {
                entity.HasNoKey();

                entity.ToView("ViewDemoAccountInfo");

                entity.Property(e => e.AccountType).IsUnicode(false);
            });

            OnModelCreatingPartial(modelBuilder);
        }

        partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
    }
}

Attempt #1:
The test

public class MyIntegrationTests : BaseIntegrationTest {
    // throws "Unable to track an instance of type 'ViewDemoAccountInfo' 
    // because it does not have a primary key. Only entity types with primary keys may be tracked."
    [Fact]
    public async Task SetupData_WhenKeylessEntity_ThenShouldNotThrow() {
        using (var scope = _testServer.Host.Services.CreateScope()) {
            var dbContext = scope.ServiceProvider.GetService<DemoDbContext>();

            await dbContext.ViewDemoAccountInfo.AddAsync(MockedAccountInfo); // setup some data
            await dbContext.SaveChangesAsync();
        }

        var endpointUrl = $"{ControllerRootUrl}/account-info";
        var response = await _testClient.GetAsync(endpointUrl);

        // Assertions
    }
}

Helpers

public class BaseIntegrationTest {
    protected readonly HttpClient _testClient;
    protected readonly TestServer _testServer;

    public BaseIntegrationTest() {
        var builder = new WebHostBuilder()
            .UseEnvironment("Test")
            .ConfigureAppConfiguration((builderContext, config) => {
                config.ConfigureSettings(builderContext.HostingEnvironment);
            });

        builder.ConfigureServices(services => {
            services.ConfigureInMemoryDatabases(new InMemoryDatabaseRoot());
        });

        builder.UseStartup<Startup>();

        _testServer = new TestServer(builder);
        _testClient = _testServer.CreateClient();
    }
}

// Regular DbContext
internal static class IntegrationExtensions {
    public static void ConfigureInMemoryDatabases(this IServiceCollection services, InMemoryDatabaseRoot memoryDatabaseRoot) {
        services.AddDbContext<DemoDbContext>(options => 
                 options.UseInMemoryDatabase("DemoApp", memoryDatabaseRoot)
                        .EnableServiceProviderCaching(false));
    }
}

The simplest solution is to change the Auto generated DbContext and remove the .HasNoKey() config, but it would be removed each time the schema structure will be generated with EF Core PowerTools.

Search for other solutions which would not require changes in Auto generated files
Found: how to test keyless entity - github discussion planned for EF Core 5 and stackoverflow source

Attemp #2 - Try to create another DbContext and override the entity setup by adding explicitly a key when Database.IsInMemory


public class TestingDemoDbContext : DemoDbContext {
    public TestingDemoDbContext(){}

    public TestingDemoDbContext(DbContextOptions<DemoDbContext> options): base(options){}

    protected override void OnModelCreating(ModelBuilder modelBuilder) {
        base.OnModelCreating(modelBuilder);
        modelBuilder.Entity<ViewDemoAccountInfo>(entity => {
            if (Database.IsInMemory()) {
                entity.HasKey(e => new { e.AccountType, e.AccountStartDate });
            }
        });
    }
}

In BaseIntegrationTest use the "extended" TestingDemoDbContext in ConfigureInMemoryDatabases method.

internal static class IntegrationExtensions {
    public static void ConfigureInMemoryDatabases(this IServiceCollection services, InMemoryDatabaseRoot memoryDatabaseRoot) {
        services.AddDbContext<TestingDemoDbContext>(options => 
                 options.UseInMemoryDatabase("DemoApp", memoryDatabaseRoot)
                        .EnableServiceProviderCaching(false));
    }
}

The test is similar, with a small difference: var dbContext = scope.ServiceProvider.GetService<TestingDemoDbContext>();

Result - strange, but it throws The string argument 'connectionString' cannot be empty. - even I do use the InMemoryDatabase

Attemp #3 - Try to use the OnModelCreatingPartial method to add a Key for that keyless entity.
So, in the same namespace with the Regular DbContext, create the partial DbContext meant to enrich the existing config

namespace Demo.Data.Entities {
    public partial class DemoDbContext : DbContext {
        partial void OnModelCreatingPartial(ModelBuilder builder) {
            builder.Entity<ViewDemoAccountInfo>(entity => {
                // try to set a key when Database.IsInMemory() 
                entity.HasKey(e => new { e.AccountType, e.AccountStartDate }));
            });
        }
    }
}

Result - The key { e.AccountType, e.AccountStartDate } cannot be added to keyless type 'ViewDemoAccountInfo'.

Any hints on how to add some mock data for Keyless entities (mapped to Sql View), with InMemoryDatabase, for testing purpose (with XUnit) would be grateful appreciated.

As well, if something is wrong or is considered bad practice in the setup I have listed here - would appreciate to receive improvement suggestions.

Vadim Ovchinnikov
  • 13,327
  • 5
  • 62
  • 90
mihai
  • 2,746
  • 3
  • 35
  • 56
  • Any luck with this one? – macwier Feb 22 '21 at 13:02
  • in this PR: https://github.com/dotnet/EntityFramework.Docs/pull/2745/files is he follow-up of the discussion concerning the issue originally posted here: https://github.com/dotnet/EntityFramework.Docs/issues/898 – mihai May 31 '22 at 08:09

1 Answers1

3

I know this is an old post, but I wanted to share the solution I ended up using for this in case anyone else comes across here.

In my model class, I added a [NotMapped] field named 'UnitTestKey'.

KeylessTable.cs

[Table("KeylessTable", Schema = "dbo")]
public class KeylessTable
{
    [NotMapped]
    public int UnitTestKey { get; set; }

    [Column("FakeColName")]
    public string FakeColumn { get; set; }
}

In my DbContext class, I use IHostEnvironment and used that to set HasKey() or HasNoKey() depending on if we are in the "Unit Testing" environment.

This example is using .NET 5. If using .NET Core 3.1 like in the original question, you would want to use IWebHostEnvironment.

ContextClass.cs

public class ContextClass : DbContext
{
    private readonly IHostEnvironment _environment;

    public ContextClass(DbContextOptions<ContextClass> options, IHostEnvironment environment) : base(options)
    {
        _environment = environment;
    }

    public DbSet<KeylessTable> KeylessTable => Set<KeylessTable>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<KeylessTable>(entity => {
            if (_environment.EnvironmentName == "UnitTesting")
                entity.HasKey(x => x.UnitTestKey);
            else
                entity.HasNoKey();
        });
    }
}

Then in my unit test, I mock the environment and set the name to be "UnitTesting".

UnitTest.cs

[Fact]
public async void GetKeylessTable_KeylessTableList()
{
    // Arrange
    var environment = new Mock<IHostEnvironment>();
    environment.Setup(e => e.EnvironmentName).Returns("UnitTesting");

    var options = new DbContextOptionsBuilder<ContextClass>().UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()).Options;
    var contextStub = new ContextClass(options, environment.Object);
    contextStub.Database.EnsureDeleted();
    contextStub.Database.EnsureCreated();

    contextStub.Set<KeylessTable>().AddRange(_keylessTablelMockData);
    contextStub.SaveChanges();

    var repository = new Repo(contextStub);

    // Act
    var response = await repository.GetKeylessData();

    // Assert
    response.Should().BeEquivalentTo(_keylessTablelMockData);
}
JCH
  • 170
  • 11