Skip to content

Commit 41c52cf

Browse files
committed
Use Testcontainers to run tests on Linux and macOS
1 parent 32846ec commit 41c52cf

9 files changed

Lines changed: 161 additions & 12 deletions

src/Directory.Packages.props

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
<PackageVersion Include="Polyfill" Version="10.3.0" />
2222
<PackageVersion Include="ProjectDefaults" Version="1.0.173" />
2323
<PackageVersion Include="System.Data.SqlClient" Version="4.9.1" />
24+
<PackageVersion Include="Testcontainers.MsSql" Version="4.11.0" />
2425
<PackageVersion Include="Verify" Version="31.16.2" />
2526
<PackageVersion Include="Verify.DiffPlex" Version="3.1.2" />
2627
<PackageVersion Include="Verify.NUnit" Version="31.16.2" />

src/Verify.EntityFramework.Tests/DbUpdateExceptionTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ public class DbUpdateExceptionTests
44
[Test]
55
public async Task Run()
66
{
7-
var instance = new SqlInstance<TestDbContext>(builder => new(builder.Options));
7+
var instance = new SqlInstanceProvider<TestDbContext>(builder => new(builder.Options));
88
var id = Guid.NewGuid();
99
var entity = new TestEntity
1010
{
Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1-
global using System.ComponentModel.DataAnnotations.Schema;
1+
global using System.ComponentModel.DataAnnotations.Schema;
22
global using System.Net.Http.Json;
33
global using Argon;
4+
global using DotNet.Testcontainers.Containers;
45
global using Microsoft.AspNetCore.Builder;
56
global using Microsoft.AspNetCore.Hosting;
67
global using Microsoft.AspNetCore.Mvc.Testing;
78
global using Microsoft.AspNetCore.TestHost;
9+
global using Microsoft.Data.SqlClient;
810
global using Microsoft.Data.Sqlite;
911
global using Microsoft.EntityFrameworkCore;
1012
global using Microsoft.Extensions.DependencyInjection;
11-
global using Microsoft.Extensions.Hosting;
13+
global using Microsoft.Extensions.Hosting;
14+
global using Testcontainers.MsSql;
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
public sealed class ContainerSqlDatabase<TDbContext>(Func<TDbContext> dbContext) : ISqlDatabase<TDbContext>
2+
where TDbContext : DbContext
3+
{
4+
public string ConnectionString => Context.Database.GetConnectionString()!;
5+
6+
public TDbContext Context { get; } = dbContext();
7+
8+
public TDbContext NewDbContext() => dbContext();
9+
10+
public Task AddData(params object[] entities) => Context.AddData(entities);
11+
12+
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
13+
}

src/Verify.EntityFramework.Tests/Snippets/DbContextBuilder.cs

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// LocalDb is used to make the sample simpler.
1+
// LocalDb is used to make the sample simpler.
22
// Replace with a real DbContext
33

44
public static class DbContextBuilder
@@ -14,7 +14,7 @@ static DbContextBuilder()
1414
});
1515
descriptiveAliasSqlInstance = new(
1616
buildTemplate: CreateDb,
17-
storage: Storage.FromSuffix<SampleDbContext>("DescriptiveTableAliases"),
17+
storageSuffix: "DescriptiveTableAliases",
1818
constructInstance: builder =>
1919
{
2020
builder.EnableRecording();
@@ -23,7 +23,7 @@ static DbContextBuilder()
2323
});
2424
descriptiveParameterNamesSqlInstance = new(
2525
buildTemplate: CreateDb,
26-
storage: Storage.FromSuffix<SampleDbContext>("DescriptiveParameterNames"),
26+
storageSuffix: "DescriptiveParameterNames",
2727
constructInstance: builder =>
2828
{
2929
builder.EnableRecording();
@@ -32,9 +32,9 @@ static DbContextBuilder()
3232
});
3333
}
3434

