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.

Expression trees and IQueryable internals Query performance and avoiding N+1

Last refreshed 2026-09-18.