BrandGhost
How to Test C# and EF Core Specifications Correctly

How to Test C# and EF Core Specifications Correctly

If you need to test C# and EF Core specifications, the difficult part is not arranging an xUnit test. The difficult part is deciding what a passing test actually proves. A compiled predicate can prove Boolean behavior in .NET. An in-memory evaluator can prove application wiring. Neither one proves that EF Core can translate the expression, that the database applies the same semantics, or that the generated command matches your expectations.

This article stays focused on those proof levels. It covers pure domain specifications, expression-based query specifications, evaluator and application seams, relational execution, generated SQL, deterministic translation failures, and the production-provider test requirement. It is not a general xUnit tutorial, and it does not turn test execution time into a performance benchmark.

Test EF Core Specifications at the Right Proof Level

A test suite becomes confusing when the word "specification" refers to several different things without saying which one is under test. For this article, the models are:

  • A pure domain specification returns bool for an in-memory candidate.
  • An expression specification exposes Expression<Func<T, bool>> for an IQueryable<T> provider.
  • A full query envelope may also carry ordering, projection, includes, tracking behavior, pagination, tags, or other query instructions.

Those models overlap, but their evidence requirements differ. A pure predicate has no database translation claim. An expression specification does. A full query envelope adds more observable behavior because ordering, projection, loading strategy, and execution can all affect the result.

The practical rule is simple: test at the lowest level that proves the claim, then add a higher level when the claim crosses a boundary.

Claim under test Minimum useful proof
A domain rule accepts and rejects the intended candidates Pure unit test
AND, OR, and NOT preserve the intended Boolean meaning Truth-table and algebraic-law unit tests
An application use case builds and sends the intended specification Application or repository-seam test
An evaluator applies criteria before materialization Evaluator test
EF Core translates the criterion Relational-provider execution test
String, null, date, collation, or provider function semantics match production Production-provider test
A critical query emits the expected shape Generated SQL or command assertion plus relational execution

Microsoft's current EF Core testing strategy guidance makes the core limitation explicit: passing against a test double does not guarantee the same behavior against the production database system. That warning is especially important for specifications because the abstraction can make provider behavior feel farther away than it really is.

Unit-Test Pure Predicates and Their Boolean Laws

A pure domain specification should be small, deterministic, and independent of EF Core. This is the right place for positive, negative, boundary, and null-policy cases. It is also the right place to test Boolean composition without involving a database.

The following production code targets .NET 10 with nullable reference types enabled:

using System;

namespace SpecificationTesting;

public interface IDomainSpecification<in T>
{
    bool IsSatisfiedBy(T candidate);
}

public sealed class Product
{
    private Product()
    {
    }

    public Product(Guid id, string sku, decimal price, bool isActive)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(sku);

        Id = id;
        Sku = sku;
        Price = price;
        IsActive = isActive;
    }

    public Guid Id { get; private set; }

    public string Sku { get; private set; } = string.Empty;

    public decimal Price { get; private set; }

    public bool IsActive { get; private set; }
}

public sealed class MinimumPriceSpecification(decimal minimum)
    : IDomainSpecification<Product>
{
    public bool IsSatisfiedBy(Product candidate)
    {
        ArgumentNullException.ThrowIfNull(candidate);

        return candidate.Price >= minimum;
    }
}

public sealed class DelegateSpecification<T>(Func<T, bool> predicate)
    : IDomainSpecification<T>
{
    public bool IsSatisfiedBy(T candidate)
    {
        ArgumentNullException.ThrowIfNull(candidate);

        return predicate(candidate);
    }
}

public sealed class AndSpecification<T>(
    IDomainSpecification<T> left,
    IDomainSpecification<T> right) : IDomainSpecification<T>
{
    public bool IsSatisfiedBy(T candidate)
    {
        ArgumentNullException.ThrowIfNull(candidate);

        return left.IsSatisfiedBy(candidate) &&
            right.IsSatisfiedBy(candidate);
    }
}

