Skip to content

Tests: EF Core integration tests for scope per dispatch, and mutation-testing gaps - #5

Merged
nifragos merged 3 commits into
mainfrom
claude/adoring-brown-01p0oh
Oct 17, 2026
Merged

nifragos merged 3 commits into
mainfrom
claude/adoring-brown-01p0oh

Conversation

@nifragos

Copy link
Copy Markdown
Contributor

Tests only. Nothing under src/ changes, and no existing test is modified. There are two independent pieces, one per commit group.

1. EF Core integration tests (tests/Switchboard.IntegrationTests/, new project)

Until now the Blazor Server promise in the README was tested only with a stand-in ScopeMarker. These tests prove it against real EF Core (SQLite, in memory) on EF 8, 9 and 10.

Why a separate project: Switchboard.Tests pins Microsoft.Extensions.DependencyInjection at 8.0.0 / 9.0.0 / 10.0.0 to test the floor the library supports. EF Core would drag that upward, so EF lives in its own project, added to Switchboard.slnx so CI's plain dotnet test runs it. Package versions per framework:

  • Microsoft.EntityFrameworkCore.Sqlite 8.0.32 (net8.0)
  • 9.0.21 (net9.0)
  • 10.0.13 (net10.0)

Deterministic, not timing-based: Microsoft.Data.Sqlite completes async calls synchronously, so dispatches never overlap on their own. A DbCommandInterceptor holds a chosen command on a gate (CommandGate), so one dispatch is provably inside EF's critical section while the test starts another. There is no Task.Delay; the only timeout is a 30 s hang guard.

What it proves (14 tests):

  • Without UseScopePerDispatch(), two overlapping Sends from one long-lived "circuit" scope share its DbContext. EF throws the real error: "A second operation was started on this context instance before a previous operation completed." It is the same message on EF 8, 9 and 10.
  • With it, the same overlap succeeds. Each dispatch gets its own DbContext, which is disposed with the dispatch, and both rows are persisted.
  • A nested Send (and a Publish of a domain event) from a handler shares the outer DbContext and its transaction: when the outer handler rolls back, nothing is persisted.
  • Streaming:
    • With scope-per-dispatch, writes sent from inside await foreach each get their own context.
    • The stream's context lives for the whole enumeration and is disposed at the end, or on early disposal.
    • Without it, a write in flight while the stream reads its next row throws the same EF error, from the stream's MoveNextAsync.
  • The UseScopePerDispatch(callback) overloads carry the circuit's current user into the dispatch scope, even with two circuits overlapping. A bare UseScopePerDispatch() doesn't, which shows why the callback exists.

One finding, not a bug: a stream handler reading rows while the consumer sequentially writes inside the loop on the shared context does not fail on SQLite. EF only guards each MoveNextAsync, and SQLite allows a command while a reader is open. It would likely fail on SQL Server without MARS ("There is already an open DataReader…"), but that can't be tested here. The README doesn't claim this case fails, so no docs change. One test documents the actual behavior.

2. Tests for gaps found by mutation testing (Stryker.NET)

Mutation testing makes small deliberate changes to the code and checks whether any test notices. The score went from 77.85% to 92.18% with 24 new tests:

  • ValidationGapTests.cs:
    • the missing void handler, and the concrete-request filters
    • the "names five, and N more" message
    • the class / struct / new() / self-referencing constraint branches
    • an unrelated open generic service never being reported
    • the null guard
  • DispatchGapTests.cs:
    • a void handler that really completes or fails asynchronously, which no test covered before
    • a failing notification handler stopping the ones after it
    • guard messages
    • a stream request with extra interfaces
    • display names of nested generic requests
    • null callback / null services guards
  • TelemetryGapTests.cs:
    • histogram units
    • a notification handler's error span and exception event
    • cancellation tagged but not marked as an error, for requests and streams
    • a traced stream still honoring the WithCancellation token

The 24 survivors left alone fall into five groups:

  • 7 allocation-only fast paths
  • 10 exception or description text
  • 5 equivalent mutants
  • 1 unreachable catch
  • 1 cosmetic >= 5 boundary