35-
static SqlInstance<SampleDbContext> sqlInstance;
36-
static SqlInstance<SampleDbContext> descriptiveAliasSqlInstance;
37-
static SqlInstance<SampleDbContext> descriptiveParameterNamesSqlInstance;
35+
static SqlInstanceProvider<SampleDbContext> sqlInstance;
36+
static SqlInstanceProvider<SampleDbContext> descriptiveAliasSqlInstance;
37+
static SqlInstanceProvider<SampleDbContext> descriptiveParameterNamesSqlInstance;
3838

3939
static async Task CreateDb(SampleDbContext data)
4040
{
@@ -85,12 +85,12 @@ static async Task CreateDb(SampleDbContext data)
8585
await data.SaveChangesAsync();
8686
}
8787

88-
public static Task<SqlDatabase<SampleDbContext>> GetDatabase([CallerMemberName] string suffix = "")
88+
public static Task<ISqlDatabase<SampleDbContext>> GetDatabase([CallerMemberName] string suffix = "")
8989
=> sqlInstance.Build(suffix);
9090

91-
public static Task<SqlDatabase<SampleDbContext>> GetDescriptiveAliasDatabase([CallerMemberName] string suffix = "")
91+
public static Task<ISqlDatabase<SampleDbContext>> GetDescriptiveAliasDatabase([CallerMemberName] string suffix = "")
9292
=> descriptiveAliasSqlInstance.Build(suffix);
9393