public sealed class OrSpecification<T>(
    IDomainSpecification<T> left,
    IDomainSpecification<T> right) : IDomainSpecification<T>
{
    public bool IsSatisfiedBy(T candidate)
    {
        ArgumentNullException.ThrowIfNull(candidate);

        return left.IsSatisfiedBy(candidate) ||
            right.IsSatisfiedBy(candidate);
    }
}

public sealed class NotSpecification<T>(
    IDomainSpecification<T> inner) : IDomainSpecification<T>
{
    public bool IsSatisfiedBy(T candidate)
    {
        ArgumentNullException.ThrowIfNull(candidate);

        return !inner.IsSatisfiedBy(candidate);
    }
}

Now the tests can describe the rule rather than the testing framework:

using System;

using Xunit;

namespace SpecificationTesting;

public sealed class DomainSpecificationTests
{
    [Theory]
    [InlineData(99, false)]
    [InlineData(100, true)]
    [InlineData(101, true)]
    public void IsSatisfiedBy_PriceAroundBoundary_ReturnsExpectedResult(
        int price,
        bool expected)
    {
        var product = new Product(
            Guid.NewGuid(),
            "HW-100",
            price,
            true);
        var specification = new MinimumPriceSpecification(100m);

        var result = specification.IsSatisfiedBy(product);

        Assert.Equal(expected, result);
    }

    [Fact]
    public void IsSatisfiedBy_NullCandidate_ThrowsArgumentNullException()
    {
        var specification = new MinimumPriceSpecification(100m);

        Assert.Throws<ArgumentNullException>(
            () => specification.IsSatisfiedBy(null!));
    }

    [Theory]
    [InlineData(false, false)]
    [InlineData(false, true)]
    [InlineData(true, false)]
    [InlineData(true, true)]
    public void BooleanComposition_AllInputs_ObeysBooleanLaws(
        bool leftValue,
        bool rightValue)
    {
        var candidate = new object();
        var left = new DelegateSpecification<object>(_ => leftValue);
        var right = new DelegateSpecification<object>(_ => rightValue);
        var alwaysTrue = new DelegateSpecification<object>(_ => true);
        var alwaysFalse = new DelegateSpecification<object>(_ => false);

        Assert.Equal(
            leftValue && rightValue,
            new AndSpecification<object>(left, right)
                .IsSatisfiedBy(candidate));
        Assert.Equal(
            leftValue || rightValue,
            new OrSpecification<object>(left, right)
                .IsSatisfiedBy(candidate));
        Assert.Equal(
            leftValue,
            new AndSpecification<object>(left, alwaysTrue)
                .IsSatisfiedBy(candidate));
        Assert.Equal(
            leftValue,
            new OrSpecification<object>(left, alwaysFalse)
                .IsSatisfiedBy(candidate));
        Assert.Equal(
            leftValue,
            new NotSpecification<object>(
                new NotSpecification<object>(left))
                .IsSatisfiedBy(candidate));

        var notAnd = new NotSpecification<object>(
            new AndSpecification<object>(left, right));
        var deMorgan = new OrSpecification<object>(
            new NotSpecification<object>(left),
            new NotSpecification<object>(right));

        Assert.Equal(
            notAnd.IsSatisfiedBy(candidate),
            deMorgan.IsSatisfiedBy(candidate));
    }
}

These tests prove in-memory predicate behavior. They cover the price boundary, define the null policy, exercise all four Boolean input combinations, and check identity, double negation, and one De Morgan law. They do not involve IQueryable<T>, an EF provider, SQL, collation, or database null semantics.

That last sentence matters. If you want a broader refresher on test organization and test doubles, the existing xUnit and Moq guide covers those mechanics. The specification-specific concern is choosing a proof level that matches the claim.

Test the Evaluator and Application Seam Separately

An application test can verify that a use case creates the intended specification and passes it to a data-access boundary. It should not need a real database when the claim is only about orchestration.

