rgvlee/EntityFrameworkCore.Testing

AddRangeToReadOnlySource results in ArgumentException

grosch-intl opened this issue · 5 comments

I'm getting this error when testing my code:

System.ArgumentException : must be reducible node

If I don't use the AddRangeToReadOnlySource methods, and instead just add it to the context and do a save first, it works fine. Is this a bug, or am I just doing something wrong?

Here's the test method, where _context is the entity framework database substitution, and _database is the class with all the database calls that I need to test.

[Fact]
public async Task PublicDatabase_GetEntitlementsForEhsAsync_SomethingAsync() {
    // Arrange
    var badgeReaderMapping = _autoFixture.Create<BadgeReaderMapping>();
    _context.BadgeReaderMapping.AddToReadOnlySource(badgeReaderMapping);

    var mapping = _autoFixture.Build<MapBadgeReaderEntitlement>()
        .With(x => x.BadgeReaderId, badgeReaderMapping.BadgeReaderId)
        .CreateMany(5);

    _context.MapBadgeReaderEntitlement.AddRangeToReadOnlySource(mapping);

    // Act
    var results = await _database.GetEntitlementsForEhsAsync(_cancellationToken);

    // Assert
    results.Should().OnlyHaveUniqueItems();
}

It throws that error when doing the GetEntitlementsForEhsAsync call, which looks like this:

public Task<PublicEhsEntitlementDTO[]> GetEntitlementsForEhsAsync(CancellationToken cancellationToken) {
    var entitlementsQuery = from m in _context.BadgeReaderMapping.AsNoTracking()
                            join x in _context.MapBadgeReaderEntitlement on m.BadgeReaderId equals x.BadgeReaderId
                            let e = x.Entitlement
                            select new PublicEhsEntitlementDTO {
                                TririgaId = m.TririgaId,
                                Name = e.Name,
                                Compliant = e.TrainingConfigured
                            };

    return entitlementsQuery.Distinct().ToArrayAsync(cancellationToken);
}

This is the IClassFixture I'm using:

public class DatabaseFixture {
    public readonly IMapper Mapper;
    public readonly LampContext Context;
    public readonly IDbContextFactory<LampContext> DbContextFactory;

    public DatabaseFixture() {
        var config = new MapperConfiguration(x => {
            x.AddProfile<AutomapperProfile>();
            x.AddMaps(typeof(AutomapperProfile));
        });
        
        Mapper = config.CreateMapper();

        Context = Create.MockedDbContextFor<LampContext>();

        DbContextFactory = Substitute.For<IDbContextFactory<LampContext>>();
        DbContextFactory.CreateDbContext().Returns(Context);
    }
}

And the relevant package versions:

    <PackageReference Include="AutoFixture.AutoNSubstitute" Version="4.18.0" />
    <PackageReference Include="EntityFrameworkCore.Testing.NSubstitute" Version="5.0.0" />
    <PackageReference Include="FluentAssertions" Version="6.12.0" />
    <PackageReference Include="Microsoft.EntityFrameworkCore" Version="7.0.11" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="7.0.11" />
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.7.2" />
    <PackageReference Include="NSubstitute" Version="5.1.0" />
    <PackageReference Include="xunit" Version="2.5.1" />
rgvlee commented

Hi Scott
I have had a look to see if I can find the issue. I have been able to get the same error message though I am not sure if I have the same environment, with the problem occurring at a very low level. Could you provide an MVP DbContext along with the MVP entities and the on model creating directives for them.

Is this what you mean?

public partial class LampContext : ILampContext {
    private static void BeforeSaveModifications(object? sender, EntityEntryEventArgs e) {
        var state = e.Entry.State;

        if (state is not EntityState.Added or EntityState.Modified)
            return;

        var entity = e.Entry.Entity;

        var validationContext = new ValidationContext(entity);
        Validator.ValidateObject(entity, validationContext, validateAllProperties: true);

        if (entity is IHasTimestamps timestamp) {
            var now = DateTime.UtcNow;

            if (state == EntityState.Added)
                timestamp.CreatedUtc = now;

            timestamp.ModifiedUtc = now;
        }

        if (entity is IHasEtag etag)
            etag.ETag = Guid.NewGuid();
    }
}