94-
public static Task<SqlDatabase<SampleDbContext>> GetDescriptiveParameterNamesDatabase([CallerMemberName] string suffix = "")
94+
public static Task<ISqlDatabase<SampleDbContext>> GetDescriptiveParameterNamesDatabase([CallerMemberName] string suffix = "")
9595
=> descriptiveParameterNamesSqlInstance.Build(suffix);
9696
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
public interface ISqlDatabase<out TDbContext> : IAsyncDisposable
2+
where TDbContext : DbContext
3+
{
4+
string ConnectionString { get; }
5+
6+
TDbContext Context { get; }
7+
8+
TDbContext NewDbContext();
9+
10+
Task AddData(params object[] entities);
11+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
public sealed class LocalDbSqlDatabase<TDbContext>(SqlDatabase<TDbContext> sqlDatabase) : ISqlDatabase<TDbContext>
2+
where TDbContext : DbContext
3+
{
4+
public string ConnectionString { get; } = sqlDatabase.ConnectionString;
5+
6+
public TDbContext Context { get; } = sqlDatabase.Context;
7+
8+
public TDbContext NewDbContext() => sqlDatabase.NewDbContext();
9+
10+
public Task AddData(params object[] entities) => sqlDatabase.AddData(entities);
11+
12+
public ValueTask DisposeAsync() => sqlDatabase.DisposeAsync();
13+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
public class SqlInstanceProvider<TDbContext> : IAsyncDisposable
2+
where TDbContext : DbContext
3+
{
4+
readonly TemplateFromContext<TDbContext>? buildTemplate;
5+
readonly ConstructInstance<TDbContext> constructInstance;
6+
7+
SemaphoreSlim semaphore = new(1);
8+
SqlInstance<TDbContext>? sqlInstance;
9+
MsSqlContainer? sqlContainer;
10+
11+
public SqlInstanceProvider(ConstructInstance<TDbContext> constructInstance, TemplateFromContext<TDbContext>? buildTemplate = null, string? storageSuffix = null)
12+
{
13+
this.buildTemplate = buildTemplate;
14+
this.constructInstance = constructInstance;
15+
if (string.Equals(Environment.GetEnvironmentVariable("VERIFY_ENTITYFRAMEWORK_TESTS_SQLENGINE"), "Docker", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows())
16+
{
17+
var suffix = storageSuffix == null ? "" : $".{storageSuffix}";
18+
sqlContainer = new MsSqlBuilder("mcr.microsoft.com/mssql/server:2025-latest")
19+
.WithName($"Verify.EntityFramework.Tests{suffix}")
20+
.WithReuse(true)
21+
.Build();
22+
}
23+
else
24+
{
25+
sqlInstance = new(
26+
buildTemplate: buildTemplate,
27+
storage: storageSuffix == null ? null : Storage.FromSuffix<TDbContext>(storageSuffix),
28+
constructInstance: constructInstance);
29+
}
30+
}
31+
32+
public async Task<ISqlDatabase<TDbContext>> Build(IEnumerable<object> data, [CallerFilePath] string testFile = "", string? databaseSuffix = null, [CallerMemberName] string memberName = "")
33+
{
34+
if (sqlInstance != null)
35+
{
36+
var sqlDatabase = await sqlInstance.Build(data, testFile, databaseSuffix, memberName);
37+
return new LocalDbSqlDatabase<TDbContext>(sqlDatabase);
38+
}
39+
40+
if (sqlContainer != null)
41+
{
42+
var testClass = Path.GetFileNameWithoutExtension(testFile);
43+
var dbName = databaseSuffix == null ? $"{testClass}_{memberName}" : $"{testClass}_{memberName}_{databaseSuffix}";
44+
var sqlDatabase = await Build(dbName);
45+
await sqlDatabase.AddData(data);
46+
return sqlDatabase;
47+
}
48+
49+
throw new UnreachableException("Both sqlInstance and sqlContainer can't be null at the same time");
50+
}
51+
52+
public async Task<ISqlDatabase<TDbContext>> Build([CallerMemberName] string dbName = "")
53+
{
54+
if (sqlInstance != null)
55+
{
56+
var sqlDatabase = await sqlInstance.Build(dbName);
57+
return new LocalDbSqlDatabase<TDbContext>(sqlDatabase);
58+
}
59+
60+
if (sqlContainer != null)
61+
{
62+
await semaphore.WaitAsync();
63+
try
64+
{
65+
if (sqlContainer.State != TestcontainersStates.Running)
66+
{
67+
await sqlContainer.StartAsync();
68+
}
69+
}
70+
finally
71+
{
72+
semaphore.Release();
73+
}
74+
75+
var result = await sqlContainer.ExecScriptAsync($"DROP DATABASE IF EXISTS [{dbName}]");
76+
if (result.ExitCode != 0)
77+
{
78+
throw new InvalidOperationException($"Failed to drop database '{dbName}' ({result.ExitCode})\n{result.Stdout}\n{result.Stderr}");
79+
}
80+
81+
await using var dbContext = CreateDbContext(dbName);
82+
await dbContext.Database.EnsureCreatedAsync();
83+
84+
if (buildTemplate != null)
85+
{
86+
await buildTemplate(dbContext);
87+
}
88+
89+
return new ContainerSqlDatabase<TDbContext>(() => CreateDbContext(dbName));
90+
}
91+
92+
throw new UnreachableException("Both sqlInstance and sqlContainer can't be null at the same time");
93+
}
94+
95+
private TDbContext CreateDbContext(string dbName)
96+
{
97+
var connectionString = new SqlConnectionStringBuilder(sqlContainer!.GetConnectionString()) { InitialCatalog = dbName }.ConnectionString;
98+
return constructInstance(new DbContextOptionsBuilder<TDbContext>().UseSqlServer(connectionString));
99+
}
100+
101+
public ValueTask DisposeAsync()
102+
{
103+
semaphore.Dispose();
104+
sqlInstance?.Dispose();
105+
return sqlContainer?.DisposeAsync() ?? ValueTask.CompletedTask;
106+
}
107+
}

src/Verify.EntityFramework.Tests/Verify.EntityFramework.Tests.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" />
1010
<PackageReference Include="NUnit" />
1111
<PackageReference Include="NUnit3TestAdapter" />
12+
<PackageReference Include="Testcontainers.MsSql" />
1213
<PackageReference Include="Verify.DiffPlex" />
1314
<PackageReference Include="MarkdownSnippets.MsBuild" PrivateAssets="all" />
1415
<PackageReference Include="Verify.NUnit" />

0 commit comments

Comments
 (0)