The seam should return materialized results rather than IQueryable<T>. Microsoft's testing-without-the-database guidance explains why: returning IQueryable<T> allows callers to keep composing provider-bound operations, so a stub no longer replaces EF query behavior.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Threading;
using System.Threading.Tasks;

using Xunit;

namespace SpecificationTesting;

public interface IQuerySpecification<T>
{
    Expression<Func<T, bool>> Criteria { get; }
}

public sealed record QuerySpecification<T>(
    Expression<Func<T, bool>> Criteria) : IQuerySpecification<T>;

public sealed record ProductSummary(Guid Id, string Sku);

public interface IProductReader
{
    Task<IReadOnlyList<ProductSummary>> ListAsync(
        IQuerySpecification<Product> specification,
        CancellationToken cancellationToken);
}

public sealed class FindActiveProducts(IProductReader productReader)
{
    public Task<IReadOnlyList<ProductSummary>> ExecuteAsync(
        decimal minimumPrice,
        CancellationToken cancellationToken)
    {
        var specification = new QuerySpecification<Product>(
            product => product.IsActive &&
                product.Price >= minimumPrice);

        return productReader.ListAsync(
            specification,
            cancellationToken);
    }
}

public sealed class InMemoryProductReader(
    IReadOnlyList<Product> products) : IProductReader
{
    public IQuerySpecification<Product>? LastSpecification { get; private set; }

    public Task<IReadOnlyList<ProductSummary>> ListAsync(
        IQuerySpecification<Product> specification,
        CancellationToken cancellationToken)
    {
        cancellationToken.ThrowIfCancellationRequested();
        LastSpecification = specification;

        IReadOnlyList<ProductSummary> rows = products
            .AsQueryable()
            .Where(specification.Criteria)
            .OrderBy(product => product.Id)
            .Select(product => new ProductSummary(
                product.Id,
                product.Sku))
            .ToList();

        return Task.FromResult(rows);
    }
}

public sealed class FindActiveProductsTests
{
    [Fact]
    public async Task ExecuteAsync_MixedProducts_ReturnsMatchingProducts()
    {
        var matchingId = Guid.Parse(
            "11111111-1111-1111-1111-111111111111");
        var reader = new InMemoryProductReader(
        [
            new Product(matchingId, "HW-100", 150m, true),
            new Product(Guid.NewGuid(), "HW-200", 50m, true),
            new Product(Guid.NewGuid(), "HW-300", 200m, false)
        ]);
        var useCase = new FindActiveProducts(reader);

        var rows = await useCase.ExecuteAsync(
            100m,
            CancellationToken.None);

        var row = Assert.Single(rows);
        Assert.Equal(matchingId, row.Id);
        Assert.NotNull(reader.LastSpecification);
    }
}

This test proves the application assembles useful criteria, passes the specification through the seam, and maps the matching result. The evaluator is LINQ-to-Objects. Even though the criterion is stored as an expression tree, this test still does not prove EF translation.

Execute Query Specifications Through a Relational Provider

An EF translation claim begins only when a relational provider receives and executes the expression. IQueryable<T> carries both an expression and a provider, and enumeration delegates execution to that provider. The .NET 10 IQueryable API reference documents that expression-and-provider contract. Compiling the expression changes the exercise into normal .NET delegate execution.

The following test uses EF Core 10.0.10 with SQLite in-memory as a relational smoke test. It inspects generated SQL, executes the command, asserts the result, and counts reader commands. It also contains a deterministic unsupported-method case.

using System;
using System.Data.Common;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;

using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Xunit;

namespace SpecificationTesting;

public sealed class ProductDbContext(
    DbContextOptions<ProductDbContext> options) : DbContext(options)
{
    public DbSet<Product> Products => Set<Product>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Product>(entity =>
        {
            entity.HasKey(product => product.Id);
            entity.Property(product => product.Sku).HasMaxLength(64);
        });
    }
}

public sealed class ReaderCommandCounter : DbCommandInterceptor
{
    private int _readerCommands;

