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.
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);
}
}
`
.NET Core SDK (reflecting any global.json):
Version: 3.1.100
Commit: cd82f021f4
Runtime Environment:
OS Name: Windows
OS Version: 6.3.9600
OS Platform: Windows
RID: win81-x64
Base Path: C:\Program Files\dotnet\sdk\3.1.100\
Host (useful for support):
Version: 3.1.0
Commit: 65f04fb6db
.NET Core SDKs installed:
2.1.2 [C:\Program Files\dotnet\sdk]
2.1.202 [C:\Program Files\dotnet\sdk]
2.1.505 [C:\Program Files\dotnet\sdk]
2.1.507 [C:\Program Files\dotnet\sdk]
2.1.700 [C:\Program Files\dotnet\sdk]
2.1.701 [C:\Program Files\dotnet\sdk]
2.2.107 [C:\Program Files\dotnet\sdk]
2.2.300 [C:\Program Files\dotnet\sdk]
2.2.301 [C:\Program Files\dotnet\sdk]
3.1.100 [C:\Program Files\dotnet\sdk]
Similar issue: https://github.com/aspnet/EntityFrameworkCore/issues/18657
All yours @javiercn
I'm in the exact same boat. Upgrading from 2.2 to 3.1. Existing code is using the AutoRollbackAttribute from xUnit ( also mentioned here and here ). So basically, a TransactionScope is created before the test is run... and I can see that System.Transaction.Transactions.Current is not null (as expected)... but then inside the TestServer/Controller method, System.Transaction.Transactions.Current is null in the 3.1 upgrade, where it is not-null in the original 2.2 project. Spent all day working on this and haven't come to any conclusions.
The Entity Framework guys seem to say "it's working for us" ... but I'm not using EF, I'm using Dapper... so I don't have "DbContext" to put into DI... so maybe they have more control over the situation? Thinking maybe I should just use DI to register a custom Db Connection Factory where I can have more explicit control over the connections/transaction-enlistment, rather than relying on the "magic" in Ambient Transactions? Do I really need to do all that tho? I _feels like_ something is missing?
This is by design but there鈥檚 a flag to get the old behavior back on TestServer called PreserveExecutionContext. We basically by default want to avoid it flowing from client to server by default but for more esoteric cases, you can turn it on
@davidfowl Thanks! This line fixed the problem
_factory.Server.PreserveExecutionContext = true;
I also updated the repository
Ticket can be closed, if you do not want to investigate further
it's a 馃巹 miracle! thank you!
Yes @NickFranceschina, a xmas miracle indeed! Thank you @niksloter74 @davidfowl for this xmas gift 馃槃
@davidfowl: I'm curious as to why you made such a breaking change without giving notice? It should definitively be in this document: https://docs.microsoft.com/en-us/dotnet/core/compatibility/2.2-3.1
We should document this change @Tratcher
Most helpful comment
This is by design but there鈥檚 a flag to get the old behavior back on TestServer called PreserveExecutionContext. We basically by default want to avoid it flowing from client to server by default but for more esoteric cases, you can turn it on