Search code examples
asp.net-coreentity-framework-coreintegration-testing

TransactionScope not working with HttpClient in integration tests


Describe the bug

After upgrading from .net core 2.2 to 3.1, integration tests are failing.
All tests are wrapped in TransactionScope so that all changes to db should be revered (scope.Complete() is not called).

When call to the data access layer is made through api (HttpClient) records are created in the database, but they should not be since the entire test is wrapped in TransactionScope.

To Reproduce

public class Entity
{
    public int Id { get; set; }
    public string Name { get; set; }
}

public class CustomDbContext : DbContext
{
    private const string DefaultConnectionString = "Server=.;Initial Catalog=WebApi;Trusted_Connection=True;";
    private readonly string _connectionString;

    public CustomDbContext() : this(DefaultConnectionString)
    {
    }

    public CustomDbContext(string connectionString)
    {
        _connectionString = connectionString;
    }

    public DbSet<Entity> Entities { get; set; }


    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder.UseSqlServer(_connectionString);
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.ApplyConfiguration(new EntityConfiguration());
    }

    public async Task Save<TModel>(TModel model)
    {
        using var scope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled);
        {
            Update(model);
            await SaveChangesAsync();
            scope.Complete();
        }
    }
}

public class EntityService : IEntityService
{
    private readonly CustomDbContext _db;

    public EntityService(CustomDbContext db)
    {
        _db = db;
    }

    public async Task Save(Entity model) => await _db.Save(model);
}

[ApiController]
[Route("[controller]")]
public class EntityController : ControllerBase
{
    private readonly IEntityService _service;

    public EntityController(IEntityService service)
    {
        _service = service;
    }

    [HttpPost]
    public async Task<IActionResult> Save(Entity model)
    {
        await _service.Save(model);
        return Ok();
    }
}

public class Startup
{
    public Startup(IConfiguration configuration)
    {
        Configuration = configuration;
    }

    public IConfiguration Configuration { get; }

    // This method gets called by the runtime. Use this method to add services to the container.
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddControllers();

        services.AddScoped<CustomDbContext>();

        services.AddScoped<IEntityService, EntityService>();
    }

    // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
    public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
    {
        if (env.IsDevelopment())
        {
            app.UseDeveloperExceptionPage();
        }

        app.UseHttpsRedirection();

        app.UseRouting();

        app.UseAuthorization();

        app.UseEndpoints(endpoints =>
        {
            endpoints.MapControllers();
        });
    }
}

/// <summary>
/// Apply this attribute to your test method to automatically create a <see cref="TransactionScope"/>
/// that is rolled back when the test is finished.
/// </summary>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)]
public sealed class AutoRollbackAttribute : BeforeAfterTestAttribute
{
    TransactionScope scope;

    /// <summary>
    /// Gets or sets whether transaction flow across thread continuations is enabled for TransactionScope.
    /// By default transaction flow across thread continuations is enabled.
    /// </summary>
    public TransactionScopeAsyncFlowOption AsyncFlowOption { get; set; } = TransactionScopeAsyncFlowOption.Enabled;

    /// <summary>
    /// Gets or sets the isolation level of the transaction.
    /// Default value is <see cref="IsolationLevel"/>.Unspecified.
    /// </summary>
    public IsolationLevel IsolationLevel { get; set; } = IsolationLevel.Unspecified;

    /// <summary>
    /// Gets or sets the scope option for the transaction.
    /// Default value is <see cref="TransactionScopeOption"/>.Required.
    /// </summary>
    public TransactionScopeOption ScopeOption { get; set; } = TransactionScopeOption.Required;

    /// <summary>
    /// Gets or sets the timeout of the transaction, in milliseconds.
    /// By default, the transaction will not timeout.
    /// </summary>
    public long TimeoutInMS { get; set; } = -1;

    /// <summary>
    /// Rolls back the transaction.
    /// </summary>
    public override void After(MethodInfo methodUnderTest)
    {
        scope.Dispose();
    }

    /// <summary>
    /// Creates the transaction.
    /// </summary>
    public override void Before(MethodInfo methodUnderTest)
    {
        var options = new TransactionOptions { IsolationLevel = IsolationLevel };
        if (TimeoutInMS > 0)
            options.Timeout = TimeSpan.FromMilliseconds(TimeoutInMS);

        scope = new TransactionScope(ScopeOption, options, AsyncFlowOption);
    }
}

public class CustomWebApplicationFactory : WebApplicationFactory<Startup>
{
    private const string TestDbConnectionString = "Server=.;Initial Catalog=WebApiTestDB_V3;Trusted_Connection=True;";

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureTestServices(services =>
        {
            services.AddSingleton(_ => new CustomDbContext(TestDbConnectionString));

            var sp = services.BuildServiceProvider();
            var db = sp.GetRequiredService<CustomDbContext>();
            db.Database.Migrate();
        });
    }
}