    public int ReaderCommands => Volatile.Read(ref _readerCommands);

    public void Reset()
    {
        Interlocked.Exchange(ref _readerCommands, 0);
    }

    public override ValueTask<InterceptionResult<DbDataReader>>
        ReaderExecutingAsync(
            DbCommand command,
            CommandEventData eventData,
            InterceptionResult<DbDataReader> result,
            CancellationToken cancellationToken = default)
    {
        Interlocked.Increment(ref _readerCommands);

        return ValueTask.FromResult(result);
    }
}

public static class ProductRules
{
    public static bool HasRequiredPrefix(
        string sku,
        string prefix)
    {
        return sku.StartsWith(
            prefix,
            StringComparison.OrdinalIgnoreCase);
    }
}

public sealed class ProductSpecificationRelationalTests
{
    [Fact]
    public async Task Query_TranslatableCriteria_ExecutesExpectedCommand()
    {
        var counter = new ReaderCommandCounter();
        await using var connection = new SqliteConnection(
            "Data Source=:memory:");
        await connection.OpenAsync(
            CancellationToken.None);

        var options = new DbContextOptionsBuilder<ProductDbContext>()
            .UseSqlite(connection)
            .AddInterceptors(counter)
            .Options;

        await using var db = new ProductDbContext(options);
        await db.Database.EnsureCreatedAsync(
            CancellationToken.None);
        db.Products.AddRange(
            new Product(Guid.NewGuid(), "HW-100", 150m, true),
            new Product(Guid.NewGuid(), "HW-200", 50m, true));
        await db.SaveChangesAsync(
            CancellationToken.None);

        var specification = new QuerySpecification<Product>(
            product => product.IsActive && product.Price >= 100m);
        var query = db.Products
            .Where(specification.Criteria)
            .OrderBy(product => product.Id);

        var sql = query.ToQueryString();
        counter.Reset();
        var rows = await query.ToListAsync(
            CancellationToken.None);

        Assert.Contains(
            "WHERE",
            sql,
            StringComparison.OrdinalIgnoreCase);
        Assert.Contains(
            "IsActive",
            sql,
            StringComparison.OrdinalIgnoreCase);
        Assert.Single(rows);
        Assert.Equal(1, counter.ReaderCommands);
    }

    [Fact]
    public async Task Query_CustomClrMethodInFilter_ThrowsOnExecution()
    {
        await using var connection = new SqliteConnection(
            "Data Source=:memory:");
        await connection.OpenAsync(
            CancellationToken.None);

        var options = new DbContextOptionsBuilder<ProductDbContext>()
            .UseSqlite(connection)
            .Options;

        await using var db = new ProductDbContext(options);
        await db.Database.EnsureCreatedAsync(
            CancellationToken.None);

        var specification = new QuerySpecification<Product>(
            product => ProductRules.HasRequiredPrefix(
                product.Sku,
                "HW-"));
        var query = db.Products.Where(specification.Criteria);

        await Assert.ThrowsAsync<InvalidOperationException>(
            () => query.ToListAsync(
                CancellationToken.None));
    }
}

EF Core's documented client-versus-server evaluation behavior allows client code in the final projection, but an untranslatable expression in Where is expected to fail at runtime. The failure test is valuable because it proves the test can detect a broken translation path instead of reporting green for every expression.

ToQueryString() adds useful evidence, but it is not the finish line. The EF Core 10 API documentation describes its output as debugging text that may not be directly executable. That is why the successful test both inspects the string and executes the query.

Know What EF InMemory and SQLite Cannot Prove

Test doubles are useful when their boundary is explicit. They become dangerous when their result is promoted into a broader claim.

EF Core InMemory

The official EF Core InMemory provider documentation says its use as a database testing fake is discouraged. It is not relational, does not support raw SQL or transactions like a relational database, supports fewer query types, and is not designed as a performance substitute.

Pros: setup is small, state can be isolated, and basic data-access orchestration may be easy to exercise.

