Skip to content

Commit 220097b

Browse files
committed
Split the base classes for integration testing in 2 classes so the DbContexts are accessible without inheriting from a base class
1 parent d59f755 commit 220097b

File tree

30 files changed

+1616
-819
lines changed

30 files changed

+1616
-819
lines changed

src/Thinktecture.EntityFrameworkCore.SqlServer.Testing/EntityFrameworkCore/Testing/SqlServerDbContextIntegrationTests.cs

Lines changed: 46 additions & 340 deletions
Large diffs are not rendered by default.
Lines changed: 317 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,317 @@
1+
using System.Collections.Concurrent;
2+
using System.Data;
3+
using System.Data.Common;
4+
using Microsoft.Data.SqlClient;
5+
using Microsoft.EntityFrameworkCore.Infrastructure;
6+
using Microsoft.EntityFrameworkCore.Migrations;
7+
using Microsoft.EntityFrameworkCore.Storage;
8+
9+
namespace Thinktecture.EntityFrameworkCore.Testing;
10+
11+
/// <summary>
12+
/// Provides instances of <see cref="DbContext"/> for testing purposes.
13+
/// </summary>
14+
/// <typeparam name="T">Type of the database context.</typeparam>
15+
public class SqlServerTestDbContextProvider<T> : ITestDbContextProvider<T>
16+
where T : DbContext
17+
{
18+
// ReSharper disable once StaticMemberInGenericType because the locks are all for the same database context but for different schemas.
19+
private static readonly ConcurrentDictionary<string, object> _locks = new(StringComparer.OrdinalIgnoreCase);
20+
21+
private readonly bool _isUsingSharedTables;
22+
private readonly DbContextOptions<T> _masterDbContextOptions;
23+
private readonly DbContextOptions<T> _dbContextOptions;
24+
private readonly IMigrationExecutionStrategy _migrationExecutionStrategy;
25+
private readonly DbConnection _masterConnection;
26+
private readonly IReadOnlyList<Action<T>> _contextInitializations;
27+
private readonly Func<DbContextOptions<T>, IDbDefaultSchema, T>? _contextFactory;
28+
29+
private T? _arrangeDbContext;
30+
private T? _actDbContext;
31+
private T? _assertDbContext;
32+
private IDbContextTransaction? _tx;
33+
private bool _isAtLeastOneContextCreated;
34+
35+
/// <inheritdoc />
36+
public T ArrangeDbContext => _arrangeDbContext ??= CreateDbContext(true);
37+
38+
/// <inheritdoc />
39+
public T ActDbContext => _actDbContext ??= CreateDbContext(true);
40+
41+
/// <inheritdoc />
42+
public T AssertDbContext => _assertDbContext ??= CreateDbContext(true);
43+
44+
/// <summary>
45+
/// Database schema to use.
46+
/// </summary>
47+
// ReSharper disable once MemberCanBePrivate.Global
48+
public string Schema { get; }
49+
50+
/// <summary>
51+
/// Contains executed commands if this feature was activated.
52+
/// </summary>
53+
public IReadOnlyCollection<string>? ExecutedCommands { get; }
54+
55+
/// <summary>
56+
/// Initializes a new instance of <see cref="SqlServerTestDbContextProvider{T}"/>
57+
/// </summary>
58+
/// <param name="options">Options.</param>
59+
protected internal SqlServerTestDbContextProvider(SqlServerTestDbContextProviderOptions<T> options)
60+
{
61+
ArgumentNullException.ThrowIfNull(options);
62+
63+
Schema = options.Schema ?? throw new ArgumentException($"The '{nameof(options.Schema)}' cannot be null.", nameof(options));
64+
_isUsingSharedTables = options.IsUsingSharedTables;
65+
_masterConnection = options.MasterConnection ?? throw new ArgumentException($"The '{nameof(options.MasterConnection)}' cannot be null.", nameof(options));
66+
_masterDbContextOptions = options.MasterDbContextOptions ?? throw new ArgumentException($"The '{nameof(options.MasterDbContextOptions)}' cannot be null.", nameof(options));
67+
_dbContextOptions = options.DbContextOptions ?? throw new ArgumentException($"The '{nameof(options.DbContextOptions)}' cannot be null.", nameof(options));
68+
_migrationExecutionStrategy = options.MigrationExecutionStrategy ?? throw new ArgumentException($"The '{nameof(options.MigrationExecutionStrategy)}' cannot be null.", nameof(options));
69+
ExecutedCommands = options.ExecutedCommands;
70+
_contextInitializations = options.ContextInitializations ?? throw new ArgumentException($"The '{nameof(options.ContextInitializations)}' cannot be null.", nameof(options));
71+
_contextFactory = options.ContextFactory;
72+
}
73+
74+
/// <inheritdoc />
75+
public T CreateDbContext()
76+
{
77+
return CreateDbContext(false);
78+
}
79+
80+
/// <summary>
81+
/// Creates a new <see cref="DbContext"/>.
82+
/// </summary>
83+
/// <param name="useMasterConnection">
84+
/// Indication whether to use the master connection or a new one.
85+
/// </param>
86+
/// <returns>A new instance of <typeparamref name="T"/>.</returns>
87+
public T CreateDbContext(bool useMasterConnection)
88+
{
89+
if (!useMasterConnection && _isUsingSharedTables)
90+
throw new NotSupportedException($"A database transaction cannot be shared among different connections, so the isolation of tests couldn't be guaranteed. Set 'useMasterConnection' to 'true' or 'useSharedTables' to 'false' or use '{nameof(ArrangeDbContext)}/{nameof(ActDbContext)}/{nameof(AssertDbContext)}' which use the same database connection.");
91+
92+
bool isFirstCtx;
93+
94+
lock (_locks.GetOrAdd(Schema, _ => new object()))
95+
{
96+
isFirstCtx = !_isAtLeastOneContextCreated;
97+
_isAtLeastOneContextCreated = true;
98+
}
99+
100+
var options = useMasterConnection ? _masterDbContextOptions : _dbContextOptions;
101+
var ctx = CreateDbContext(options, new DbDefaultSchema(Schema));
102+
103+
foreach (var ctxInit in _contextInitializations)
104+
{
105+
ctxInit(ctx);
106+
}
107+
108+
if (isFirstCtx)
109+
{
110+
RunMigrations(ctx);
111+
112+
if (_isUsingSharedTables)
113+
_tx = BeginTransaction(ctx);
114+
}
115+
else if (_tx != null)
116+
{
117+
ctx.Database.UseTransaction(_tx.GetDbTransaction());
118+
}
119+
120+
return ctx;
121+
}
122+
123+
/// <summary>
124+
/// Creates a new instance of the database context.
125+
/// </summary>
126+
/// <param name="options">Options to use for creation.</param>
127+
/// <param name="schema">Database schema to use.</param>
128+
/// <returns>A new instance of the database context.</returns>
129+
protected virtual T CreateDbContext(DbContextOptions<T> options, IDbDefaultSchema schema)
130+
{
131+
if (_contextFactory is not null)
132+
return _contextFactory(options, schema) ?? throw new Exception("The provided context factory must not return 'null'.");
133+
134+
var ctx = Activator.CreateInstance(typeof(T), options, schema);
135+
136+
if (ctx is null)
137+
{
138+
if (!_isUsingSharedTables)
139+
{
140+
throw new Exception(@$"Could not create an instance of type of '{typeof(T).ShortDisplayName()}' using constructor parameters ({typeof(DbContextOptions<T>).ShortDisplayName()} options, {nameof(IDbDefaultSchema)} schema).
141+
Please provide the corresponding constructor or a custom factory via '{typeof(SqlServerTestDbContextProviderBuilder<T>).ShortDisplayName()}.{nameof(SqlServerTestDbContextProviderBuilder<T>.UseContextFactory)}'.");
142+
}
143+
144+
ctx = Activator.CreateInstance(typeof(T), options)
145+
?? throw new Exception(@$"Could not create an instance of type of '{typeof(T).ShortDisplayName()}' neither using constructor parameters ({typeof(DbContextOptions<T>).ShortDisplayName()} options, {nameof(IDbDefaultSchema)} schema) nor using construct ({typeof(DbContextOptions<T>).ShortDisplayName()} options).
146+
Please provide the corresponding constructor or a custom factory via '{typeof(SqlServerTestDbContextProviderBuilder<T>).ShortDisplayName()}.{nameof(SqlServerTestDbContextProviderBuilder<T>.UseContextFactory)}'.");
147+
}
148+
149+
return (T)ctx;
150+
}
151+
152+
/// <summary>
153+
/// Starts a new transaction.
154+
/// </summary>
155+
/// <param name="ctx">Database context.</param>
156+
/// <returns>An instance of <see cref="IDbContextTransaction"/>.</returns>
157+
protected virtual IDbContextTransaction BeginTransaction(T ctx)
158+
{
159+
ArgumentNullException.ThrowIfNull(ctx);
160+
161+
return ctx.Database.BeginTransaction(IsolationLevel.ReadCommitted);
162+
}
163+
164+
/// <summary>
165+
/// Runs migrations for provided <paramref name="ctx" />.
166+
/// </summary>
167+
/// <param name="ctx">Database context to run migrations for.</param>
168+
/// <exception cref="ArgumentNullException">The provided context is <c>null</c>.</exception>
169+
protected virtual void RunMigrations(T ctx)
170+
{
171+
ArgumentNullException.ThrowIfNull(ctx);
172+
173+
// concurrent execution is not supported by EF migrations
174+
lock (_locks.GetOrAdd(Schema, _ => new object()))
175+
{
176+
_migrationExecutionStrategy.Migrate(ctx);
177+
}
178+
}
179+
180+
/// <summary>
181+
/// Rollbacks transaction if shared tables are used
182+
/// otherwise the migrations are rolled back and all tables, functions, views and the newly generated schema are deleted.
183+
/// </summary>
184+
public void Dispose()
185+
{
186+
Dispose(true);
187+
GC.SuppressFinalize(this);
188+
}
189+
190+
/// <summary>
191+
/// Disposes of inner resources.
192+
/// </summary>
193+
/// <param name="disposing">Indication whether this method is being called by the method <see cref="SqlServerTestDbContextProvider{T}.Dispose()"/>.</param>
194+
protected virtual void Dispose(bool disposing)
195+
{
196+
if (!disposing)
197+
return;
198+
199+
if (_isAtLeastOneContextCreated)
200+
{
201+
DisposeContextsAndRollbackMigrations();
202+
_isAtLeastOneContextCreated = false;
203+
}
204+
205+
_masterConnection.Dispose();
206+
}
207+
208+
private void DisposeContextsAndRollbackMigrations()
209+
{
210+
if (_isUsingSharedTables)
211+
{
212+
_tx?.Rollback();
213+
_tx?.Dispose();
214+
}
215+
else
216+
{
217+
// Create a new ctx as a last resort to rollback migrations and clean up the database
218+
using var ctx = _actDbContext ?? _arrangeDbContext ?? _assertDbContext ?? CreateDbContext(true);
219+
220+
RollbackMigrations(ctx);
221+
CleanUpDatabase(ctx, Schema);
222+
}
223+
224+
_arrangeDbContext?.Dispose();
225+
_actDbContext?.Dispose();
226+
_assertDbContext?.Dispose();
227+
228+
_arrangeDbContext = null;
229+
_actDbContext = null;
230+
_assertDbContext = null;
231+
}
232+
233+
private static void RollbackMigrations(T ctx)
234+
{
235+
ArgumentNullException.ThrowIfNull(ctx);
236+
237+
ctx.GetService<IMigrator>().Migrate("0");
238+
}
239+
240+
private static void CleanUpDatabase(T ctx, string schema)
241+
{
242+
ArgumentNullException.ThrowIfNull(ctx);
243+
ArgumentNullException.ThrowIfNull(schema);
244+
245+
var sqlHelper = ctx.GetService<ISqlGenerationHelper>();
246+
247+
ctx.Database.ExecuteSqlRaw(GetSqlForCleanup(), new SqlParameter("@schema", schema));
248+
ctx.Database.ExecuteSqlRaw(GetDropSchemaSql(sqlHelper, schema));
249+
}
250+
251+
private static string GetDropSchemaSql(ISqlGenerationHelper sqlHelper, string schema)
252+
{
253+
ArgumentNullException.ThrowIfNull(sqlHelper);
254+
255+
return $"DROP SCHEMA {sqlHelper.DelimitIdentifier(schema)}";
256+
}
257+
258+
private static string GetSqlForCleanup()
259+
{
260+
return @"
261+
DECLARE @crlf NVARCHAR(MAX) = CHAR(13) + CHAR(10);
262+
DECLARE @sql NVARCHAR(MAX);
263+
DECLARE @cursor CURSOR
264+
265+
-- Drop Constraints
266+
SET @cursor = CURSOR FAST_FORWARD FOR
267+
SELECT DISTINCT sql = 'ALTER TABLE ' + QUOTENAME(tc.TABLE_SCHEMA) + '.' + QUOTENAME(tc.TABLE_NAME) + ' DROP ' + QUOTENAME(rc.CONSTRAINT_NAME) + ';' + @crlf
268+
FROM
269+
INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS rc
270+
LEFT JOIN
271+
INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc
272+
ON tc.CONSTRAINT_NAME = rc.CONSTRAINT_NAME
273+
WHERE
274+
tc.TABLE_SCHEMA = @schema
275+
276+
OPEN @cursor FETCH NEXT FROM @cursor INTO @sql
277+
278+
WHILE (@@FETCH_STATUS = 0)
279+
BEGIN
280+
Exec sp_executesql @sql
281+
FETCH NEXT FROM @cursor INTO @sql
282+
END
283+
284+
CLOSE @cursor
285+
DEALLOCATE @cursor
286+
287+
-- Drop Views
288+
SELECT @sql = N'';
289+
SELECT @sql = @sql + 'DROP VIEW ' + QUOTENAME(SCHEMA_NAME(schema_id)) + '.' + QUOTENAME(name) +';' + @crlf
290+
FROM
291+
SYS.VIEWS
292+
WHERE
293+
schema_id = SCHEMA_ID(@schema)
294+
295+
EXEC(@sql);
296+
297+
-- Drop Functions
298+
SELECT @sql = N'';
299+
SELECT @sql = @sql + N' DROP FUNCTION ' + QUOTENAME(SCHEMA_NAME(schema_id)) + N'.' + QUOTENAME(name)
300+
FROM sys.objects
301+
WHERE type_desc LIKE '%FUNCTION%'
302+
AND schema_id = SCHEMA_ID(@schema);
303+
304+
EXEC(@sql);
305+
306+
-- Drop tables
307+
SELECT @sql = N'';
308+
SELECT @sql = @sql + 'DROP TABLE ' + QUOTENAME(SCHEMA_NAME(schema_id)) + '.' + QUOTENAME(name) +';' + @crlf
309+
FROM
310+
SYS.TABLES
311+
WHERE
312+
schema_id = SCHEMA_ID(@schema)
313+
314+
EXEC(@sql);
315+
";
316+
}
317+
}

0 commit comments

Comments
 (0)