rgvlee / EntityFrameworkCore.Testing

Adds relational support to the Microsoft EntityFrameworkCore in-memory database provider by mocking relational operations.
MIT License
161 stars 15 forks source link

AddRangeToReadOnlySource results in ArgumentException #141

Open grosch-intl opened 1 year ago

grosch-intl commented 1 year ago

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 1 year ago

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.

grosch-intl commented 1 year ago

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; }

}
grosch-intl commented 1 year ago

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 1 year ago

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 1 year ago

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.