public class IntegrationTest : IClassFixture<CustomWebApplicationFactory>
{
    protected readonly HttpClient _client;
    protected readonly IServiceProvider _serviceProvider;
    protected readonly CustomDbContext _db;

    public IntegrationTest(CustomWebApplicationFactory factory)
    {
        _client = factory.CreateClient();
        _serviceProvider = factory.Services.CreateScope().ServiceProvider;
        _db = _serviceProvider.GetRequiredService<CustomDbContext>();
    }

    protected void DetachAll()
    {
        _db.ChangeTracker.Entries()
            .ToList()
            .ForEach(e => e.State = EntityState.Detached);
    }

    protected async Task<Entity> AddTestEntity()
    {
        var model = new Entity
        {
            Name = "test entity"
        };
        await _db.AddAsync(model);
        await _db.SaveChangesAsync();
        return model;
    }
}

public static class HttpContentHelper
{
    public static HttpContent GetJsonContent(object model) =>
        new StringContent(JsonConvert.SerializeObject(model), Encoding.UTF8, "application/json");
}
[AutoRollback]
public class EntityIntegrationTest : IntegrationTest
{
    private const string apiUrl = "/entity";
    public EntityIntegrationTest(CustomWebApplicationFactory factory) : base(factory)
    {
    }

    [Fact]
    public async Task CanAdd()
    {
        // arrange
        var model = new Entity
        {
            Name = "new entity"
        };
        var content = HttpContentHelper.GetJsonContent(model);

        // act
        var response = await _client.PostAsync(apiUrl, content);

        // assert
        response.EnsureSuccessStatusCode();
        var result = await _db.Entities.FirstOrDefaultAsync();
        Assert.Equal(model.Name, result.Name);
    }

    [Fact]
    public async Task CanUpdate()
    {
        // arrange
        var model = await AddTestEntity();
        DetachAll(); // detach all entries because posting to api would create a new model, saving a new object with existing key throws entity already tracked exception
        model.Name = "updated entity";
        var content = HttpContentHelper.GetJsonContent(model);

        // act
        var response = await _client.PostAsync(apiUrl, content);

        // assert
        response.EnsureSuccessStatusCode();
        var result = await _db.Entities.FirstOrDefaultAsync();
        Assert.Equal(model.Id, result.Id);
        Assert.Equal(model.Name, result.Name);
    }

    [Fact]
    public async Task CannotInsertDuplicate()
    {
        // arrange
        var entity = await AddTestEntity();
        var model = new Entity
        {
            Name = entity.Name
        };
        var content = HttpContentHelper.GetJsonContent(model);

        // act
        var response = await _client.PostAsync(apiUrl, content);

        // assert
        var result = await response.Content.ReadAsStringAsync();
        Assert.Contains("Cannot insert duplicate", result);
    }
}

There are many files/classes involved so I've created a example repository

Example tests that are failing are in https://github.com/niksloter74/web-api-integration-test/tree/master/netcore3.1

Working example in .net core 2.2 https://github.com/niksloter74/web-api-integration-test/tree/master/netcore2.2

Direct test for service layer is working correctly

[AutoRollback]
public class EntityServiceTest : IntegrationTest
{
    private readonly IEntityService service;

    public EntityServiceTest(CustomWebApplicationFactory factory) : base(factory)
    {
        service = _serviceProvider.GetRequiredService<IEntityService>();
    }

    [Fact]
    public async Task CanAdd()
    {
        // arrange
        var model = new Entity
        {
            Name = "new entity"
        };

        // act
        await service.Save(model);

        // assert
        var result = await _db.Entities.FirstOrDefaultAsync();
        Assert.Equal(model.Name, result.Name);
    }

    [Fact]
    public async Task CanUpdate()
    {
        // arrange
        var model = await AddTestEntity();
        model.Name = "updated entity";

        // act
        await service.Save(model);

        // assert
        var result = await _db.Entities.FirstOrDefaultAsync();
        Assert.Equal(model.Id, result.Id);
        Assert.Equal(model.Name, result.Name);
    }

    [Fact]
    public async Task CannotInsertDuplicate()
    {
        // arrange
        var entity = await AddTestEntity();
        var model = new Entity
        {
            Name = entity.Name
        };

        // act
        var ex = await Assert.ThrowsAnyAsync<Exception>(async () => await service.Save(model));

        // assert
        Assert.StartsWith("Cannot insert duplicate", ex.InnerException.Message);
    }
}

Solution

  • This is by design but there’s a flag to get the old behavior back on TestServer called PreserveExecutionContext.

    Here is an official discussion thread.

    This line in IntegartionTest class fixed the problem
    _factory.Server.PreserveExecutionContext = true;

    I've also updated the repository