public partial class LampContext : DbContext, ILampContext
{
	public LampContext(DbContextOptions<LampContext> options) : base(options) {
		// While ChangeTrack is non-null, it won't have a value during unit tests.
		if (ChangeTracker is not null) {
			ChangeTracker.StateChanged += BeforeSaveModifications; // Fired only when already tracked entities are modified.
			ChangeTracker.Tracked += BeforeSaveModifications; // Fired when entities are first tracked.
		}
	}

	public LampContext() : base() {}

	public virtual DbSet<BadgeReaderData> BadgeReaderData { get; set; }
	public virtual DbSet<BadgeReaderMapping> BadgeReaderMapping { get; set; }
	public virtual DbSet<BadgeReaderEntitlement> BadgeReaderEntitlement { get; set; }

        // Tons of others.
}

public partial class BadgeReaderEntitlement
{
	[Column("id"), Required, Key]
	public int Id { get; set; }


	public const int ApprovalGroupPropertyMaxLength = 2000;
	/// <remarks>
	/// Maximum string length is 2000
	/// </remarks>
	[Column("approvalGroup"), Required, StringLength(ApprovalGroupPropertyMaxLength)]
	public string ApprovalGroup { get; set; }


	[Column("exclude"), Required]
	public bool Exclude { get; set; }


	public const int NamePropertyMaxLength = 255;
	/// <remarks>
	/// Maximum string length is 255
	/// </remarks>
	[Column("name"), Required, StringLength(NamePropertyMaxLength)]
	public string Name { get; set; }


	[Column("ownerId"), Required]
	public int OwnerId { get; set; }


	[Column("trainingConfigured")]
	public bool? TrainingConfigured { get; set; }



	#region Foreign Keys
	[ForeignKey(nameof(OwnerId))]
	public virtual Employee Owner { get; set; } = null!;

	#endregion

}

[GeneratedCode("Scaffold", "1.0.0.0"), Table("Badge_Reader_Mapping"), PrimaryKey(nameof(BadgeReaderId), nameof(TririgaId))]
public partial class BadgeReaderMapping
{
	[Column("badgeReaderId"), Required]
	public int BadgeReaderId { get; set; }


	[Column("tririgaId"), Required]
	public int TririgaId { get; set; }


	public const int BadgeReaderNamePropertyMaxLength = 200;
	/// <remarks>
	/// Maximum string length is 200
	/// </remarks>
	[Column("badgeReaderName"), Required, StringLength(BadgeReaderNamePropertyMaxLength)]
	public string BadgeReaderName { get; set; }




}

Oh, also that line saying if (ChangeTracker is not null) { is a total hack I put in place to make testing work. What's the correct way to do that?

rgvlee commented

Oh, also that line saying if (ChangeTracker is not null) { is a total hack I put in place to make testing work. What's the correct way to do that?

I haven't had to do this in the past. Initial thoughts:
Don't change the implementation to pass a test; though it is fine to inspect the implementation and ask if it's being done as per best practice
Usage of a validation context - what is it doing, is it a data access layer concern, should it be injected
Setting audit properties such as created/last updated is fairly common - though I usually perform this as part of save changes

EntityFrameworkCore.Testing is basically a proxy over a testing DbContext, by default the MS in-memory provider, with mock support added for some relational operations. There will be relational operations that aren't mocked and this may be one of them.

rgvlee commented

I did make some progress, but I don't think it's for this specific issue.
I was able to confirm that joining between an entity and a read only entity fails, and this is due to a query provider mismatch between the two concerns. The check happens within EFCore.
I may be able to address that issue but it's not straightforward. I pushed a 4.x branch which confirmed the issue.