| | | 1 | | #region Usings |
| | | 2 | | using System.Data.Common; |
| | | 3 | | using System.Diagnostics.CodeAnalysis; |
| | | 4 | | using Microsoft.Data.SqlClient; |
| | | 5 | | using Respawn; |
| | | 6 | | using Respawn.Graph; |
| | | 7 | | using Testcontainers.MsSql; |
| | | 8 | | #endregion |
| | | 9 | | |
| | | 10 | | namespace ZhEaIsNsAaBn.IntegrationTestingBases; |
| | | 11 | | |
| | | 12 | | /// <summary> |
| | | 13 | | /// Base NUnit set-up fixture that starts SQL Server in Docker (Testcontainers), hosts the app under test with |
| | | 14 | | /// <see cref="CustomWebApplicationFactory{TProgram}"/>, and resets the database between tests with Respawn. |
| | | 15 | | /// Derive one class per test project and implement <see cref="InitializeDatabaseAsync"/>. |
| | | 16 | | /// </summary> |
| | | 17 | | /// <typeparam name="TProgram">The <c>Program</c> class of the web application under test.</typeparam> |
| | | 18 | | /// <typeparam name="TDbContext">The app's <see cref="DbContext"/>. It must be registered with <c>AddDbContextFactory</c |
| | | 19 | | [ SetUpFixture ] |
| | | 20 | | [ SuppressMessage( "Design", "CA1001:Types that own disposable fields should be disposable", |
| | | 21 | | Justification = "Disposed in the [OneTimeTearDown] method RunAfterAllTestsAsync, which NUnit always r |
| | | 22 | | public abstract class SqlServerTestFixture< TProgram, TDbContext > |
| | | 23 | | where TProgram : class |
| | | 24 | | where TDbContext : DbContext |
| | | 25 | | { |
| | | 26 | | private const string IndexRebuildScript = """ |
| | | 27 | | DECLARE @TableName NVARCHAR(517) |
| | | 28 | | DECLARE @sql NVARCHAR(1000) |
| | | 29 | | DECLARE TableCursor CURSOR FOR |
| | | 30 | | SELECT QUOTENAME(OBJECT_SCHEMA_NAME([object_id])) + '.' + QUOTENAME(na |
| | | 31 | | OPEN TableCursor |
| | | 32 | | FETCH NEXT FROM TableCursor INTO @TableName |
| | | 33 | | WHILE @@FETCH_STATUS = 0 |
| | | 34 | | BEGIN |
| | | 35 | | SET @sql = 'ALTER INDEX ALL ON ' + @TableName + ' REBUILD WITH (FILLFA |
| | | 36 | | EXEC (@sql) |
| | | 37 | | FETCH NEXT FROM TableCursor INTO @TableName |
| | | 38 | | END |
| | | 39 | | CLOSE TableCursor |
| | | 40 | | DEALLOCATE TableCursor |
| | | 41 | | """; |
| | | 42 | | |
| | 6 | 43 | | private string _connectionString = string.Empty; |
| | | 44 | | |
| | | 45 | | |
| | | 46 | | /// <summary> |
| | | 47 | | /// The fixture NUnit started for this <typeparamref name="TProgram"/> / <typeparamref name="TDbContext"/> pair, or |
| | | 48 | | /// when none is running. Set at the end of <see cref="RunBeforeAnyTestsAsync"/> and cleared in <see cref="RunAfterA |
| | | 49 | | /// <see cref="IntegrationTestBase{TProgram, TDbContext}"/> uses it when no fixture was set explicitly. |
| | | 50 | | /// </summary> |
| | | 51 | | [ SuppressMessage( "Design", "CA1000:Do not declare static members on generic types", |
| | | 52 | | Justification = "One registry per app/context pair is intended; callers reach it through Integrat |
| | | 53 | | public static SqlServerTestFixture<TProgram, TDbContext>? Current { get; private set; } |
| | | 54 | | |
| | | 55 | | /// <summary>The HTTP client for the app under test. Set by <see cref="RunBeforeAnyTestsAsync"/>.</summary> |
| | | 56 | | protected internal HttpClient? Client { get; protected set; } |
| | | 57 | | |
| | | 58 | | /// <summary>The factory hosting the app under test. Set by <see cref="RunBeforeAnyTestsAsync"/>.</summary> |
| | | 59 | | protected WebApplicationFactory<TProgram>? Factory { get; set; } |
| | | 60 | | |
| | | 61 | | /// <summary>The SQL Server container. Set by <see cref="RunBeforeAnyTestsAsync"/>.</summary> |
| | | 62 | | protected MsSqlContainer? SqlContainer { get; set; } |
| | | 63 | | |
| | | 64 | | /// <summary>The Respawn instance that clears data. Set by <see cref="InitializeRespawnerAsync"/>.</summary> |
| | | 65 | | protected Respawner? DatabaseRespawner { get; set; } |
| | | 66 | | |
| | | 67 | | /// <summary>Tables kept during a reset, in addition to <c>__EFMigrationsHistory</c>. Default: none.</summary> |
| | 0 | 68 | | protected virtual Table[] TablesToIgnore => []; |
| | | 69 | | |
| | | 70 | | /// <summary>Schemas cleared during a reset. Default: <c>dbo</c>.</summary> |
| | 3 | 71 | | protected virtual string[] SchemasToInclude => [ "dbo" ]; |
| | | 72 | | |
| | | 73 | | /// <summary>The database name inside the container. Default: <c>TestDatabase</c>.</summary> |
| | 0 | 74 | | protected virtual string DatabaseName => "TestDatabase"; |
| | | 75 | | |
| | | 76 | | /// <summary> |
| | | 77 | | /// The connection-string name the app under test reads (<c>GetConnectionString( name )</c>). The container's connec |
| | | 78 | | /// string is set at <c>ConnectionStrings:{name}</c>. Default: <c>DefaultConnection</c>. |
| | | 79 | | /// </summary> |
| | 0 | 80 | | protected virtual string ConnectionStringName => CustomWebApplicationFactory<TProgram>.DefaultConnectionStringName; |
| | | 81 | | |
| | | 82 | | /// <summary>The SQL Server Docker image. Default: <c>mcr.microsoft.com/mssql/server:2022-latest</c>.</summary> |
| | 3 | 83 | | protected virtual string SqlServerImage => "mcr.microsoft.com/mssql/server:2022-latest"; |
| | | 84 | | |
| | | 85 | | /// <summary>Extra configuration values for the app under test (key = configuration path). Default: none.</summary> |
| | | 86 | | /// <returns>The values to add to the app's configuration.</returns> |
| | 0 | 87 | | protected virtual Dictionary<string, string> GetAdditionalConfiguration() => []; |
| | | 88 | | |
| | | 89 | | /// <summary>Prepares the database once per run: migrations, seed data and so on.</summary> |
| | | 90 | | /// <param name="scope">A service scope of the app under test.</param> |
| | | 91 | | /// <param name="cancellationToken">Token to cancel the operation.</param> |
| | | 92 | | /// <returns>A task that completes when the database is ready.</returns> |
| | | 93 | | protected abstract Task InitializeDatabaseAsync( IServiceScope scope, CancellationToken cancellationToken = default |
| | | 94 | | |
| | | 95 | | /// <summary>Creates a new <typeparamref name="TDbContext"/> from the app's <see cref="IDbContextFactory{TContext}"/ |
| | | 96 | | /// <returns>A new context connected to the test database.</returns> |
| | | 97 | | /// <exception cref="InvalidOperationException"><see cref="RunBeforeAnyTestsAsync"/> has not run yet.</exception> |
| | | 98 | | protected internal TDbContext GetDbContext() |
| | | 99 | | { |
| | 27 | 100 | | if ( Factory is null ) { throw new InvalidOperationException( "Factory is not initialized. Ensure RunBeforeAnyTe |
| | | 101 | | |
| | | 102 | | |
| | 27 | 103 | | using var scope = Factory.Services.CreateScope(); |
| | | 104 | | |
| | 27 | 105 | | return scope.ServiceProvider |
| | 27 | 106 | | .GetRequiredService<IDbContextFactory<TDbContext>>() |
| | 27 | 107 | | .CreateDbContext(); |
| | 27 | 108 | | } |
| | | 109 | | |
| | | 110 | | /// <summary>Opens a new connection to the test database. The caller disposes it.</summary> |
| | | 111 | | /// <param name="cancellationToken">Token to cancel the operation.</param> |
| | | 112 | | /// <returns>An open connection.</returns> |
| | | 113 | | protected async Task<DbConnection> GetOpenedDbConnectionAsync( CancellationToken cancellationToken = default ) |
| | | 114 | | { |
| | 18 | 115 | | var connection = new SqlConnection( _connectionString ); |
| | | 116 | | |
| | | 117 | | try |
| | | 118 | | { |
| | 18 | 119 | | await connection.OpenAsync( cancellationToken ).ConfigureAwait( false ); |
| | | 120 | | |
| | 18 | 121 | | return connection; |
| | | 122 | | } |
| | 0 | 123 | | catch |
| | | 124 | | { |
| | 0 | 125 | | await connection.DisposeAsync().ConfigureAwait( false ); |
| | | 126 | | |
| | 0 | 127 | | throw; |
| | | 128 | | } |
| | 18 | 129 | | } |
| | | 130 | | |
| | | 131 | | /// <summary>Creates the <see cref="DatabaseRespawner"/>, ignoring <c>__EFMigrationsHistory</c> and <see cref="Table |
| | | 132 | | /// <param name="cancellationToken">Token to cancel the operation.</param> |
| | | 133 | | /// <returns>A task that completes when the respawner is ready.</returns> |
| | | 134 | | protected virtual async Task InitializeRespawnerAsync( CancellationToken cancellationToken = default ) |
| | | 135 | | { |
| | 3 | 136 | | var connection = await GetOpenedDbConnectionAsync( cancellationToken ).ConfigureAwait( false ); |
| | | 137 | | |
| | 3 | 138 | | await using ( connection.ConfigureAwait( false ) ) |
| | | 139 | | { |
| | 3 | 140 | | Table[] tablesToIgnore = [ "__EFMigrationsHistory", .. TablesToIgnore ]; |
| | | 141 | | |
| | | 142 | | |
| | 3 | 143 | | DatabaseRespawner = await Respawner.CreateAsync( connection, new RespawnerOptions |
| | 3 | 144 | | { |
| | 3 | 145 | | SchemasToInclude = SchemasToInclude, |
| | 3 | 146 | | TablesToIgnore = tablesToIgnore, |
| | 3 | 147 | | DbAdapter = DbAdapter.SqlServer, |
| | 3 | 148 | | WithReseed = true |
| | 3 | 149 | | } ) |
| | 3 | 150 | | .ConfigureAwait( false ); |
| | | 151 | | } |
| | 3 | 152 | | } |
| | | 153 | | |
| | | 154 | | /// <summary>Deletes all data (except ignored tables), reseeds identities and rebuilds indexes.</summary> |
| | | 155 | | /// <param name="cancellationToken">Token to cancel the operation.</param> |
| | | 156 | | /// <returns>A task that completes when the database is clean.</returns> |
| | | 157 | | /// <exception cref="InvalidOperationException"><see cref="InitializeRespawnerAsync"/> has not run yet.</exception> |
| | | 158 | | public virtual async Task ResetDatabaseAsync( CancellationToken cancellationToken = default ) |
| | | 159 | | { |
| | 15 | 160 | | if ( DatabaseRespawner is null ) { throw new InvalidOperationException( "Database respawner is not initialized." |
| | | 161 | | |
| | | 162 | | |
| | 15 | 163 | | var connection = await GetOpenedDbConnectionAsync( cancellationToken ).ConfigureAwait( false ); |
| | | 164 | | |
| | 15 | 165 | | await using ( connection.ConfigureAwait( false ) ) |
| | | 166 | | { |
| | 15 | 167 | | await DatabaseRespawner.ResetAsync( connection ).ConfigureAwait( false ); |
| | | 168 | | } |
| | | 169 | | |
| | | 170 | | |
| | 15 | 171 | | var context = GetDbContext(); |
| | | 172 | | |
| | 15 | 173 | | await using ( context.ConfigureAwait( false ) ) |
| | | 174 | | { |
| | 15 | 175 | | await context.Database.ExecuteSqlRawAsync( IndexRebuildScript, cancellationToken ).ConfigureAwait( false ); |
| | | 176 | | } |
| | 15 | 177 | | } |
| | | 178 | | |
| | | 179 | | /// <summary> |
| | | 180 | | /// Starts the container, creates <see cref="Factory"/> and <see cref="Client"/>, then runs |
| | | 181 | | /// <see cref="InitializeDatabaseAsync"/> and <see cref="InitializeRespawnerAsync"/>. |
| | | 182 | | /// </summary> |
| | | 183 | | /// <returns>A task that completes when the environment is ready.</returns> |
| | | 184 | | [ OneTimeSetUp ] |
| | | 185 | | public virtual async Task RunBeforeAnyTestsAsync() |
| | | 186 | | { |
| | 3 | 187 | | SqlContainer = new MsSqlBuilder( SqlServerImage ).Build(); |
| | | 188 | | |
| | 3 | 189 | | await SqlContainer.StartAsync().ConfigureAwait( false ); |
| | | 190 | | |
| | | 191 | | |
| | 3 | 192 | | _connectionString = new SqlConnectionStringBuilder( SqlContainer.GetConnectionString() ) { InitialCatalog = Data |
| | 3 | 193 | | Factory = new CustomWebApplicationFactory<TProgram>( _connectionString, "Testing", GetAdditionalConfig |
| | 3 | 194 | | Client = Factory.CreateClient(); |
| | | 195 | | |
| | | 196 | | |
| | 3 | 197 | | var scope = Factory.Services.CreateAsyncScope(); |
| | | 198 | | |
| | 3 | 199 | | await using ( scope.ConfigureAwait( false ) ) |
| | | 200 | | { |
| | 3 | 201 | | await InitializeDatabaseAsync( scope, CancellationToken.None ).ConfigureAwait( false ); |
| | | 202 | | } |
| | | 203 | | |
| | | 204 | | |
| | 3 | 205 | | await InitializeRespawnerAsync().ConfigureAwait( false ); |
| | | 206 | | |
| | | 207 | | |
| | 3 | 208 | | Current = this; |
| | 3 | 209 | | } |
| | | 210 | | |
| | | 211 | | /// <summary> |
| | | 212 | | /// Clears <see cref="Current"/> (if it is this fixture), disposes <see cref="Client"/>, stops and disposes the cont |
| | | 213 | | /// and disposes <see cref="Factory"/>. |
| | | 214 | | /// </summary> |
| | | 215 | | /// <returns>A task that completes when everything is released.</returns> |
| | | 216 | | [ OneTimeTearDown ] |
| | | 217 | | public virtual async Task RunAfterAllTestsAsync() |
| | | 218 | | { |
| | 6 | 219 | | if ( ReferenceEquals( Current, this ) ) { Current = null; } |
| | | 220 | | |
| | | 221 | | |
| | 3 | 222 | | Client?.Dispose(); |
| | | 223 | | |
| | | 224 | | |
| | 3 | 225 | | if ( SqlContainer is not null ) |
| | | 226 | | { |
| | 3 | 227 | | await SqlContainer.StopAsync().ConfigureAwait( false ); |
| | 3 | 228 | | await SqlContainer.DisposeAsync().ConfigureAwait( false ); |
| | | 229 | | } |
| | | 230 | | |
| | | 231 | | |
| | 6 | 232 | | if ( Factory is not null ) { await Factory.DisposeAsync().ConfigureAwait( false ); } |
| | 3 | 233 | | } |
| | | 234 | | } |