Cons: it cannot prove relational translation, generated SQL, relational constraints, provider functions, collation, or production query plans.

For a query specification, those cons remove most of the evidence people usually think they are gaining.

SQLite In-Memory

SQLite is relational, so it can detect many translation mistakes and can expose generated SQL. It is a stronger smoke test than EF InMemory.

It is still a different provider. Microsoft's EF Core testing strategy documents examples such as case-sensitivity differences, unsupported provider-specific methods, different SQL dialects, and queries that work on one provider but not another. A SQLite pass proves SQLite behavior for that test. It does not prove SQL Server, PostgreSQL, MySQL, Oracle, or another production system behaves identically.

The balanced approach is:

  1. Use pure tests for predicate meaning.
  2. Use seam tests for application orchestration.
  3. Use SQLite when a quick relational smoke test is useful.
  4. Run the provider contract against the actual production database system for production claims.

The existing EF Core InMemory versus SQLite article provides more setup context. For specifications, keep the narrower conclusion in view: neither substitute removes the production-provider requirement.

Make Production-Provider Tests a Deliberate Contract

Provider tests should be boring and repeatable. Seed only the rows needed for the semantic boundary. Execute through the same provider major version used by the application. Isolate test data through a disposable database, schema, transaction strategy, or provider-supported container setup. Then assert outcomes that would matter in production.

EF Core providers typically do not work across major versions, so the provider test package should match the application's EF Core major version. The current database provider guidance also recommends reviewing each provider's detailed compatibility documentation.

Good candidates include:

  • Case and collation-sensitive criteria.
  • Nullable comparisons and null compensation.
  • Date, time, JSON, spatial, or full-text provider functions.
  • Global query filters and required-navigation joins.
  • Projection column shape.
  • Unique ordering and pagination behavior.
  • Includes, split queries, and expected command counts.
  • Raw SQL or provider-specific mappings.

Do not snapshot every character of every SQL command. Provider servicing releases may make harmless formatting or alias changes. Assert the stable part of the contract: required predicates, joins, projected columns, parameterization, ordering, and command count. For a small number of critical queries, a reviewed SQL snapshot can still be reasonable when the team accepts the maintenance cost.

There is a real tradeoff. Production-provider tests require database lifecycle and isolation work. In return, they provide evidence no fake can provide. Microsoft's database testing guidance discusses local installations, containers, shared fixtures, and isolation strategies. The right answer is not "put every unit test in a container." It is "put every provider-dependent claim in a test that uses that provider."

Reuse One Provider Contract Without Pretending Providers Are Equal

You do not need separate test intent for every provider. You need a shared contract with provider-specific setup and provider-specific expectations where the semantics differ.

For example, the contract can seed the same active and inactive products, execute the same price criterion, and assert the same business result. A SQLite implementation proves the SQLite translation path. A SQL Server implementation proves the SQL Server path. If production uses only SQL Server, the SQLite implementation remains optional convenience rather than a release gate that replaces the SQL Server test.

Keep the shared assertions focused on behavior that should be common:

  • The query executes through the provider.
  • The expected rows are returned.
  • The result ordering is deterministic when ordering is part of the envelope.
  • The projection contains the expected values.
  • The expected number of commands is issued for that query mode.

Put provider-specific assertions beside the provider fixture. Collation, database functions, SQL syntax, indexes, and plan operators are not meaningful as one universal snapshot. A SQL Server test may assert a SQL Server function mapping. A PostgreSQL test may assert its own operator or function. Neither assertion belongs in the shared provider-neutral contract.

This design has useful tradeoffs. Shared contract tests reduce duplicated setup and make missing provider coverage visible. Provider-specific tests preserve honest semantics. The cost is additional fixture infrastructure and a need to decide which tests run on every local edit, on pull requests, or in a slower scheduled lane.

I recommend three execution groups:

  1. Pure rule and seam tests on every developer test run.
  2. Fast relational smoke tests where they improve feedback.
  3. Production-provider contract tests as a required delivery gate for database-facing specifications.

