Search code examples
c#.netasp.net-corexunittestcontainers

Testcontainers .NET 8 db context seems to not be updated in [Fact], solved the issue, looking for explanation


I'm trying to implement an integration test on the Put endpoint of a crud controller


tl;dr;

Needed to fetch without tracking, but why? Tests and fixture don't share context? Shouldn't they?

(solution under question)


[HttpPut("{id}")]
    public async Task<IActionResult> Put([FromRoute] string id, [FromBody] UpdateUser updateUser, [FromServices] CaManDbContext dbContext, CancellationToken cancellationToken)
    {
        if (!Ulid.TryParse(id, out var ulId))
        {
            return BadRequest();
        }
        
        var existingUser = await dbContext.Users
            .Include(u => u.ContactInfo)
            .FirstOrDefaultAsync(u => u.Id == new UserId(ulId), cancellationToken);

        if (existingUser is null)
        {
            return NotFound();
        }

        if (!string.IsNullOrWhiteSpace(updateUser.shortName))
        {
            var newShortName = ShortName.Create(updateUser.shortName);

            existingUser.UpdateShortName(newShortName);
        }

        if (!string.IsNullOrWhiteSpace(updateUser.email))
        {
            var newEmail = Email.Create(updateUser.email);

            existingUser.UpdateEmail(newEmail);
        }

        await dbContext.SaveChangesAsync(cancellationToken);

        return Ok(existingUser);
    }

I've already implemented the api fixture and tests on other endpoints run smoothly.


However, the following test

[Fact]
    public async Task Update_ShouldUpdate_EmailOfExistingUser_ToDatabase()
    {
        // Arrange
        var existingUser = await UserHelperMethods.CreateRandomUserInDb(_apiDbContext);
        
        var shortName = existingUser.ShortName.Value;
        var email = "[email protected]";

        // Act
        var httpResponse =
            await _apiClient.PutAsJsonAsync($"/api/Users/{existingUser.Id.Value}", 
                new UpdateUser(null, email, null));

        Assert.True(httpResponse.IsSuccessStatusCode);
        
        var updatedUser = await httpResponse.Content.ReadFromJsonAsync<CreatedTestUser>();

        //Assert
        Assert.NotNull(updatedUser);
        Assert.Equal(existingUser.Id, updatedUser.Id);
        Assert.Equal(shortName, updatedUser.ShortName.Value);
        Assert.Equal(email, updatedUser.Email.Value);
        
        var fetchedUser = await (await _apiClient.GetAsync($"api/Users/{existingUser.Id.Value}")).Content.ReadFromJsonAsync<CreatedTestUser>();
        
        Assert.NotNull(fetchedUser);
        Assert.Equal(updatedUser.Id, fetchedUser.Id);
        Assert.Equal(updatedUser.ShortName.Value, fetchedUser.ShortName.Value);
        Assert.Equal(updatedUser.Email.Value, fetchedUser.Email.Value);

        var dbUser = await _apiDbContext.Users.FirstOrDefaultAsync(u => u.Id == updatedUser.Id);
        
        Assert.NotNull(dbUser);
        Assert.Equal(updatedUser.Id, dbUser.Id);
        Assert.Equal(updatedUser.ShortName.Value, dbUser.ShortName.Value);
        Assert.Equal(updatedUser.Email.Value, dbUser.Email.Value);
    }

fails on the last line Assert.Equal(updatedUser.Email.Value, dbUser.Email.Value);

The failure message indicates that even though the request is processed correctly, the user inside the db context is not updated!


The api factory:

public class IntegrationTestApiFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
    private readonly MySqlContainer 
        _dbContainer = new MySqlBuilder()
            .WithImage("mysql:8.0")
            .Build();

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureTestServices(services =>
        {
            var dbDescriptor = services
                .SingleOrDefault(s => s.ServiceType == typeof(DbContextOptions<CaManDbContext>));

            if (dbDescriptor is not null)
            {
                services.Remove(dbDescriptor);
            }

            services.AddDbContext<CaManDbContext>(optionsBuilder =>
            {
                var serverVersion = new MySqlServerVersion(new Version(8, 0, 36));
                optionsBuilder.UseMySql(_dbContainer.GetConnectionString(), serverVersion);
            });
        });
    }

    public async Task InitializeAsync()
    {
        await _dbContainer.StartAsync();
        using var scope = Services.CreateScope();
        var dbContext = scope.ServiceProvider.GetRequiredService<CaManDbContext>();
        await dbContext.Database.MigrateAsync();
    }

    public new Task DisposeAsync()
    {
        return _dbContainer.StopAsync();
    }
}

and the integration test base class;

public abstract class BaseIntegrationTest : IClassFixture<IntegrationTestApiFactory>
{
    protected readonly HttpClient _apiClient;
    protected readonly CaManDbContext _apiDbContext;
    protected readonly IServiceScope _apiScope;
    
    protected BaseIntegrationTest(IntegrationTestApiFactory apiFactory)
    {
        _apiClient = apiFactory.Server.CreateClient();
        _apiScope = apiFactory.Services.CreateScope();
        _apiDbContext = _apiScope.ServiceProvider.GetRequiredService<CaManDbContext>();
    }
}

The whole code can be found here which can directly run the tests, also here can be found the failed test run on github's action (the failure is the same as in local environment)

I tried calling the db context from different location in order to clean up anything persistent but got nowhere


Solution

  • You are using the same context instance to seed the data and to check for the updates. EF Core uses change tracking (is enabled by default for queries, most DML operations are done via tracking), so when you have added and saved changes, the entities are present in the change tracker and if you will query the database (with change tracking enabled) EF will skip the mapping of the data for already tracked entities (based on the primary key, though it should "monitor" additions and deletions), hence the behavior you observe - the update test fails since the stale data is used.

    You have at least the following options:

    1. Create different scopes and contexts for seeding and the rest of manipulations (potentially the best one, you try wraping it in some helper method which will accept action/func to execute over context instance)
    2. Add dbContext.ChangeTracker.Clear(); calls after SaveChangesAsync in the seeding methods (like CreateRandomUserInDb or CreateRandomUsersInDb)
    3. Disable tracking for "verifications" as you do with AsNoTracking

    Potentially some more insights - How does caching work in Entity Framework? see code examples there.