skip to content

🚧Keeping Entity Framework Snapshot in Sync

I don't like any of these approaches, but they somehow solve the problem of EF Core snapshot being out of sync. We cover four approaches that you can use to ensure that your snapshot is always in sync with your code.

EF Core Snapshot

The EF Core snapshot is a snapshot of the current state of the model and its metadata in Entity Framework Core. It contains information about the schema of the database and the state of the entities in the DbContext. It is automatically generated and updated by EF Core, and is used to generate SQL queries for database operations. When a migration is generated, EF Core compares the current snapshot to the previous snapshot and generates a migration script to update the database schema.

Keeping The Snapshot In Sync

We can write automated tests to ensure that the database schema and data are in sync with the application code. This helps to catch any issues early on and ensure that the snapshot is always up-to-date.

We can do so with 4 approaches

🥉Compare SQL Script

We can check if the generated SQL script from the current DbContext matches the expected snapshot. It uses the dotnet command to generate the EF Core migration script for the specified DbContext and saves it to a file. Then, it reads the generated script and compares it to the expected snapshot using the Snapshot.Match() method. This test ensures that the database schema is in sync with the DbContext and any changes made to the DbContext are correctly reflected in the database schema.

Test to Acknowledge SQL Script Update
[Fact]
public async void AcknowledgeDbContextSqlScriptUpdate()
{
  // Specifies the location of the .csproj file that contains the
  // DbContext for which the migration script is to be generated.
  var projLocation = @"..\..\..\..\MyBlog\MyBlog.csproj";
 
  // Output location for the generated migration script.
  var outputLocation = @"..\..\..\Tests\DbSnapshot.sql";
 
  // Configures the dotnet command to generate the
  // EF Core migration script for the specified DbContext.
  ProcessStartInfo startInfo = new()
  {
    FileName = "dotnet",
    Arguments = $"ef dbcontext script --no-build -c BloggingContext -p {projLocation} -o {outputLocation}",
    CreateNoWindow = true,
    RedirectStandardOutput = true,
    RedirectStandardError = true,
  };
 
  // Starts the process to generate the migration script
  var proc = Process.Start(startInfo);
  ArgumentNullException.ThrowIfNull(proc);
  await proc.WaitForExitAsync();
 
  // Reads the generated migration script and compares it to the expected snapshot.
  string content = File.ReadAllText(outputLocation);
  Snapshot.Match(content);
}
[Fact]
public async void AcknowledgeDbContextSqlScriptUpdate()
{
  // Specifies the location of the .csproj file that contains the
  // DbContext for which the migration script is to be generated.
  var projLocation = @"..\..\..\..\MyBlog\MyBlog.csproj";
 
  // Output location for the generated migration script.
  var outputLocation = @"..\..\..\Tests\DbSnapshot.sql";
 
  // Configures the dotnet command to generate the
  // EF Core migration script for the specified DbContext.
  ProcessStartInfo startInfo = new()
  {
    FileName = "dotnet",
    Arguments = $"ef dbcontext script --no-build -c BloggingContext -p {projLocation} -o {outputLocation}",
    CreateNoWindow = true,
    RedirectStandardOutput = true,
    RedirectStandardError = true,
  };
 
  // Starts the process to generate the migration script
  var proc = Process.Start(startInfo);
  ArgumentNullException.ThrowIfNull(proc);
  await proc.WaitForExitAsync();
 
  // Reads the generated migration script and compares it to the expected snapshot.
  string content = File.ReadAllText(outputLocation);
  Snapshot.Match(content);
}

Drawback of SQL Snapshot

Since updating the snapshot file does not require creating a migration, it is possible for developers to update the snapshot file without creating a corresponding migration. This can lead to the snapshot being out of sync with the actual state of the database schema, which defeats the purpose of keeping the snapshot in sync. Therefore, it’s important to establish a process that encourages developers to update the snapshot file in conjunction with creating a migration.

🥈Check Migration is Empty

This code checks whether the newly added migration file is empty or not. It does this by adding a new migration with the specified DbContext, and then comparing the contents of the generated migration file with the expected contents of an empty migration. If the contents match, the test passes; otherwise, it fails. The code also deletes the output directory and its contents after the test is completed.