Stryker can't instrument SwitchboardTelemetry.ObserveStream: its own rewrite fails to compile with CS0165. That method was therefore mutation-tested by hand with 16 mutants, and the one survivor is now killed. No bugs were found.

To re-run, the commit adds Stryker as a local tool (.config/dotnet-tools.json, stryker-config.json; StrykerOutput/ is git-ignored): dotnet tool restore && dotnet stryker. It is not wired into CI. It takes a while, which is better for an occasional local run.

Verification

dotnet build -c Release gives 0 warnings. dotnet test -c Release passes 155 unit + 14 integration tests on net8.0, net9.0 and net10.0, in 3 consecutive full runs.


Generated by Claude Code

New project tests/Switchboard.IntegrationTests, added to Switchboard.slnx so
CI's plain dotnet restore/build/test runs it. It is kept apart from
Switchboard.Tests so EF Core never lifts that project's pinned
Microsoft.Extensions.DependencyInjection floor. EF Core Sqlite 8.0.32 /
9.0.21 / 10.0.13 per framework, against a shared-cache in-memory database.

A Blazor circuit is simulated as one long-lived scope with a scoped
AddDbContext and the ISender resolved from it. A DbCommandInterceptor gate
holds one command just before it executes, so a dispatch is provably inside
EF's critical section when the next one starts, with no timing involved.

Covered:
- Without scope per dispatch, overlapping sends share the circuit's DbContext
  and EF throws "A second operation was started on this context instance".
  With it, each gets its own context and both rows are saved.
- Nested sends and published events share the outer DbContext and
  transaction; rolling back the outer transaction discards the nested write.
- Streaming: a write sent inside await foreach gets its own context and the
  stream's scope ends with the enumeration. A save still in flight when the
  shared-context stream reads its next row makes EF throw; with scope per
  dispatch it works, and abandoning the stream disposes its scope.
- The UseScopePerDispatch callback (async and sync) carries the circuit's
  user into the dispatch, including across overlapping dispatches from two
  circuits; without it the dispatch scope has no user.

Finding: a write awaited inside await foreach over a shared-context
AsAsyncEnumerable() does not fail on SQLite. EF holds its concurrency check
only during each MoveNextAsync, and Microsoft.Data.Sqlite allows a command
while a reader is open. A test documents that behavior as observed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qh4oHys2D2TLycvt3yKVtX
`dotnet tool restore && dotnet stryker` mutates src/Switchboard against the
test project on net10.0 (Release) and writes progress, cleartext, HTML and
JSON reports to StrykerOutput/, which is now git-ignored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qh4oHys2D2TLycvt3yKVtX
Stryker.NET left 68 mutants alive (47 survived, 21 without coverage); these
tests kill the ones that stand for behavior that could regress unnoticed.

Validation (ValidationGapTests):
- a void request without a handler is reported;
- abstract, interface and open generic request types are not reported;
- the void-request skip check: five names then "and N more", an exact
  message for a single skipped request, class/struct/new() constraints on
  TRequest and TResponse, a constraint on the behavior's own type parameter,
  and open generic services that are not pipeline behaviors;
- ValidateSwitchboard names its null argument.

Dispatch (DispatchGapTests):
- Send of a void request awaits an async handler and surfaces its failure;
- a failing notification handler fails Publish and stops later handlers;
- Publish of a non-notification names its parameter;
- a stream request that implements another interface still streams;
- a request nested in a generic type dispatches and gets a readable name;
- UseScopePerDispatch and AddSwitchboard reject null arguments up front.

Telemetry (TelemetryGapTests):
- histogram units are seconds;
- a failing notification handler's span carries its tags, error status and
  the OpenTelemetry exception event;
- cancelled requests and streams are tagged but not marked as errors;
- a traced stream still passes the WithCancellation token to the handler
  (found by hand-mutating ObserveStream, which Stryker's safe mode skips).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qh4oHys2D2TLycvt3yKVtX
@nifragos
nifragos merged commit 23e393a into main Oct 17, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants