TransactionScope не работает с HttpClient в интеграционных тестах - PullRequest
0 голосов
/ 22 декабря 2019

Опишите ошибку

После обновления с .net core 2.2 до 3.1 интеграционные тесты не пройдены. Все тесты обернуты в TransactionScope, поэтому все изменения в БД должны уважаться (scope.Complete () не вызывается). Когда выполняется вызов уровня доступа к данным через API (HttpClient), в базе данных создаются записи, но этого не должно быть, поскольку весь тест обернут в 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);
    }
}

Здесь задействовано много файлов / классов, поэтому я создал пример репозитория

Примеры тестов, которые не пройдены, находятся в https://github.com/niksloter74/web-api-integration-test/tree/master/netcore3.1

Рабочий пример в .net core 2.2 https://github.com/niksloter74/web-api-integration-test/tree/master/netcore2.2

Прямой тест для сервисного уровня работает правильно

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