Skip to content

Instantly share code, notes, and snippets.

@scionwest
Created June 3, 2019 19:42
Show Gist options
  • Save scionwest/7e58253a848d74caf79f119f9eb33ddd to your computer and use it in GitHub Desktop.
Save scionwest/7e58253a848d74caf79f119f9eb33ddd to your computer and use it in GitHub Desktop.
Generate Databases per Integration Test
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using MySql.Data.MySqlClient;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
namespace ToNote
{
/// <summary>
/// Provides a Test Server that will setup a new database and optionally seed the database.
/// Any database created from this factory will be automatically deleted unless marked with <see cref="RetainDatabase{TContext}"/>
/// </summary>
/// <typeparam name="TStartup">The startup class that will be register services, configurations and middleware.</typeparam>
public class DatabaseAppFactory<TStartup> : WebApplicationFactory<TStartup> where TStartup : class
{
private readonly List<TestDatabaseSetup> databaseSetups = new List<TestDatabaseSetup>();
private readonly string userSecrets;
public DatabaseAppFactory() { }
/// <summary>
/// User Secrets of the project being tested so that secrets don't need to be copied to the test project.
/// </summary>
/// <param name="userSecrets">The Subject Under Test` user secrets.</param>
public DatabaseAppFactory(string userSecrets) => this.userSecrets = userSecrets;
/// <summary>
/// Marks the database being created for the test as needing to be retained after the test is completed.
/// The database will not be deleted.
/// </summary>
public DatabaseAppFactory<TStartup> RetainDatabase<TContext>() where TContext : DbContext
{
Type contextType = typeof(TContext);
TestDatabaseSetup databaseSetup = this.databaseSetups.First(setup => setup.DbContextType == contextType);
databaseSetup.RetainDatabase = true;
return this;
}
/// <summary>
/// Requests the creation of a test database using the given <see cref="DbContext"/>.
/// </summary>
/// <typeparam name="TContext">The context to create a database for.</typeparam>
/// <param name="connectionStringKey">
/// The <see cref="IConfiguration"/>
/// <para>key used to find the connection string.
/// Assuming a connection string called <c>MyDatabase</c>, then if the fully qualified key is
/// <c>ConnectionStrings:MyDatabase</c> you may only provide <c>MyDatabase</c>.
/// The factory will discover the full connection string value from the standard convention expected.
/// </para>
/// <para>
/// If you do not use the standard convention, instead using a different fully qualified name such as <c>Data:MyApp:MyDatabase</c>
/// then you will need to specify the fully qualified configuration key for the factory to discover the value.
/// </para>
/// </param>
/// <param name="seeder">A callback that will provide the caller with an instance of the <see cref="DbContext"/> for seeding the test database with data.</param>
/// <param name="callingTest">The test being executed for this factory.</param>
public DatabaseAppFactory<TStartup> WithDataContext<TContext>(string connectionStringKey, Action<TContext> seeder = null, [CallerMemberName] string callingTest = null) where TContext : DbContext
{
this.databaseSetups.Add(new TestDatabaseSetup
{
ConnectionStringKey = connectionStringKey,
DbContextType = typeof(TContext),
Seeder = seeder,
ExecutingTest = callingTest,
});
return this;
}
/// <summary>
/// Delete any database that is not marked as needing to be retained.
/// </summary>
/// <param name="disposing">Ignored by this factory.</param>
protected override void Dispose(bool disposing)
{
foreach (TestDatabaseSetup setup in this.databaseSetups)
{
if (setup.RetainDatabase)
{
continue;
}
DbContext dbContext = (DbContext)this.Server.Host.Services.GetService(setup.DbContextType);
dbContext.Database.EnsureDeleted();
}
base.Dispose(disposing);
}
/// <summary>
/// Configure the host so that it uses the Startup needed by the test along with
/// any user secrets if they exist.
/// </summary>
/// <returns>Returns a configured host builder.</returns>
protected override IWebHostBuilder CreateWebHostBuilder()
{
IWebHostBuilder hostBuilder = WebHost.CreateDefaultBuilder()
.ConfigureAppConfiguration(configBuilder =>
{
if (string.IsNullOrEmpty(this.userSecrets)) return;
configBuilder.AddUserSecrets(this.userSecrets);
})
.UseStartup<TStartup>();
return hostBuilder;
}
/// <summary>
/// Sets up the content root of the host.
/// </summary>
/// <param name="builder">Pre-configured host builder.</param>
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseSolutionRelativeContentRoot(".");
base.ConfigureWebHost(builder);
}
/// <summary>
/// Creates the in-memory web server and creates the test databases registered.
/// </summary>
/// <param name="builder">The configured host builder.</param>
/// <returns></returns>
protected override TestServer CreateServer(IWebHostBuilder builder)
{
foreach (TestDatabaseSetup setup in this.databaseSetups)
{
this.SetupDb(builder, setup);
}
TestServer server = base.CreateServer(builder);
foreach (TestDatabaseSetup setup in this.databaseSetups)
{
DbContext dbContext = (DbContext)server.Host.Services.GetService(setup.DbContextType);
dbContext.Database.EnsureCreated();
setup.Seeder?.DynamicInvoke(dbContext);
}
return server;
}
/// <summary>
/// Replaces the connection string for each test database registerd.
/// </summary>
/// <param name="hostBuilder">Configured host builder</param>
/// <param name="testSetup">Setup of a database context.</param>
private void SetupDb(IWebHostBuilder hostBuilder, TestDatabaseSetup testSetup)
{
hostBuilder.ConfigureAppConfiguration((hostContext, configBuilder) =>
{
IConfiguration config = configBuilder.Build();
string connectionString = string.IsNullOrEmpty(config.GetConnectionString(testSetup.ConnectionStringKey))
? config[testSetup.ConnectionStringKey]
: config.GetConnectionString(testSetup.ConnectionStringKey);
string timeStamp = DateTime.Now.ToString("HHmmss.fff");
var connectionStringBuilder = new MySqlConnectionStringBuilder(connectionString)
{
Database = $"Tests-{testSetup.ExecutingTest}-{timeStamp}"
};
string configKey = string.IsNullOrEmpty(config.GetConnectionString(testSetup.ConnectionStringKey))
? testSetup.ConnectionStringKey
: $"ConnectionStrings:{testSetup.ConnectionStringKey}";
var memoryConfig = new Dictionary<string, string>
{
{ configKey, connectionStringBuilder.ToString() }
};
configBuilder.AddInMemoryCollection(memoryConfig);
});
}
}
}
using Microsoft.EntityFrameworkCore;
using System;
namespace ToNote
{
internal class TestDatabaseSetup
{
public Delegate Seeder { get; set; }
public Type DbContextType { get; set; }
public string ConnectionStringKey { get; set; }
public string ExecutingTest { get; set; }
public bool RetainDatabase { get; set; }
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment