Debugging and testing LINQ queries
Break the pipeline, read the SQL, and write assertions that pin your behaviour without re-testing the framework.
Making a pipeline observable
// A tap you can leave in a pipeline: logs and passes the item through
public static IEnumerable<T> Tap<T>(this IEnumerable<T> source,
Action<T> action,
[CallerArgumentExpression("source")] string? label = null)
{
foreach (var item in source)
{
action(item);
yield return item;
}
}
var result = orders
.Tap(o => Console.WriteLine("in " + o.Id), "start")
.Where(o => o.Status == OrderStatus.Open)
.Tap(o => Console.WriteLine("kept " + o.Id), "after where")
.Select(o => o.Total)
.ToList();
// Count at each stage without changing the pipeline shape
static (int In, int Out) Measure<T>(IEnumerable<T> source, Func<IEnumerable<T>, IEnumerable<T>> step)
{
var materialised = source as IReadOnlyCollection<T> ?? source.ToList();
var after = step(materialised).ToList();
return (materialised.Count, after.Count);
}// Log the SQL for every query in development, including parameters
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(connectionString)
.LogTo(Console.WriteLine, LogLevel.Information)
.EnableSensitiveDataLogging() // development only
.EnableDetailedErrors());
// Or capture the SQL without running the query
var query = db.Orders.Where(o => o.Status == OrderStatus.Open).Select(o => o.Id);
var sql = query.ToQueryString();
Console.WriteLine(sql);
// Detect client evaluation while debugging
var projected = db.Orders
.Select(o => new { o.Id, Slug = MakeSlug(o.Note) }) // untranslatable
.ToList();
// In EF Core 3 and later this throws rather than silently downloading rows,
// which is exactly the behaviour you want in a test suite.Assertions worth writing
// Assert on your query's behaviour, not on Enumerable.Where
[Fact]
public void Only_open_orders_placed_this_year_are_returned()
{
var orders = new[]
{
new Order(1, OrderStatus.Open, new DateOnly(2026, 3, 1)),
new Order(2, OrderStatus.Open, new DateOnly(2025, 12, 31)),
new Order(3, OrderStatus.Closed, new DateOnly(2026, 4, 1)),
};
var result = OrdersFilter.ForYear(orders, 2026);
Assert.Equal(new[] { 1 }, result.Select(o => o.Id));
}
// Pin the ordering, because a query that returns the right rows in an
// undefined order is still a bug in a paginated API
[Fact]
public void Results_are_ordered_by_placed_at_then_id()
{
var orders = new[]
{
new Order(2, OrderStatus.Open, new DateOnly(2026, 1, 1)),
new Order(1, OrderStatus.Open, new DateOnly(2026, 1, 1)),
};
var ids = OrdersFilter.ForYear(orders, 2026).Select(o => o.Id).ToArray();
Assert.Equal(new[] { 1, 2 }, ids);
}
// Assert the query executes once, which catches accidental multiple enumeration
[Fact]
public void Source_is_enumerated_once()
{
var enumerations = 0;
IEnumerable<int> Source()
{
enumerations++;
yield return 1;
yield return 2;
}
var total = Source().Select(x => x * 2).Sum();
Assert.Equal(6, total);
Assert.Equal(1, enumerations);
}- Test the filter or projection as a method you can call with a list, not through the whole HTTP stack. It is faster and the failure names the rule that broke.
- Pin the ordering explicitly whenever the result is paginated or displayed, because an unordered query is a latent bug that appears under a different query plan.
- Count enumerations with a counting iterator to catch a query that is accidentally run twice — a real cost on a database and a real waste in memory.
- Do not assert on the exact SQL text: the provider changes it between versions. Assert on results and on the number of round trips.
💡
The most valuable LINQ test is the one that counts executions. Multiple enumeration is invisible in review, harmless on a list and catastrophic on a database, and a single counter assertion in the test suite prevents it from returning.
FAQ
How do I see the SQL for a query I cannot run yet?
Call ToQueryString on the IQueryable. It returns the SQL and the parameters without executing anything, which is ideal for a test or a debugging session.
Should I unit test against an in-memory provider?
Not for query behaviour, because the in-memory provider does not translate expressions and will accept queries that fail on a real database. Use a real engine via Testcontainers for anything involving translation.
Related
Expression trees and IQueryable internals Query performance and avoiding N+1
Last refreshed 2026-09-18.