EntityFramework Migrations are supported via the buildTemplate parameter on SqlInstance. When using migrations, pass the TemplateFromConnection overload of buildTemplate so that the template database schema is created by applying migrations rather than EnsureCreatedAsync.
var sqlInstance = new SqlInstance<MyDbContext>(
buildTemplate: async (connection, options) =>
{
options.ReplaceService<IMigrationsSqlGenerator, MigrationsGenerator>();
await using var data = new MyDbContext(options.Options);
await data.Database.MigrateAsync();
},
constructInstance: builder => new(builder.Options));The above performs the following actions:
- Creates a
SqlInstanceusing the connection-basedbuildTemplatedelegate. - Optionally replaces
IMigrationsSqlGeneratorwith a custom implementation. - Constructs a
DbContextfrom the providedDbContextOptionsBuilder. - Applies all pending migrations via
Database.MigrateAsync().
The template database is built once and then cloned for each test, so migrations only run a single time regardless of how many tests execute.
By default (when no buildTemplate is provided), SqlInstance uses Database.EnsureCreatedAsync() to create the schema. This is simpler but does not use migration history. If the project uses EF migrations, pass a buildTemplate that calls Database.MigrateAsync() instead. Do not mix both — EnsureCreated and Migrate are mutually exclusive in EF Core.
When a buildTemplate delegate is provided via the context-based overload (TemplateFromContext), SqlInstance automatically calls ThrowIfPendingChanges() before building the template. This compares the current DbContext model against the latest migration snapshot and throws an InvalidOperationException listing the differences if any model changes have not been captured in a migration. This ensures the template database always matches the migration history and prevents silent schema drift during tests.
Optionally use Custom Migrations Operations by replacing IMigrationsSqlGenerator on the options builder before applying migrations.
options.ReplaceService<IMigrationsSqlGenerator, MigrationsGenerator>();This is useful for scenarios such as custom SQL generation, adding seed data via migration operations, or integrating with database-specific features not covered by the default SQL generator.
Perform a Runtime apply of migrations.
await using var data = new MyDbContext(options.Options);
await data.Database.MigrateAsync();This constructs a DbContext using the options builder (which is pre-configured to connect to the template database) and then applies all pending migrations. After this point the template database has the full schema and is ready to be cloned for individual tests.
To test a single migration in isolation, build the template up to the migration before the one under test using IMigrator.MigrateAsync("targetMigration"). Each test then receives a clone at that point and can apply the next migration independently.
static SqlInstance<MyDbContext> sqlInstance = new(
buildTemplate: async (connection, options) =>
{
await using var data = new MyDbContext(options.Options);
var migrator = data.GetInfrastructure()
.GetRequiredService<IMigrator>();
// apply up to and including a target migration
await migrator.MigrateAsync("AddOrders");
},
constructInstance: builder => new(builder.Options));This uses IMigrator (resolved from the EF Core service provider) instead of Database.MigrateAsync() so a target migration name can be specified. The template is frozen at that point and cloned for every test.
[Test]
public async Task TestNextMigration()
{
await using var database = await sqlInstance.Build();
// apply the next migration under test
var migrator = database.Context
.GetInfrastructure()
.GetRequiredService<IMigrator>();
await migrator.MigrateAsync("AddOrderStatus");
// verify the migration applied the expected schema change
await using var command = database.Connection.CreateCommand();
command.CommandText = """
SELECT COUNT(*)
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME = 'Orders'
AND COLUMN_NAME = 'Status'
""";
var result = (int) (await command.ExecuteScalarAsync())!;
That(result, Is.EqualTo(1));
}Each test gets its own database clone at the earlier migration state. It then applies the target migration and verifies the expected schema change. Because every test starts from the same cloned template, migrations under test are fully isolated from each other.