diff --git a/readme.md b/readme.md index c8054e4d..cf383fce 100644 --- a/readme.md +++ b/readme.md @@ -26,7 +26,7 @@ Enable VerifyEntityFramework once at assembly load time: ```cs VerifyEntityFramework.Enable(); ``` -snippet source | anchor +snippet source | anchor @@ -58,7 +58,7 @@ builder.UseSqlServer(connection); builder.EnableRecording(); var data = new SampleDbContext(builder.Options); ``` -snippet source | anchor +snippet source | anchor `EnableRecording` should only be called in the test context. @@ -86,7 +86,7 @@ await data.Companies await Verify(data.Companies.Count()); ``` -snippet source | anchor +snippet source | anchor Will result in the following verified file: @@ -143,7 +143,7 @@ await Verify(new sql = entries }); ``` -snippet source | anchor +snippet source | anchor @@ -174,7 +174,7 @@ await data2.Companies await Verify(data2.Companies.Count()); ``` -snippet source | anchor +snippet source | anchor @@ -241,7 +241,7 @@ public async Task Added() await Verify(data.ChangeTracker); } ``` -snippet source | anchor +snippet source | anchor Will result in the following verified file: @@ -275,7 +275,7 @@ public async Task Deleted() var options = DbContextOptions(); await using var data = new SampleDbContext(options); - data.Add(new Company {Content = "before"}); + data.Add(new Company { Content = "before" }); await data.SaveChangesAsync(); var company = data.Companies.Single(); @@ -283,7 +283,7 @@ public async Task Deleted() await Verify(data.ChangeTracker); } ``` -snippet source | anchor +snippet source | anchor Will result in the following verified file: @@ -327,7 +327,7 @@ public async Task Modified() await Verify(data.ChangeTracker); } ``` -snippet source | anchor +snippet source | anchor Will result in the following verified file: @@ -362,7 +362,7 @@ var queryable = data.Companies .Where(x => x.Content == "value"); await Verify(queryable); ``` -snippet source | anchor +snippet source | anchor Will result in the following verified file: @@ -410,7 +410,7 @@ await Verify(data.AllData()) serializer => serializer.TypeNameHandling = TypeNameHandling.Objects)); ``` -snippet source | anchor +snippet source | anchor Will result in the following verified file with all data in the database: @@ -494,9 +494,60 @@ public async Task IgnoreNavigationProperties() x => x.IgnoreNavigationProperties(data)); } ``` -snippet source | anchor +snippet source | anchor +## WebApplicationFactory + +To be able to use [WebApplicationFactory](https://docs.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.mvc.testing.webapplicationfactory-1) for [integration testing](https://docs.microsoft.com/en-us/aspnet/core/test/integration-tests) +an identifier must be used to be able to retrive the recorded commands. Start by enable recording with a unique identifier, for example the test name or a GUID: + + + +```cs +.ConfigureTestServices(services => +{ + services.AddScoped>(_ => + { + return new DbContextOptionsBuilder() + .EnableRecording(testName) + .UseSqlite($"Data Source={testName};Mode=Memory;Cache=Shared") + .Options; + }); +}); +``` +snippet source | anchor + + +Then use the same identifer for recording: + + + +```cs +var httpClient = factory.CreateClient(); + +EfRecording.StartRecording(testName); + +var companies = await httpClient.GetFromJsonAsync("/companies"); + +var entries = EfRecording.FinishRecording(testName); +``` +snippet source | anchor + + +The results will not be automatically included in verified file so it will have to be verified manually: + + + +```cs +await Verify(new +{ + target = companies!.Length, + sql = entries +}); +``` +snippet source | anchor + ## Icon diff --git a/src/Verify.EntityFramework.Tests/CoreTests.RecordingWebApplicationFactory.verified.txt b/src/Verify.EntityFramework.Tests/CoreTests.RecordingWebApplicationFactory.verified.txt new file mode 100644 index 00000000..4404b68f --- /dev/null +++ b/src/Verify.EntityFramework.Tests/CoreTests.RecordingWebApplicationFactory.verified.txt @@ -0,0 +1,11 @@ +{ + target: 1, + sql: [ + { + Type: ReaderExecutedAsync, + Text: +SELECT "c"."Id", "c"."Content" +FROM "Companies" AS "c" + } + ] +} \ No newline at end of file diff --git a/src/Verify.EntityFramework.Tests/CoreTests.RecordingWebApplicationFactory9.verified.txt b/src/Verify.EntityFramework.Tests/CoreTests.RecordingWebApplicationFactory9.verified.txt new file mode 100644 index 00000000..4404b68f --- /dev/null +++ b/src/Verify.EntityFramework.Tests/CoreTests.RecordingWebApplicationFactory9.verified.txt @@ -0,0 +1,11 @@ +{ + target: 1, + sql: [ + { + Type: ReaderExecutedAsync, + Text: +SELECT "c"."Id", "c"."Content" +FROM "Companies" AS "c" + } + ] +} \ No newline at end of file diff --git a/src/Verify.EntityFramework.Tests/CoreTests.RecordingWebApplicationFactory_run=0.verified.txt b/src/Verify.EntityFramework.Tests/CoreTests.RecordingWebApplicationFactory_run=0.verified.txt new file mode 100644 index 00000000..4404b68f --- /dev/null +++ b/src/Verify.EntityFramework.Tests/CoreTests.RecordingWebApplicationFactory_run=0.verified.txt @@ -0,0 +1,11 @@ +{ + target: 1, + sql: [ + { + Type: ReaderExecutedAsync, + Text: +SELECT "c"."Id", "c"."Content" +FROM "Companies" AS "c" + } + ] +} \ No newline at end of file diff --git a/src/Verify.EntityFramework.Tests/CoreTests.RecordingWebApplicationFactory_run=1.verified.txt b/src/Verify.EntityFramework.Tests/CoreTests.RecordingWebApplicationFactory_run=1.verified.txt new file mode 100644 index 00000000..4404b68f --- /dev/null +++ b/src/Verify.EntityFramework.Tests/CoreTests.RecordingWebApplicationFactory_run=1.verified.txt @@ -0,0 +1,11 @@ +{ + target: 1, + sql: [ + { + Type: ReaderExecutedAsync, + Text: +SELECT "c"."Id", "c"."Content" +FROM "Companies" AS "c" + } + ] +} \ No newline at end of file diff --git a/src/Verify.EntityFramework.Tests/CoreTests.RecordingWebApplicationFactory_run=2.verified.txt b/src/Verify.EntityFramework.Tests/CoreTests.RecordingWebApplicationFactory_run=2.verified.txt new file mode 100644 index 00000000..4404b68f --- /dev/null +++ b/src/Verify.EntityFramework.Tests/CoreTests.RecordingWebApplicationFactory_run=2.verified.txt @@ -0,0 +1,11 @@ +{ + target: 1, + sql: [ + { + Type: ReaderExecutedAsync, + Text: +SELECT "c"."Id", "c"."Content" +FROM "Companies" AS "c" + } + ] +} \ No newline at end of file diff --git a/src/Verify.EntityFramework.Tests/CoreTests.RecordingWebApplicationFactory_run=3.verified.txt b/src/Verify.EntityFramework.Tests/CoreTests.RecordingWebApplicationFactory_run=3.verified.txt new file mode 100644 index 00000000..4404b68f --- /dev/null +++ b/src/Verify.EntityFramework.Tests/CoreTests.RecordingWebApplicationFactory_run=3.verified.txt @@ -0,0 +1,11 @@ +{ + target: 1, + sql: [ + { + Type: ReaderExecutedAsync, + Text: +SELECT "c"."Id", "c"."Content" +FROM "Companies" AS "c" + } + ] +} \ No newline at end of file diff --git a/src/Verify.EntityFramework.Tests/CoreTests.RecordingWebApplicationFactory_run=4.verified.txt b/src/Verify.EntityFramework.Tests/CoreTests.RecordingWebApplicationFactory_run=4.verified.txt new file mode 100644 index 00000000..4404b68f --- /dev/null +++ b/src/Verify.EntityFramework.Tests/CoreTests.RecordingWebApplicationFactory_run=4.verified.txt @@ -0,0 +1,11 @@ +{ + target: 1, + sql: [ + { + Type: ReaderExecutedAsync, + Text: +SELECT "c"."Id", "c"."Content" +FROM "Companies" AS "c" + } + ] +} \ No newline at end of file diff --git a/src/Verify.EntityFramework.Tests/CoreTests.cs b/src/Verify.EntityFramework.Tests/CoreTests.cs index 296d355d..7b3d3ccb 100644 --- a/src/Verify.EntityFramework.Tests/CoreTests.cs +++ b/src/Verify.EntityFramework.Tests/CoreTests.cs @@ -1,8 +1,17 @@ -using Microsoft.EntityFrameworkCore; +using System.Net.Http.Json; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; using Newtonsoft.Json; using VerifyTests.EntityFramework; [TestFixture] +[Parallelizable(ParallelScope.All)] public class CoreTests { #region Added @@ -31,7 +40,7 @@ public async Task Deleted() var options = DbContextOptions(); await using var data = new SampleDbContext(options); - data.Add(new Company {Content = "before"}); + data.Add(new Company { Content = "before" }); await data.SaveChangesAsync(); var company = data.Companies.Single(); @@ -158,7 +167,7 @@ public Task ShouldIgnoreDbContext() => Factory = new SampleDbContext(new DbContextOptions()) }); - class MyDbContextFactory: IDbContextFactory + class MyDbContextFactory : IDbContextFactory { public SampleDbContext CreateDbContext() => throw new NotImplementedException(); @@ -222,7 +231,7 @@ public async Task NestedQueryable() var data = database.Context; var queryable = data.Companies .Where(x => x.Content == "value"); - await Verify(new {queryable}); + await Verify(new { queryable }); } void Build(string connection) @@ -327,6 +336,99 @@ await data.Companies #endregion } + [DatapointSource] + public IEnumerable runs = Enumerable.Range(0, 5); + + [Theory] + public async Task RecordingWebApplicationFactory(int run) + { + // Not actually the test name, the variable name is for README.md to make sense + var testName = nameof(RecordingWebApplicationFactory) + run; + + using var connection = new SqliteConnection($"Data Source={testName};Mode=Memory;Cache=Shared"); + await connection.OpenAsync(); + + var factory = new CustomWebApplicationFactory(testName); + + using (var scope = factory.Services.CreateScope()) + { + var context = scope.ServiceProvider.GetRequiredService(); + + await context.Database.EnsureCreatedAsync(); + + context.Add(new Company { Id = 1, Content = "Foo" }); + + await context.SaveChangesAsync(); + } + + #region RecordWithIdentifier + var httpClient = factory.CreateClient(); + + EfRecording.StartRecording(testName); + + var companies = await httpClient.GetFromJsonAsync("/companies"); + + var entries = EfRecording.FinishRecording(testName); + #endregion + + #region VerifyRecordedCommandsWithIdentifier + await Verify(new + { + target = companies!.Length, + sql = entries + }); + #endregion + } + + class CustomWebApplicationFactory : WebApplicationFactory + { + readonly string testName; + + public CustomWebApplicationFactory(string testName) + { + this.testName = testName; + } + + protected override void ConfigureWebHost(IWebHostBuilder builder) => + builder + #region EnableRecordingWithIdentifier + .ConfigureTestServices(services => + { + services.AddScoped>(_ => + { + return new DbContextOptionsBuilder() + .EnableRecording(testName) + .UseSqlite($"Data Source={testName};Mode=Memory;Cache=Shared") + .Options; + }); + }); + #endregion + + protected override IHostBuilder CreateHostBuilder() => + Host.CreateDefaultBuilder() + .ConfigureWebHostDefaults(webBuilder => webBuilder.UseStartup()); + } + + public class Startup + { + public void ConfigureServices(IServiceCollection services) + { + services + .AddDbContext(builder => + { + builder.UseInMemoryDatabase(""); + }); + } + + public void Configure(IApplicationBuilder app) + { + app.UseRouting(); + + app.UseEndpoints(endpoints + => endpoints.MapGet("/companies", async (SampleDbContext data) => await data.Companies.ToListAsync())); + } + } + [Test] public async Task RecordingSpecific() { diff --git a/src/Verify.EntityFramework.Tests/Verify.EntityFramework.Tests.csproj b/src/Verify.EntityFramework.Tests/Verify.EntityFramework.Tests.csproj index da37736e..e63caffe 100644 --- a/src/Verify.EntityFramework.Tests/Verify.EntityFramework.Tests.csproj +++ b/src/Verify.EntityFramework.Tests/Verify.EntityFramework.Tests.csproj @@ -4,7 +4,9 @@ + + diff --git a/src/Verify.EntityFramework/LogCommandInterceptor.cs b/src/Verify.EntityFramework/LogCommandInterceptor.cs index ef5eb886..14422e98 100644 --- a/src/Verify.EntityFramework/LogCommandInterceptor.cs +++ b/src/Verify.EntityFramework/LogCommandInterceptor.cs @@ -1,4 +1,4 @@ -using System.Data.Common; +using System.Data.Common; using Microsoft.EntityFrameworkCore.Diagnostics; using VerifyTests.EntityFramework; @@ -6,8 +6,11 @@ class LogCommandInterceptor : DbCommandInterceptor { static AsyncLocal asyncLocal = new(); + static ConcurrentDictionary> namedEvents = new(StringComparer.OrdinalIgnoreCase); + readonly string? identifier; public static void Start() => asyncLocal.Value = new(); + public static void Start(string identifier) => namedEvents.GetOrAdd(identifier, _ => new()); public static IEnumerable? Stop() { @@ -16,6 +19,18 @@ class LogCommandInterceptor : return state?.Events.OrderBy(x => x.StartTime); } + public static IEnumerable? Stop(string identifier) + { + namedEvents.TryRemove(identifier, out var state); + + return state?.OrderBy(x => x.StartTime); + } + + public LogCommandInterceptor(string? identifier) + { + this.identifier = identifier; + } + public override void CommandFailed(DbCommand command, CommandErrorEventData data) => Add("CommandFailed", command, data, data.Exception); @@ -61,8 +76,17 @@ public override ValueTask NonQueryExecutedAsync(DbCommand command, CommandE return new(result); } - static void Add(string type, DbCommand command, CommandEndEventData data, Exception? exception = null) - => asyncLocal.Value?.WriteLine(new(type, command, data, exception)); + void Add(string type, DbCommand command, CommandEndEventData data, Exception? exception = null) + { + if (identifier is null) + { + asyncLocal.Value?.WriteLine(new(type, command, data, exception)); + } + else if (namedEvents.ContainsKey(identifier)) + { + namedEvents[identifier].Add(new(type, command, data, exception)); + } + } class State { diff --git a/src/Verify.EntityFramework/SqlRecording.cs b/src/Verify.EntityFramework/SqlRecording.cs index dedbf4e5..5a42c644 100644 --- a/src/Verify.EntityFramework/SqlRecording.cs +++ b/src/Verify.EntityFramework/SqlRecording.cs @@ -1,15 +1,23 @@ -using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; namespace VerifyTests.EntityFramework; public static class EfRecording { - public static void EnableRecording(this DbContextOptionsBuilder builder) - => builder.AddInterceptors(new LogCommandInterceptor()); + public static DbContextOptionsBuilder EnableRecording(this DbContextOptionsBuilder builder) + where TContext : DbContext + => builder.EnableRecording(null); + + public static DbContextOptionsBuilder EnableRecording(this DbContextOptionsBuilder builder, string? identifier) + where TContext : DbContext + => builder.AddInterceptors(new LogCommandInterceptor(identifier)); public static void StartRecording() => LogCommandInterceptor.Start(); + public static void StartRecording(string identifier) + => LogCommandInterceptor.Start(identifier); + public static IEnumerable FinishRecording() { var entries = LogCommandInterceptor.Stop(); @@ -19,4 +27,14 @@ public static IEnumerable FinishRecording() } throw new("No recorded state. It is possible `VerifyEntityFramework.StartRecording()` has not been called on the DbContext."); } + + public static IEnumerable FinishRecording(string identifier) + { + var entries = LogCommandInterceptor.Stop(identifier); + if (entries is not null) + { + return entries; + } + throw new("No recorded state. It is possible `VerifyEntityFramework.StartRecording(string identifier)` has not been called on the DbContext."); + } } \ No newline at end of file