Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

TransactionScope not working with HttpClient in integration tests #18001

Closed
niksloter74 opened this issue Dec 22, 2019 · 9 comments
Closed

TransactionScope not working with HttpClient in integration tests #18001

niksloter74 opened this issue Dec 22, 2019 · 9 comments
Assignees
Labels
area-mvc Includes: MVC, Actions and Controllers, Localization, CORS, most templates investigate
Milestone

Comments

@niksloter74
Copy link

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

`

Further technical details

  • ASP.NET Core version 3.1.0

.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]

  • Visual Studio 2019 Community
@niksloter74
Copy link
Author

Similar issue: dotnet/efcore#18657

@pranavkm
Copy link
Contributor

All yours @javiercn

@pranavkm pranavkm added area-mvc Includes: MVC, Actions and Controllers, Localization, CORS, most templates investigate labels Dec 23, 2019
@pranavkm pranavkm added this to the 5.0.0-preview1 milestone Dec 23, 2019
@NickFranceschina
Copy link

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?

@davidfowl
Copy link
Member

davidfowl commented Dec 24, 2019

This is by design but there’s 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

@niksloter74
Copy link
Author

niksloter74 commented Dec 24, 2019

@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

@NickFranceschina
Copy link

it's a 🎄 miracle! thank you!

@moklas
Copy link

moklas commented Dec 24, 2019

Yes @NickFranceschina, a xmas miracle indeed! Thank you @niksloter74 @davidfowl for this xmas gift 😄

@reinseth
Copy link

reinseth commented May 5, 2020

@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

@davidfowl
Copy link
Member

We should document this change @Tratcher

@ghost ghost locked as resolved and limited conversation to collaborators Jul 9, 2020
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
area-mvc Includes: MVC, Actions and Controllers, Localization, CORS, most templates investigate
Projects
None yet
Development

No branches or pull requests

8 participants