Skip to content

Run a test's application on Kestrel with [KestrelHost] - #275

Merged
ipjohnson merged 2 commits into
mainfrom
test-hosts
Sep 5, 2026
Merged

ipjohnson merged 2 commits into
mainfrom
test-hosts

Conversation

@ipjohnson

Copy link
Copy Markdown
Owner

What

[KestrelHost] on a [HardenedTest], a test class or the assembly runs the test's application on Kestrel, over the test's own container, on a loopback port the kernel picks. Everything the test holds follows it: ITestWebApp sends to the socket, the HttpClient the harness hands out and every Kiota, Refit or hand-written client parameter send to the socket, LastResponse is what came back over the wire, and [Mock], [Grants], [Subject], [Anonymous] and the environment attributes work as they do in-process, because the server resolves handlers from the same container the test does. [PipelineHost] on a method opts one test back to the pipeline inside a socket class.

The seam is in Hardened.Web.Testing: ITestHost, TestHostAttribute, PipelineHost (today's behaviour, moved behind the seam) and SocketHost (the client half every socket host shares). Hardened.Web.Kestrel.Testing is its own package, one attribute and one internal host over KestrelServerRunner, because hosting needs the ASP.NET Core shared framework and a test project on the pipeline must not be forced onto it.

Why

The pipeline harness cannot see the host: Kestrel's framing and headers, a body arriving compressed, a cached response on a socket, a connection cut by a timeout. The 0.19 and 0.20 trials found defects only over a socket, and the guide already tells every suite to budget a smoke test against one; today that is a hand-built harness per test class, available only in this repository, that composes a ServiceCollection by hand and so has no [Mock], no credential attributes and no entry point. TEST-HOST-PLAN.md has the design.

Teardown and ports

  • The host is registered through a factory, so the container disposes it. An instance registration would never be disposed: MS.DI tracks what it created and hands a registered instance back from a constant call site.
  • SocketHost.DisposeAsync disposes its one shared SocketsHttpHandler first, so no request is in flight, then stops the server with a token bounded by SocketHost.StopBound, then disposes it. Kestrel closes what is idle at once and aborts what is left when the bound fires; KestrelServerRunner.DisposeAsync alone stops with CancellationToken.None and would wait on a hung handler for ever, which a test holds it to.
  • Port 0: the kernel hands out a port no socket holds, and the address is read back from IServerAddressesFeature after the bind rather than computed, so parallel tests, classes, processes and CI jobs cannot collide. The client is given the literal 127.0.0.1:port, never localhost.
  • Under xUnit the container, and with it the server, is disposed when the case has run only once DependencyModules 1.3.1 (Dispose a test's container when its case has run DependencyModules#56) is in; until then it lives until the run ends, as every test container does today. The assembly-end leak test follows with that bump.
  • KestrelServerRunner.StartAsync runs the startup services through the guarded ApplicationLogic.Start, so a test whose entry point already ran them does not run them again; KestrelHostingTests still holds the runner to one run.

Held by

  • Hardened.Web.Kestrel.Testing.Tests: the bound address is a loopback port the kernel picked and is not available before start; the request goes in with its method, path, query, headers and body bytes and the answer comes out with its status, Kestrel's headers and the body; an unmatched path is a 404 from the real not-found handler; the credential is applied where a request carries neither test header and left alone where it does; a client built over the host records what it received; an event stream is handed on without being buffered; disposing the container closes the port; and a hung handler cannot hold disposal past the bound.
  • KestrelHostTests in the web SUT's test project, [KestrelHost] on the class: ITestWebApp with the headers Kestrel wrote, a compressed body arriving encoded and reading decoded, a [Mock] behind a route, a Kiota client and a Refit interface read by Returns<T>(), LastResponse, three parameters with three credentials, a 404, the handler's exception not crossing the wire, and [PipelineHost] on one method getting it back.
  • KestrelHostUnderNUnitTests in the NUnit project: the same shapes on the other runner.
  • Kiota and Refit packages unchanged: a route builds over the host's handler and reads TestClientContext.BaseAddress, which is now the bound address.

Notes

  • Stacked on Split the test runner out of Hardened.Shared.Testing #273, the runner split, because LastResponse on a socket is recorded client-side on the runner seam.
  • Pack list 27 to 28; public API approved for Hardened.Web.Testing and the new assembly; the new assembly is not in coverage-baseline.json, which the gate reads as unmeasured.
  • docs/client-testing.md gains an "Over a socket" section. The Docs site's guide is item D of the plan.

🤖 Generated with Claude Code

The host a test's application runs on is now an attribute on the test,
and everything the test holds follows it: ITestWebApp, the HttpClient
the harness hands out, every typed client parameter, and LastResponse.
Hardened.Web.Testing gets the seam - ITestHost, TestHostAttribute, the
pipeline host that was always there, and SocketHost, the client half
every socket host shares - and Hardened.Web.Kestrel.Testing ships
[KestrelHost] over KestrelServerRunner, on a loopback port the kernel
picks, over the test's own container. KestrelServerRunner runs the
startup services through the guarded ApplicationLogic.Start.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
CI's coverage gate found the one branch nothing took: the subject header
on the request a socket host's handler applies the credential to.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@ipjohnson
ipjohnson merged commit ceae3ca into main Sep 5, 2026
2 checks passed
@ipjohnson
ipjohnson deleted the test-hosts branch September 6, 2026 00:04
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.

1 participant