The exact grouping depends on the team's environment. The proof rule does not change. If a release claim depends on production-provider behavior, that provider's contract must run before release.

Use Deterministic Failures to Prove the Test Suite Has Teeth

A reliable translation failure should come from a fixed unsupported shape, not from timing, network instability, or a missing local service. A custom CLR helper inside Where is a useful example because EF Core cannot translate arbitrary method bodies into equivalent SQL. The corresponding corrected criterion should express supported operations directly in the tree, or use a mapped provider function that has its own production-provider test.

Failure tests also help classify defects:

  • A pure test failure points to rule logic or null policy.
  • A seam test failure points to application construction, delegation, or mapping.
  • A SQLite translation failure points to the expression or SQLite provider path.
  • A production-provider-only failure points to provider translation or database semantics.
  • A SQL-shape assertion failure points to changed query construction.
  • A command-count failure points to changed materialization or loading behavior.

This classification shortens diagnosis because each test has a declared proof boundary. A giant test that compiles a predicate, invokes an evaluator, opens a database, and asserts a DTO may still be useful, but it is harder to tell which guarantee failed.

FAQ About Testing Specifications

Does compiling a specification predicate prove EF Core translation?

No. Compile() creates a .NET delegate and evaluates with normal CLR semantics. EF Core translation requires an expression tree to remain attached to IQueryable<T> and be processed by a database provider. A compiled predicate test is valuable for in-memory meaning, but it is not EF evidence.

Is EF Core InMemory enough for query specification tests?

Not for relational claims. It can support limited orchestration tests, but it cannot prove SQL translation, relational constraints, transactions, provider functions, collation, or query plans.

Is SQLite enough if the query executes successfully?

SQLite execution proves that the SQLite provider translated and ran that query. It can be an excellent relational smoke test. It does not prove production-provider behavior where SQL dialect, functions, comparison rules, data types, or translations differ.

Should every specification test assert generated SQL?

No. Assert SQL where query shape is part of the contract or where a regression would be expensive. Most pure domain tests need no SQL. Most relational tests should at least execute the query, and critical query tests can additionally inspect SQL or command logs.

What should a repository or application-seam test return?

Prefer materialized results, scalar values, or application-owned result types. Returning IQueryable<T> keeps provider composition alive outside the seam and prevents the stub from replacing the data-access behavior reliably.

How do I test a translation failure without making the test flaky?

Use a fixed expression shape that the selected provider does not translate, execute it, and assert the documented exception category. Do not depend on a timeout, a remote outage, or a data race. Keep a corrected, executable query nearby so the intended provider-safe form is clear.

Choose Proof Levels, Not One Favorite Test Double

The strongest specification test suite is layered. Unit tests prove pure rules and Boolean laws. Evaluator and application tests prove construction and delegation. Relational tests prove translation and SQL shape. Production-provider tests prove the database semantics the application actually depends on.

Each layer has a cost and a benefit. Fast pure tests provide precise feedback but no provider evidence. SQLite provides convenient relational feedback but not production equivalence. Production-provider tests require more infrastructure but close the most important evidence gap.

When a test passes, you should be able to finish this sentence without hand-waving: "This proves that..." If the answer mentions EF translation, SQL semantics, collation, a provider function, or a query plan, the test must cross the relational provider boundary. A compiled predicate never does.

Specification Pattern With EF Core Without a Repository

Use the Specification Pattern with EF Core DbContext queries while keeping tracking, result limits, cancellation, SQL checks, and relational tests explicit.

Combining C# Specifications With AND, OR, and NOT

Combining C# specifications with AND, OR, and NOT requires Boolean laws, explicit null policies, parameter rebinding, and EF Core relational provider tests.

EF Core Specifications for Includes, Projection, and Tracking

Learn how EF Core specifications can shape DTO projections, deliberate includes, tracking behavior, and single or split queries without over-fetching.

An error has occurred. This application may no longer respond until reloaded. Reload