Test to Check If Migration Add Is Empty
[Fact]
public async void CheckIfEfMigrationAddIsEmpty()
{
  // Specifies the location of the .csproj file that contains the DbContext
  // for which the migration is being added.
  var projLocation = @"..\..\..\..\MyBlog\MyBlog.csproj";
 
  // Output location for the generated migration files.
  var outputLocation = @"..\Tests\CheckMigrationIsEmpty";
 
  // Name of the migration to be added.
  var migrationName = "SHOULD_BE_REMOVED_BEFORE_PR";
 
  // Configures the dotnet command to add a new migration for the specified DbContext.
  ProcessStartInfo startInfo = new()
  {
    FileName = "dotnet",
    Arguments =
        $"ef migrations add {migrationName} --json --no-build  -c BloggingContext -p {projLocation} -o {outputLocation}",
    CreateNoWindow = true,
    RedirectStandardOutput = true,
    RedirectStandardError = true,
  };
 
  // Starts the process to add the new migration.
  var proc = Process.Start(startInfo);
  ArgumentNullException.ThrowIfNull(proc);
  await proc.WaitForExitAsync();
 
  // Specifies the relative location of the output directory.
  string relativeOutputLocation = @"..\..\..\CheckMigrationIsEmpty";
 
  // Defines the contents of an empty migration.
  var emptyMigration =
@$"using Microsoft.EntityFrameworkCore.Migrations;
 
#nullable disable
 
namespace MyBlog.Migrations
{{
    /// <inheritdoc />
    public partial class SHOULD_BE_REMOVED_BEFORE_PR : Migration
    {{
        /// <inheritdoc />
        protected override void Up(MigrationBuilder migrationBuilder)
        {{
 
        }}
 
        /// <inheritdoc />
        protected override void Down(MigrationBuilder migrationBuilder)
        {{
 
        }}
    }}
}}
";
 
  // Retrieves the contents of the newly added migration file and compares
  // it to the expected contents of an empty migration.
  var file = Directory.GetFiles(relativeOutputLocation, $"*_{migrationName}.cs").First();
  var migration = File.ReadAllText(file);
  Assert.Equal(emptyMigration, migration);
 
  // Deletes the output directory and its contents.
  Directory.Delete(relativeOutputLocation, true);
}
[Fact]
public async void CheckIfEfMigrationAddIsEmpty()
{
  // Specifies the location of the .csproj file that contains the DbContext
  // for which the migration is being added.
  var projLocation = @"..\..\..\..\MyBlog\MyBlog.csproj";
 
  // Output location for the generated migration files.
  var outputLocation = @"..\Tests\CheckMigrationIsEmpty";
 
  // Name of the migration to be added.
  var migrationName = "SHOULD_BE_REMOVED_BEFORE_PR";
 
  // Configures the dotnet command to add a new migration for the specified DbContext.
  ProcessStartInfo startInfo = new()
  {
    FileName = "dotnet",
    Arguments =
        $"ef migrations add {migrationName} --json --no-build  -c BloggingContext -p {projLocation} -o {outputLocation}",
    CreateNoWindow = true,
    RedirectStandardOutput = true,
    RedirectStandardError = true,
  };
 
  // Starts the process to add the new migration.
  var proc = Process.Start(startInfo);
  ArgumentNullException.ThrowIfNull(proc);
  await proc.WaitForExitAsync();
 
  // Specifies the relative location of the output directory.
  string relativeOutputLocation = @"..\..\..\CheckMigrationIsEmpty";
 
  // Defines the contents of an empty migration.
  var emptyMigration =
@$"using Microsoft.EntityFrameworkCore.Migrations;
 
#nullable disable
 
namespace MyBlog.Migrations
{{
    /// <inheritdoc />
    public partial class SHOULD_BE_REMOVED_BEFORE_PR : Migration
    {{
        /// <inheritdoc />
        protected override void Up(MigrationBuilder migrationBuilder)
        {{
 
        }}
 
        /// <inheritdoc />
        protected override void Down(MigrationBuilder migrationBuilder)
        {{
 
        }}
    }}
}}
";
 
  // Retrieves the contents of the newly added migration file and compares
  // it to the expected contents of an empty migration.
  var file = Directory.GetFiles(relativeOutputLocation, $"*_{migrationName}.cs").First();
  var migration = File.ReadAllText(file);
  Assert.Equal(emptyMigration, migration);
 
  // Deletes the output directory and its contents.
  Directory.Delete(relativeOutputLocation, true);
}

Drawback of Checking Empty Migrations

Adding an empty migration to check for synchronization issues will update the EF Core model snapshot, which means that the user will have to reset the snapshot and add another migration. This can be a time-consuming process, especially if the project is large and complex

🥇Compare DbContext Snapshots

We can write a unit test that checks if the database schema snapshot of the production environment matches the test environment’s snapshot. It creates a migration using EF Core’s migrations tool with a unique name and then deletes it. The code then reads the database schema snapshot from both the main and test projects and removes any lines that need to be ignored. It then compares both snapshots to see if they match. If they match, the test passes. Otherwise, it fails. The test also includes a class that inherits from the BloggingContext to use it as the TestDbContext.

