Skip to content

Commit

Permalink
feature(#59): Improved SQL Server testing to also support creating te…
Browse files Browse the repository at this point in the history
…st data templates instead of recreating a fresh test database for each test
  • Loading branch information
jhartmann123 authored and davidroth committed Sep 6, 2024
1 parent 84b936c commit 98b84fa
Show file tree
Hide file tree
Showing 11 changed files with 403 additions and 117 deletions.
14 changes: 11 additions & 3 deletions .gitlab-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,16 @@ publish:
stage: publish
variables:
GIT_STRATEGY: none
allow_failure: true
script:
- dotnet nuget push "artifacts/package/release/*.nupkg" -k $Nuget_Key -s https://api.nuget.org/v3/index.json --skip-duplicate
- |
if [ $CI_PIPELINE_SOURCE != "merge_request_event" ]; then
echo "Publishing to NuGet.org"
dotnet nuget push "artifacts/package/release/*.nupkg" -k $Nuget_Key -s https://api.nuget.org/v3/index.json --skip-duplicate
fi
- echo "Publishing to GitLab"
- dotnet nuget push "artifacts/package/release/*.nupkg" --source "https://gitlab.com/api/v4/projects/${CI_PROJECT_ID}/packages/nuget/index.json" --api-key $CI_JOB_TOKEN
rules:
- if: $CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH =~ /^release\// || $CI_COMMIT_TAG
when: manual
- if: $CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH =~ /^release\// || $CI_COMMIT_TAG || $CI_PIPELINE_SOURCE == "merge_request_event"
when: manual
39 changes: 23 additions & 16 deletions docs/UnitTests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,10 @@ public class TestFixture : ServiceProviderTestFixture
{
protected sealed override void RegisterCoreDependencies(ServiceCollection services)
{
var options = Configuration.Get<NpgsqlDatabasePerTestStoreOptions>();
var options = new NpgsqlDatabasePerTestStoreOptions
{
ConnectionString = Configuration.GetConnectionString("Npgsql")
};
var testStore = new NpgsqlDatabasePerTestStore(options);
services.AddSingleton<ITestStore>(testStore);

Expand All @@ -187,8 +190,11 @@ You can create the template directly in the TestFixture by specifying a `Templat
```cs
protected sealed override void RegisterCoreDependencies(ServiceCollection services)
{
var options = Configuration.Get<NpgsqlDatabasePerTestStoreOptions>();
options.TemplateCreator = CreateTemplate; // Function to create the template on first connect
var options = new NpgsqlDatabasePerTestStoreOptions
{
ConnectionString = Configuration.GetConnectionString("Npgsql"),
TemplateCreator = CreateTemplate; // Function to create the template on first connect
};

// rest of the configuration
}
Expand Down Expand Up @@ -240,24 +246,28 @@ public class TestFixture : ServiceProviderTestFixture
var options = new SqlServerDatabasePerTestStoreOptions
{
ConnectionString = Configuration.GetConnectionString("SqlServer")!,
CreateDatabase = CreateSqlServerDatabase
TemplateCreator = CreateSqlServerTemplate,
DatabasePrefix = "project_test_" // Optional. Defines a prefix for the randomly generated test database names.
DatabaseDirectoryPath = "C:/mssql/data" // Optional. Defaults to docker image default path.
};
var testStore = new SqlServerDatabasePerTestStore(options);
services.AddSingleton<ITestStore>(testStore);
services.AddDbContext<AppDbContext>(b => b.UseSqlServerDatabasePerTest(testStore));
}

private static async Task CreateSqlServerDatabase(string connectionString)
{
await using var dbContext = new AppDbContext(connectionString);
await dbContext.Database.EnsureCreatedAsync();
}
private static async Task CreateSqlServerTemplate(string connectionString)
=> await SqlServerTestUtil.CreateTestDbTemplate<SqlServerDbContext>(connectionString, o => new SqlServerDbContext(o));
}
```

The connection string must have the `Intial catalog` set. It determines the name of the template database. All tests will use a copy of the template database.

The `TemplateCreator` specifies the method to create a template. It has to create and seed the database and create a backup for the copies used for the tests. Fortunately, the `SqlServerTestUtil` provides a method to do exactly that.


### Configuring any other database

The database support is not limited to PostgreSql. You just have to implement and register the `ITestStore`.
The database support is not limited to PostgreSql and SQL Server. You just have to implement and register the `ITestStore`.

For a simple example with SqLite, check `Fusonic.Extensions.UnitTests.EntityFrameworkCore.Tests` -> `SqliteTestStore` and `TestFixture`.

Expand All @@ -284,7 +294,7 @@ public class TestFixture : ServiceProviderTestFixture
var sqlServerSettings = new SqlServerDatabasePerTestStoreOptions
{
ConnectionString = Configuration.GetConnectionString("SqlServer")!,
CreateDatabase = CreateSqlServerDatabase
TemplateCreator = CreateSqlServerTemplate
};
var sqlServerTestStore = new SqlServerDatabasePerTestStore(sqlServerSettings);
services.AddDbContext<SqlServerDbContext>(b => b.UseSqlServerDatabasePerTest(sqlServerTestStore));
Expand All @@ -296,11 +306,8 @@ public class TestFixture : ServiceProviderTestFixture
private static Task CreatePostgresTemplate(string connectionString)
=> PostgreSqlUtil.CreateTestDbTemplate<NpgsqlDbContext>(connectionString, o => new NpgsqlDbContext(o), seed: ctx => new TestDataSeed(ctx).Seed());

private static async Task CreateSqlServerDatabase(string connectionString)
{
await using var dbContext = new SqlServerDbContext(connectionString);
await dbContext.Database.EnsureCreatedAsync();
}
private static Task CreateSqlServerTemplate(string connectionString)
=> SqlServerTestUtil.CreateTestDbTemplate<SqlServerDbContext>(connectionString, o => new SqlServerDbContext(o));
}
```

Expand Down
2 changes: 1 addition & 1 deletion src/Hangfire/test/TestFixture.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ private void RegisterDatabase(IServiceCollection services)
var testStoreOptions = new NpgsqlDatabasePerTestStoreOptions
{
TemplateCreator = CreateDatabase,
ConnectionString = Configuration.GetConnectionString("Hangfire")
ConnectionString = Configuration.GetConnectionString("Hangfire")!
};

var testStore = new NpgsqlDatabasePerTestStore(testStoreOptions);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,26 +11,26 @@ public class NpgsqlDatabasePerTestStore : ITestStore
private readonly NpgsqlDatabasePerTestStoreOptions options;
private readonly NpgsqlConnectionStringBuilder connectionStringBuilder;

private readonly string templateDatabaseName;
private readonly string postgresConnectionString;

public string ConnectionString => connectionStringBuilder.ConnectionString;

private bool isDbCreated;

public NpgsqlDatabasePerTestStore(NpgsqlDatabasePerTestStoreOptions options)
{
this.options = new NpgsqlDatabasePerTestStoreOptions(options);

connectionStringBuilder = new NpgsqlConnectionStringBuilder(options.ConnectionString);

templateDatabaseName = connectionStringBuilder.Database
?? throw new ArgumentException("Missing template database in connection string.");
ValidateConnectionString();
OnTestConstruction();
}

connectionStringBuilder.Database = "postgres";
postgresConnectionString = connectionStringBuilder.ConnectionString;
private void ValidateConnectionString()
{
var templateDatabaseName = connectionStringBuilder.Database
?? throw new ArgumentException("Missing database in connection string.");

OnTestConstruction();
if (templateDatabaseName == "postgres")
throw new ArgumentException("Connection string cannot use postgres as database. It should provide the name of the template database, even if it does not exist.");
}

public void OnTestConstruction()
Expand All @@ -41,13 +41,8 @@ public void OnTestConstruction()

public async Task OnTestEnd()
{
if (!isDbCreated)
return;

var connection = new NpgsqlConnection(postgresConnectionString);
await using var _ = connection.ConfigureAwait(false);
await connection.OpenAsync().ConfigureAwait(false);
await connection.ExecuteAsync($@"DROP DATABASE IF EXISTS ""{connectionStringBuilder.Database}"" WITH (FORCE)").ConfigureAwait(false);
if (isDbCreated)
await PostgreSqlUtil.DropDatabase(ConnectionString).ConfigureAwait(false);
}

public async Task CreateDatabase()
Expand All @@ -56,7 +51,14 @@ public async Task CreateDatabase()
return;

if (options.TemplateCreator != null)
await DatabaseHelper.EnsureCreated(options.ConnectionString!, options.TemplateCreator, options.AlwaysCreateTemplate).ConfigureAwait(false);
{
await PostgreSqlUtil
.EnsureTemplateDbCreated(
options.ConnectionString,
options.TemplateCreator,
options.AlwaysCreateTemplate)
.ConfigureAwait(false);
}

// Creating a DB from a template can cause an exception when done in parallel.
// The lock usually prevents this, however, we still encounter race conditions
Expand All @@ -72,10 +74,7 @@ async Task CreateDb()
if (isDbCreated)
return;

var connection = new NpgsqlConnection(postgresConnectionString);
await using var _ = connection.ConfigureAwait(false);
await connection.OpenAsync().ConfigureAwait(false);
await connection.ExecuteAsync($@"CREATE DATABASE ""{connectionStringBuilder.Database}"" TEMPLATE ""{templateDatabaseName}""").ConfigureAwait(false);
await PostgreSqlUtil.CreateDatabase(options.ConnectionString, connectionStringBuilder.Database!).ConfigureAwait(false);

isDbCreated = true;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
// Copyright (c) Fusonic GmbH. All rights reserved.
// Licensed under the MIT License. See LICENSE file in the project root for license information.

using System.Diagnostics.CodeAnalysis;

namespace Fusonic.Extensions.UnitTests.EntityFrameworkCore.Npgsql;

public class NpgsqlDatabasePerTestStoreOptions
{
public NpgsqlDatabasePerTestStoreOptions()
{ }

[SetsRequiredMembers]
internal NpgsqlDatabasePerTestStoreOptions(NpgsqlDatabasePerTestStoreOptions copyFrom)
{
ConnectionString = copyFrom.ConnectionString;
Expand All @@ -16,10 +19,10 @@ internal NpgsqlDatabasePerTestStoreOptions(NpgsqlDatabasePerTestStoreOptions cop
}

/// <summary> Connection string to the template database. </summary>
public string? ConnectionString { get; set; }
public required string ConnectionString { get; set; }

/// <summary> Action to create the database template on first connect. Only gets executed once. If AlwaysCreateTemplate is set to false, the action only gets executed if the template database does not exist. </summary>
public Func<string, Task>? TemplateCreator { get; set; }
public required Func<string, Task>? TemplateCreator { get; set; }

/// <summary> Ignores an existing template database and always recreates the template on the first run. Ignored, if TemplateCreator is null. </summary>
public bool AlwaysCreateTemplate { get; set; }
Expand Down
74 changes: 55 additions & 19 deletions src/UnitTests.EntityFrameworkCore.Npgsql/src/PostgreSqlUtil.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) Fusonic GmbH. All rights reserved.
// Licensed under the MIT License. See LICENSE file in the project root for license information.

using System.Data.Common;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using Microsoft.EntityFrameworkCore;
Expand All @@ -14,7 +15,21 @@ namespace Fusonic.Extensions.UnitTests.EntityFrameworkCore.Npgsql;

public static class PostgreSqlUtil
{
/// <summary> Creates a test database. </summary>
internal static async Task EnsureTemplateDbCreated(
string connectionString,
Func<string, Task> createTemplate,
bool alwaysCreateTemplate = false)
{
await DatabaseTestHelper.EnsureTemplateDbCreated(
connectionString,
CheckDatabaseExists,
DropDatabase,
NpgsqlConnection.ClearAllPools,
createTemplate,
alwaysCreateTemplate).ConfigureAwait(false);
}

/// <summary> Creates a test database template. </summary>
/// <param name="connectionString">Connection string to the test database. The database does not have to exist.</param>
/// <param name="dbContextFactory">Returns a DbContext using the given options.</param>
/// <param name="npgsqlOptionsAction">The configuration action for .UseNpgsql().</param>
Expand Down Expand Up @@ -80,17 +95,27 @@ public static async Task CreateTestDbTemplate<TDbContext>(
logger.LogInformation("Done");
}

public static async Task DropDatabase(string connectionString)
internal static async Task CreateDatabase(string templateConnectionString, string dbName)
{
var csBuilder = new NpgsqlConnectionStringBuilder(connectionString);
var dbName = csBuilder.Database;
AssertNotPostgres(dbName);
var connection = CreatePostgresConnection(templateConnectionString, out var templateName);
await using (connection.ConfigureAwait(false))
{
if (templateName == dbName)
throw new ArgumentException("Template and new database name must not be the same.");

csBuilder.Database = "postgres";
var connection = new NpgsqlConnection(csBuilder.ConnectionString);
await using var _ = connection.ConfigureAwait(false);
connection.Open();
await DropDatabase(connection, dbName).ConfigureAwait(false);
await connection.OpenAsync().ConfigureAwait(false);
await connection.ExecuteAsync($""" CREATE DATABASE "{dbName}" TEMPLATE "{templateName}"; """).ConfigureAwait(false);
}
}

public static async Task DropDatabase(string connectionString)
{
var connection = CreatePostgresConnection(connectionString, out var dbName);
await using (connection.ConfigureAwait(false))
{
connection.Open();
await DropDatabase(connection, dbName).ConfigureAwait(false);
}
}

private static async Task DropDatabase(NpgsqlConnection connection, string dbName)
Expand All @@ -104,16 +129,14 @@ private static async Task DropDatabase(NpgsqlConnection connection, string dbNam

public static async Task<bool> CheckDatabaseExists(string connectionString)
{
var csBuilder = new NpgsqlConnectionStringBuilder(connectionString);
var dbName = csBuilder.Database!;

csBuilder.Database = "postgres";
var connection = new NpgsqlConnection(csBuilder.ConnectionString);
await using var _ = connection.ConfigureAwait(false);
connection.Open();
var exists = await CheckDatabaseExists(connection, dbName).ConfigureAwait(false);
var connection = CreatePostgresConnection(connectionString, out var dbName);
await using (connection.ConfigureAwait(false))
{
connection.Open();
var exists = await CheckDatabaseExists(connection, dbName).ConfigureAwait(false);

return exists;
return exists;
}
}

private static async Task<bool> CheckDatabaseExists(NpgsqlConnection connection, string dbName)
Expand All @@ -126,6 +149,19 @@ private static async Task<bool> CheckDatabaseExists(NpgsqlConnection connection,
return result != null && (bool)result;
}

private static NpgsqlConnection CreatePostgresConnection(string connectionString, out string dbName)
{
var csBuilder = new NpgsqlConnectionStringBuilder(connectionString);
if (string.IsNullOrWhiteSpace(csBuilder.Database))
throw new ArgumentException("Database name is missing in the connection string.", nameof(connectionString));

dbName = csBuilder.Database;
AssertNotPostgres(dbName);

csBuilder.Database = "postgres";
return new NpgsqlConnection(csBuilder.ConnectionString);
}

private static void AssertNotPostgres([NotNull] string? dbName)
{
if (string.IsNullOrWhiteSpace(dbName))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ protected override void RegisterCoreDependencies(IServiceCollection services)
var testStoreOptions = new NpgsqlDatabasePerTestStoreOptions
{
TemplateCreator = CreateDatabase,
ConnectionString = Configuration.GetConnectionString("Npgsql")
ConnectionString = Configuration.GetConnectionString("Npgsql")!
};

var testStore = new NpgsqlDatabasePerTestStore(testStoreOptions);
Expand Down
Loading

0 comments on commit 98b84fa

Please sign in to comment.