Verify.EntityFramework
September 23, 2026 ยท View on GitHub
Extends Verify to allow snapshot testing with EntityFramework.
See Milestones for release notes.
Sponsors
Entity Framework Extensions
Entity Framework Extensions is a major sponsor and is proud to contribute to the development this project.
Developed using JetBrains IDEs
NuGet
- https://nuget.org/packages/Verify.EntityFramework/
- https://nuget.org/packages/Verify.EntityFrameworkClassic/
Enable
Enable VerifyEntityFramework once at assembly load time:
EF Core
static IModel GetDbModel()
{
var options = new DbContextOptionsBuilder<SampleDbContext>();
options.UseSqlServer("fake");
using var data = new SampleDbContext(options.Options);
return data.Model;
}
[ModuleInitializer]
public static void Init()
{
var model = GetDbModel();
VerifyEntityFramework.Initialize(model);
}
The GetDbModel pattern allows an instance of the IModel to be stored for use when IgnoreNavigationProperties is called inside tests. This is optional, and instead can be passed explicitly to IgnoreNavigationProperties.
EF Classic
[ModuleInitializer]
public static void Init() =>
VerifyEntityFrameworkClassic.Initialize();
Recording
Recording allows all commands executed by EF to be captured and then (optionally) verified.
Enable
Call EnableRecording() on DbContextOptionsBuilder.
var builder = new DbContextOptionsBuilder<SampleDbContext>();
builder.UseSqlServer(connection);
builder.EnableRecording();
var data = new SampleDbContext(builder.Options);
EnableRecording should only be called in the test context.
Usage
To start recording call Recording.Start(). The results will be automatically included in verified file.
var company = new Company
{
Name = "Title"
};
data.Add(company);
await data.SaveChangesAsync();
Recording.Start();
await data
.Companies
.Where(_ => _.Name == "Title")
.ToListAsync();
await Verify();
Will result in the following verified file:
{
ef: {
Type: ReaderExecutedAsync,
HasTransaction: false,
Text:
select c.Id,
c.Name
from Companies as c
where c.Name = N'Title'
}
}
Sql entries can be explicitly read using Recording.Stop(), optionally filtered, and passed to Verify:
var company = new Company
{
Name = "Title"
};
data.Add(company);
await data.SaveChangesAsync();
Recording.Start();
await data
.Companies
.Where(_ => _.Name == "Title")
.ToListAsync();
var entries = Recording.Stop();
//TODO: optionally filter the results
await Verify(
new
{
target = data.Companies.Count(),
entries
});
DbContext spanning
Recording.Start() can be called on different DbContext instances (built from the same options) and the results will be aggregated.
var builder = new DbContextOptionsBuilder<SampleDbContext>();
builder.UseSqlServer(connectionString);
builder.EnableRecording();
await using var data1 = new SampleDbContext(builder.Options);
Recording.Start();
var company = new Company
{
Name = "Title"
};
data1.Add(company);
await data1.SaveChangesAsync();
await using var data2 = new SampleDbContext(builder.Options);
await data2
.Companies
.Where(_ => _.Name == "Title")
.ToListAsync();
await Verify();
{
ef: [
{
Type: ReaderExecutedAsync,
HasTransaction: false,
Parameters: {
@p0 (Int32): 0,
@p1 (String): Title
},
Text:
set implicit_transactions off;
set nocount on;
insert into Companies (Id, Name)
values (@p0, @p1)
},
{
Type: ReaderExecutedAsync,
HasTransaction: false,
Text:
select c.Id,
c.Name
from Companies as c
where c.Name = N'Title'
}
]
}
Disabling Recording for an instance
var company = new Company
{
Name = "Title"
};
data.Add(company);
await data.SaveChangesAsync();
Recording.Start();
await data
.Companies
.Where(_ => _.Name == "Title")
.ToListAsync();
data.DisableRecording();
await data
.Companies
.Where(_ => _.Name == "Disabled")
.ToListAsync();
await Verify();
{
ef: {
Type: ReaderExecutedAsync,
HasTransaction: false,
Text:
select c.Id,
c.Name
from Companies as c
where c.Name = N'Title'
}
}
Disabling Recording globally
Recording is attached by EnableRecording(). Pass recordCommands: false to Initialize to leave that interceptor unattached, which makes every subsequent EnableRecording() call a no-op:
VerifyEntityFramework.Initialize(data, recordCommands: false);
Only recording is disabled. The converters, and the queryable to SQL file converter, are still registered.
This is useful when another package records the same commands. Verify.SqlServer subscribes to the Microsoft.Data.SqlClient diagnostic listener and records under the name sql. Since EF Core executes its commands through SqlCommand, with both packages recording every command EF executes is captured twice: once as ef and once as sql. Disable one of the two:
VerifyEntityFramework.Initialize(model, recordCommands: false)keeps thesqlentries.VerifySqlServer.Initialize(recordCommands: false)keeps theefentries, which also carry the commandTypeand transaction state. It has to be called beforeVerifierSettings.InitializePlugins(), otherwise plugin discovery initializes Verify.SqlServer first with recording enabled, and the explicit call throwsAlready Initialized.
InMemory
The InMemory provider executes no SQL, so there are no commands to record. Instead EnableRecording() records:
- Each query, as the LINQ expression that EF executes, with captured variables extracted to
Parameters. - Each
SaveChanges, as the entities being added, modified, and deleted, in the same format as ChangeTracking.
var builder = new DbContextOptionsBuilder<SampleDbContext>();
builder.UseInMemoryDatabase(databaseName);
builder.EnableRecording();
var data = new SampleDbContext(builder.Options);
Recording.Start();
data.Add(
new Company
{
Id = 1,
Name = "Title"
});
await data.SaveChangesAsync();
await data
.Companies
.Where(_ => _.Name == "Title")
.ToListAsync();
await Verify();
Will result in the following verified file:
{
ef: [
{
Type: SaveChangesAsync,
Added: {
Company: {
Id: 1,
Name: Title
}
}
},
{
Type: QueryAsync,
Text:
DbSet<Company>()
.Where(_ => _.Name == "Title")
}
]
}
Queries compiled with EF.CompileQuery or EF.CompileAsyncQuery are not recorded, since after being compiled they execute without passing through the query compiler.
ChangeTracking
Added, deleted, and Modified entities can be verified by performing changes on a DbContext and then verifying the instance of ChangeTracking. This approach leverages the EntityFramework ChangeTracker.
Added entity
This test:
[Test]
public async Task Added()
{
var options = DbContextOptions();
await using var data = new SampleDbContext(options);
var company = new Company
{
Name = "company name"
};
data.Add(company);
await Verify(data.ChangeTracker);
}
Will result in the following verified file:
{
Added: {
Company: {
Id: 0,
Name: company name
}
}
}
Deleted entity
This test:
[Test]
public async Task Deleted()
{
var options = DbContextOptions();
await using var data = new SampleDbContext(options);
data.Add(new Company
{
Name = "company name"
});
await data.SaveChangesAsync();
var company = data.Companies.Single();
data.Companies.Remove(company);
await Verify(data.ChangeTracker);
}
Will result in the following verified file:
{
Deleted: {
Company: {
Id: 0
}
}
}
Modified entity
This test:
[Test]
public async Task Modified()
{
var options = DbContextOptions();
await using var data = new SampleDbContext(options);
var company = new Company
{
Name = "old name"
};
data.Add(company);
await data.SaveChangesAsync();
data.Companies.Single()
.Name = "new name";
await Verify(data.ChangeTracker);
}
Will result in the following verified file:
{
Modified: {
Company: {
Id: 0,
Name: {
Original: old name,
Current: new name
}
}
}
}
Queryable
This test:
var queryable = data.Companies
.Where(_ => _.Name == "company name");
await Verify(queryable);
Will result in the following verified files:
EF Core
CoreTests.Queryable.verified.txt
[
{
Name: company name
}
]
CoreTests.Queryable.verified.sql
select c.Id,
c.Name
from Companies as c
where c.Name = N'company name'
EF Classic
ClassicTests.Queryable.verified.txt
SELECT
[Extent1].[Id] AS [Id],
[Extent1].[Content] AS [Content]
FROM [dbo].[Companies] AS [Extent1]
WHERE N'value' = [Extent1].[Content]
AllData
This test:
await Verify(data.AllData())
.AddExtraSettings(
serializer =>
serializer.TypeNameHandling = TypeNameHandling.Objects);
Will result in the following verified file with all data in the database:
[
{
$type: Company,
Id: 1,
Name: Company1
},
{
$type: Company,
Id: 4,
Name: Company2
},
{
$type: Company,
Id: 6,
Name: Company3
},
{
$type: Company,
Id: 7,
Name: Company4
},
{
$type: Employee,
Id: 2,
CompanyId: 1,
Name: Employee1,
Age: 25
},
{
$type: Employee,
Id: 3,
CompanyId: 1,
Name: Employee2,
Age: 31
},
{
$type: Employee,
Id: 5,
CompanyId: 4,
Name: Employee4,
Age: 34
}
]
IgnoreNavigationProperties
IgnoreNavigationProperties extends SerializationSettings to exclude all navigation properties from serialization:
[Test]
public async Task IgnoreNavigationProperties()
{
var options = DbContextOptions();
await using var data = new SampleDbContext(options);
var company = new Company
{
Name = "company"
};
var employee = new Employee
{
Name = "employee",
Company = company
};
await Verify(employee)
.IgnoreNavigationProperties();
}
Ignore globally
var options = DbContextOptions();
using var data = new SampleDbContext(options);
VerifyEntityFramework.IgnoreNavigationProperties();
WebApplicationFactory
To be able to use WebApplicationFactory for integration testing an identifier must be used to be able to retrieve the recorded commands. Start by enable recording with a unique identifier, for example the test name or a GUID:
protected override void ConfigureWebHost(IWebHostBuilder webBuilder)
{
var dataBuilder = new DbContextOptionsBuilder<SampleDbContext>()
.EnableRecording(name)
.UseSqlServer(connectionString);
webBuilder.ConfigureTestServices(
_ => _.AddScoped(
_ => dataBuilder.Options));
}
Then use the same identifier for recording:
var httpClient = factory.CreateClient();
Recording.Start(testName);
var companies = await httpClient.GetFromJsonAsync<Company[]>("/companies");
var entries = Recording.Stop(testName);
The results will not be automatically included in verified file so it will have to be verified manually:
await Verify(
new
{
target = companies!.Length,
sql = entries
});
Descriptive Table Aliases
By default EF generates single character table aliases in SQL (eg c for Companies, e for Employees). UseDescriptiveTableAliases replaces these with the full table name, making recorded and verified SQL easier to read.
Enable
Call UseDescriptiveTableAliases() on DbContextOptionsBuilder.
var builder = new DbContextOptionsBuilder<SampleDbContext>();
builder.UseSqlServer(connection);
builder.UseDescriptiveTableAliases();
Result
With descriptive aliases enabled, the generated SQL:
select companies.Id,
companies.Name,
employees.Id,
employees.Age,
employees.CompanyId,
employees.Name
from Companies as companies
left outer join
Employees as employees
on companies.Id = employees.CompanyId
order by companies.Name,
companies.Id
Instead of the default:
select c.Id,
c.Name,
e.Id,
e.Age,
e.CompanyId,
e.Name
from Companies as c
left outer join
Employees as e
on c.Id = e.CompanyId
order by c.Name,
c.Id
Descriptive Parameter Names
By default EF generates generic parameter names in SQL (eg @p0, @p1). UseDescriptiveParameterNames replaces these with the column name, making recorded and verified SQL easier to read. When the same column name appears across multiple tables in a batch, subsequent occurrences are prefixed with the entity type name (eg @Id for the first table, @EmployeeId for the second).
Enable
Call UseDescriptiveParameterNames() on DbContextOptionsBuilder.
var builder = new DbContextOptionsBuilder<SampleDbContext>();
builder.UseSqlServer(connection);
builder.UseDescriptiveParameterNames();
Result
With descriptive parameter names enabled, an insert:
{
ef: {
Type: ReaderExecutedAsync,
HasTransaction: false,
Parameters: {
@Id (Int32): 0,
@Name (String): Title
},
Text:
set implicit_transactions off;
set nocount on;
insert into Companies (Id, Name)
values (@Id, @Name)
}
}
Instead of the default:
Parameters: {
@p0 (Int32): 0,
@p1 (String): Title
},
Text:
insert into Companies (Id, Name)
values (@p0, @p1)
Duplicate column names
When multiple tables in the same batch have columns with the same name, subsequent occurrences are prefixed with the entity type name:
{
ef: {
Type: ReaderExecutedAsync,
HasTransaction: true,
Parameters: {
@Age (Int32): 25,
@CompanyId (Int32): 100,
@EmployeeId (Int32): 200,
@EmployeeName (String): EmployeeName,
@Id (Int32): 100,
@Name (String): CompanyName
},
Text:
set nocount on;
insert into Companies (Id, Name)
values (@Id, @Name);
insert into Employees (Id, Age, CompanyId, Name)
values (@EmployeeId, @Age, @CompanyId, @EmployeeName)
}
}
If the entity-prefixed name itself collides with an existing column name (eg Company + Id = CompanyId which is already a column on Employee), a counter suffix is used as a fallback.
Missing OrderBy
To detect and correct missing OrderBy clauses in EF queries, use EntityFramework.OrderBy.
ScrubInlineEfDateTimes
In some scenarios EntityFrmaeowrk does not parameterise DateTimes. For example when querying temporal tables.
ScrubInlineEfDateTimes() is a convenience method that calls .ScrubInlineDateTimes("yyyy-MM-ddTHH:mm:ss.fffffffZ").
Static usage
VerifyEntityFramework.ScrubInlineEfDateTimes();
Instance usage
var settings = new VerifySettings();
settings.ScrubInlineEfDateTimes();
await Verify(target, settings);
Fluent usage
await Verify(target)
.ScrubInlineEfDateTimes();
DisableSqlFormatting
By default SQL captured against SQL Server is reformatted via SqlFormatter before being written to the snapshot. This applies to both Recording output and Queryable .sql files.
Reformatting can be disabled globally:
VerifyEntityFramework.DisableSqlFormatting = true;
When disabled, the SQL is written verbatim as produced by EntityFramework.
Replaying recent migrations
Migrations are usually only ever tested against a database built by migrating an empty one. Deployed databases are not like that: a deployment applies state after migrating, such as enabling change tracking, rebuilding views, or re-granting permissions. That state can make DDL that is valid against an empty database fail against a real one. SQL Server, for example, refuses to drop a primary key while change tracking is enabled on the table.
ReplayRecentMigrations applies the most recent migrations one at a time, running a callback after each, so migrations meet the conditions a deployment gives them.
The database has to start with no migrations applied, so build it from an empty template.
// the template is left empty, so each test migrates forward from nothing
static SqlInstance<MyDbContext> sqlInstance = new(
constructInstance: builder => new(builder.Options),
buildTemplate: _ => Task.CompletedTask);
await using var database = await sqlInstance.Build();
await database.Context.ReplayRecentMigrations(
count: 5,
afterEachMigration: ApplyDeploymentState);
The callback applies whatever the deployment applies after migrating.
// whatever the deployment does after migrating: enabling
// change tracking, rebuilding views, re-granting permissions
static Task ApplyDeploymentState(MyDbContext data) =>
Task.CompletedTask;
Everything before the window is applied in a single hop, since those migrations are not under test. From there each migration is applied on its own, with the callback in between.
The one at a time part is the point. Applying the window in a single hop and running the callback once at the end is not equivalent: a table created by a migration inside the window would never have the state applied to it before a later migration alters it, and that is exactly the case that tends to break.
Migrating a database that is already up to date would revert migrations by running their Down, so this throws rather than doing that.
Icon
Database designed by Creative Stall from The Noun Project.