Test Db Context
public class TestDbContext : BloggingContext { }
public class TestDbContext : BloggingContext { }
Compare Snapshots Of Blogging And Test Db Context
[Fact]
public async void CompareSnapshotsOfBloggingAndTest()
{
  var migrationName = "SHOULD_BE_REMOVED_BEFORE_PR";
  ProcessStartInfo startInfo =
    new()
    {
    FileName = "dotnet",
    Arguments =
        $"ef migrations add {migrationName} --json --no-build -c TestDbContext -p ..\\..\\..\\Tests.csproj",
    CreateNoWindow = true,
    RedirectStandardOutput = true,
    RedirectStandardError = true,
    };
  var proc = Process.Start(startInfo);
  ArgumentNullException.ThrowIfNull(proc);
  string output = proc.StandardOutput.ReadToEnd();
  await proc.WaitForExitAsync();
  Console.WriteLine(output);
 
  string migrationDir = @"..\..\..\Migrations";
 
  Directory
    .GetFiles(migrationDir, $"*_{migrationName}*.cs")
    .ToList()
    .ForEach(f => File.Delete(f));
 
  var linesToIgnore = new List<string>
    {
      // Lines from Main db snapshot
      "using System;",
      "using MyBlog;",
      "namespace MyBlog.Migrations",
      "[DbContext(typeof(BloggingContext))]",
      "partial class BloggingContextModelSnapshot : ModelSnapshot",
      // Lines from Test db snapshot
      "using System;",
      "using Tests;",
      "namespace CustomNS",
      "[DbContext(typeof(TestDbContext))]",
      "partial class TestDbContextModelSnapshot : ModelSnapshot",
    };
 
  var productVersionLine = """modelBuilder.HasAnnotation("ProductVersion",""";
 
  var testDbSnapshotPath = @"..\..\..\Migrations\TestDbContextModelSnapshot.cs";
 
  var mainDbSnapshotPath = @"..\..\..\..\MyBlog\Migrations\BloggingContextModelSnapshot.cs";
 
  var testDbSnapshot = GetSnapshotContent(testDbSnapshotPath);
  var mainDbSnapshot = GetSnapshotContent(mainDbSnapshotPath);
 
  Assert.Equal(mainDbSnapshot, testDbSnapshot);
 
  string GetSnapshotContent(string mainDbSnapshotPath) =>
    string.Join(
      "\r\n",
      File.ReadAllText(mainDbSnapshotPath)
        .Split("\r\n")
        .Where(
          line =>
            !(
              linesToIgnore.Contains(line.Trim())
              || line.Contains(productVersionLine)
            )
        )
    );
}
[Fact]
public async void CompareSnapshotsOfBloggingAndTest()
{
  var migrationName = "SHOULD_BE_REMOVED_BEFORE_PR";
  ProcessStartInfo startInfo =
    new()
    {
    FileName = "dotnet",
    Arguments =
        $"ef migrations add {migrationName} --json --no-build -c TestDbContext -p ..\\..\\..\\Tests.csproj",
    CreateNoWindow = true,
    RedirectStandardOutput = true,
    RedirectStandardError = true,
    };
  var proc = Process.Start(startInfo);
  ArgumentNullException.ThrowIfNull(proc);
  string output = proc.StandardOutput.ReadToEnd();
  await proc.WaitForExitAsync();
  Console.WriteLine(output);
 
  string migrationDir = @"..\..\..\Migrations";
 
  Directory
    .GetFiles(migrationDir, $"*_{migrationName}*.cs")
    .ToList()
    .ForEach(f => File.Delete(f));
 
  var linesToIgnore = new List<string>
    {
      // Lines from Main db snapshot
      "using System;",
      "using MyBlog;",
      "namespace MyBlog.Migrations",
      "[DbContext(typeof(BloggingContext))]",
      "partial class BloggingContextModelSnapshot : ModelSnapshot",
      // Lines from Test db snapshot
      "using System;",
      "using Tests;",
      "namespace CustomNS",
      "[DbContext(typeof(TestDbContext))]",
      "partial class TestDbContextModelSnapshot : ModelSnapshot",
    };
 
  var productVersionLine = """modelBuilder.HasAnnotation("ProductVersion",""";
 
  var testDbSnapshotPath = @"..\..\..\Migrations\TestDbContextModelSnapshot.cs";
 
  var mainDbSnapshotPath = @"..\..\..\..\MyBlog\Migrations\BloggingContextModelSnapshot.cs";
 
  var testDbSnapshot = GetSnapshotContent(testDbSnapshotPath);
  var mainDbSnapshot = GetSnapshotContent(mainDbSnapshotPath);
 
  Assert.Equal(mainDbSnapshot, testDbSnapshot);
 
  string GetSnapshotContent(string mainDbSnapshotPath) =>
    string.Join(
      "\r\n",
      File.ReadAllText(mainDbSnapshotPath)
        .Split("\r\n")
        .Where(
          line =>
            !(
              linesToIgnore.Contains(line.Trim())
              || line.Contains(productVersionLine)
            )
        )
    );
}

Drawback for Comparing Db Context Snapshot

One potential drawback of this approach is that it relies on file comparisons to determine if the database snapshots are identical, if the code generating the snapshots changes, the file comparison may break even if the snapshots are still identical.

🏆Snapshot DbContext Model

Almost same as the first approach with the same drawbacks but much cleaner 🫠

[Fact]
public void AcknowledgeDbContextChanges()
{
  var dbContext = new BloggingContext();
  var dbModel = dbContext.Model.ToDebugString(MetadataDebugStringOptions.LongDefault);
  Snapshot.Match(dbModel);
}
[Fact]
public void AcknowledgeDbContextChanges()
{
  var dbContext = new BloggingContext();
  var dbModel = dbContext.Model.ToDebugString(MetadataDebugStringOptions.LongDefault);
  Snapshot.Match(dbModel);
}