Table of Contents

Trellis.Testing.AspNetCore — API Reference

Package: Trellis.Testing.AspNetCore
Namespaces: Trellis.Testing.AspNetCore, Trellis.Testing.AspNetCore.Http
Purpose: ASP.NET Core integration-test utilities for WebApplicationFactory<TEntryPoint>, dependency replacement, fake time, MSAL-backed Entra E2E tokens, and replaying .http files.

Use this package from test projects only. It depends on Microsoft.AspNetCore.Mvc.Testing, EF Core, MSAL, Trellis.Authorization, and Trellis.Testing.

Use this file when

  • You are writing ASP.NET Core integration tests with WebApplicationFactory<TEntryPoint>.
  • You need to replace services, resource loaders, DB providers, time providers, or actor headers in tests.
  • You want to replay .http files against a test host and assert expected status/header behavior.

Patterns Index

Goal Canonical API / pattern See
Create a client with X-Test-Actor factory.CreateClientWithActor(...) WebApplicationFactoryExtensions
Replace an app service before the host is built WithWebHostBuilder(...ConfigureTestServices(s => s.ReplaceSingleton<T>(fake))) ServiceCollectionExtensions
Replace a resource loader services.ReplaceResourceLoader<TMessage,TResource>(...) ServiceCollectionExtensions
Swap EF provider for integration tests services.ReplaceDbProvider<TContext>(...) ServiceCollectionDbProviderExtensions
Use deterministic time factory.WithFakeTimeProvider(...) WebApplicationFactoryTimeExtensions
Acquire a real Entra token in gated E2E tests MsalTestTokenProvider + CreateClientWithEntraTokenAsync(...) MsalTestTokenProvider
Replay .http files HttpFileParser.ParseFile, HttpFileRunner.RunAsync, HttpFileAssertions.AssertExpectationsMet .http file replay helpers

API failure-path test checklist

  • Include .http requests for the success path and representative failure paths (422, 409, 403, 404, and framework-level 400 when applicable).
  • Keep expected statuses close to the request with # @expect status: ... so replay tests and human examples stay in sync.
  • Use CreateClientWithActor(...) for authorization paths instead of hand-rolling actor headers.
  • Replace services through ConfigureTestServices before the host is built; do not mutate the service collection after CreateClient().

WebApplicationFactory helpers

WebApplicationFactoryExtensions

public static class WebApplicationFactoryExtensions
Signature Returns Description
public static HttpClient CreateClientWithActor<TEntryPoint>(this WebApplicationFactory<TEntryPoint> factory, string actorId, params string[] permissions) where TEntryPoint : class HttpClient Creates a client and writes an X-Test-Actor header containing the actor id, permissions, empty forbidden-permissions list, and empty attributes object. Use with the ASP development/test actor provider.
public static HttpClient CreateClientWithActor<TEntryPoint>(this WebApplicationFactory<TEntryPoint> factory, Actor actor) where TEntryPoint : class HttpClient Creates a client and serializes the full Actor into X-Test-Actor, including forbidden permissions and attributes.
[RequiresUnreferencedCode] public static Task<HttpClient> CreateClientWithEntraTokenAsync<TEntryPoint>(this WebApplicationFactory<TEntryPoint> factory, MsalTestTokenProvider tokenProvider, string testUserName, CancellationToken cancellationToken = default) where TEntryPoint : class Task<HttpClient> Acquires a real Entra ID token with MsalTestTokenProvider, creates a client, and sets the Authorization: Bearer <token> header. Use only in E2E tests against a dedicated test tenant.

WebApplicationFactoryTimeExtensions

public static class WebApplicationFactoryTimeExtensions
Signature Returns Description
public static readonly DateTimeOffset DefaultTestStartInstant DateTimeOffset Deterministic default fake-clock baseline: 2024-01-01T00:00:00Z.
public static WebApplicationFactory<TEntryPoint> WithFakeTimeProvider<TEntryPoint>(this WebApplicationFactory<TEntryPoint> factory, FakeTimeProvider fakeTimeProvider) where TEntryPoint : class WebApplicationFactory<TEntryPoint> Returns a factory configured with the supplied FakeTimeProvider as the singleton TimeProvider.
public static WebApplicationFactory<TEntryPoint> WithFakeTimeProvider<TEntryPoint>(this WebApplicationFactory<TEntryPoint> factory, out FakeTimeProvider fakeTimeProvider) where TEntryPoint : class WebApplicationFactory<TEntryPoint> Creates a FakeTimeProvider at DefaultTestStartInstant, registers it, and returns it through the out parameter.
public static WebApplicationFactory<TEntryPoint> WithFakeTimeProvider<TEntryPoint>(this WebApplicationFactory<TEntryPoint> factory, DateTimeOffset startInstant, out FakeTimeProvider fakeTimeProvider) where TEntryPoint : class WebApplicationFactory<TEntryPoint> Creates a FakeTimeProvider at the supplied instant, registers it, and returns it through the out parameter.

Dependency replacement helpers

ServiceCollectionExtensions

public static class ServiceCollectionExtensions
Signature Returns Description
public static IServiceCollection ReplaceResourceLoader<TMessage, TResource>(this IServiceCollection services, Func<IServiceProvider, IResourceLoader<TMessage, TResource>> factory) IServiceCollection Removes existing IResourceLoader<TMessage,TResource> registrations and adds the supplied scoped factory.
public static IServiceCollection ReplaceSingleton<TService>(this IServiceCollection services, TService instance) where TService : class IServiceCollection Removes all registrations of TService and registers the supplied singleton instance.

ServiceCollectionDbProviderExtensions

public static class ServiceCollectionDbProviderExtensions
Signature Returns Description
public static IServiceCollection ReplaceDbProvider<TContext>(this IServiceCollection services, Action<DbContextOptionsBuilder> configureOptions) where TContext : DbContext IServiceCollection Removes the existing TContext, DbContextOptions<TContext>, and EF Core provider-scoped services for that context, then re-registers TContext with AddDbContext<TContext>(configureOptions). Use for swapping production providers to SQLite/in-memory providers in integration tests.

TContext carries [DynamicallyAccessedMembers(PublicConstructors | NonPublicConstructors | PublicProperties)] for EF Core compatibility.

MSAL / Entra E2E token helpers

MsalTestOptions

public sealed class MsalTestOptions
Member Type Description
TenantId string Azure Entra tenant id or domain.
ClientId string Public-client app registration id. The app must allow public client flows for ROPC.
Scopes string[] Scopes requested when acquiring tokens.
TestUsers Dictionary<string, TestUserCredentials> Named test users keyed by logical role name, e.g. "salesRep".

TestUserCredentials

public sealed class TestUserCredentials
Member Type Description
Username string User principal name or email.
Password string Test-user password. Store outside source control.
ExpectedPermissions string[] Permissions the test user is expected to have after authentication.

MsalTestTokenProvider

[RequiresUnreferencedCode("MSAL uses reflection for token serialization and is not AOT-compatible.")]
public sealed class MsalTestTokenProvider
Signature Returns Description
public MsalTestTokenProvider(MsalTestOptions options) — Creates a public MSAL client for options.ClientId and options.TenantId. Throws InvalidOperationException when TenantId or ClientId is missing, when Scopes is empty, or when any entry inside Scopes is blank (empty or whitespace-only).
public Task<string> AcquireTokenAsync(string testUserName, CancellationToken cancellationToken = default) Task<string> Acquires an access token for the named user via MSAL ROPC. Throws KeyNotFoundException if testUserName is not configured and MsalException when token acquisition fails.

The constructor validates required MSAL configuration before calling MSAL: TenantId and ClientId must be non-empty (and non-whitespace) strings, Scopes must contain at least one entry, and every entry in Scopes must itself be a non-empty, non-whitespace scope URI. A blank entry in the middle of an otherwise-valid array (e.g. ["api://valid/.default", ""]) is rejected because MSAL would otherwise surface its own opaque downstream error.

ROPC is deprecated for production use. Use this helper only for gated E2E tests against a dedicated test tenant with MFA disabled for test users.

.http file replay helpers

These types live in namespace Trellis.Testing.AspNetCore.Http.

HttpFileParser

public static class HttpFileParser
Signature Returns Description
public static IReadOnlyList<HttpFileRequest> Parse(string content, IReadOnlyDictionary<string, string>? vars = null) IReadOnlyList<HttpFileRequest> Parses raw .http content. File-level @var = value entries and supplied vars are substituted immediately; response placeholders and the reserved {{$guid}} token are left for the runner.
public static IReadOnlyList<HttpFileRequest> ParseFile(string path, IReadOnlyDictionary<string, string>? vars = null) IReadOnlyList<HttpFileRequest> Reads and parses a .http file from disk.

Supported syntax: ### request separators, # @name, # @expect status: 201, # @expect status: 2xx, # @expect status: 200-299, # @expect header: ETag, # @expect content-type: application/problem+json, # @parity: status-only, file variables, {{var}}, {{$guid}}, {{name.response.body.path}}, {{name.response.headers.Header-Name}}, and {{name.response.status}}.

A separator line consisting of nothing but # characters is a divider rather than a title, and it discards any directives accumulated above it. That is what lets a file header document a directive by quoting it without thereby imposing it on the first request below.

HttpFileRunner

public static class HttpFileRunner
Signature Returns Description
public static Task<IReadOnlyList<HttpFileResult>> RunAsync(HttpClient client, IReadOnlyList<HttpFileRequest> requests, CancellationToken ct = default) Task<IReadOnlyList<HttpFileResult>> Executes parsed requests in order, sharing a ScenarioContext so named responses can feed later substitutions.
public static Task<HttpFileResult> RunSingleAsync(HttpClient client, HttpFileRequest request, ScenarioContext context, CancellationToken ct = default) Task<HttpFileResult> Resolves deferred response placeholders and dynamic GUIDs, rejects unresolved placeholders before sending, records named responses, reads the response body as text, and returns the result.

Replay variables and failure handling

Static variable names are case-insensitive; file-level values override supplied vars. {{$guid}} is reserved and is not a static variable: each occurrence in a URL, header value (including content headers), or body generates a fresh D-format GUID at execution. Reusing the same parsed request list generates new GUIDs on every replay without mutating the templates. Multiple occurrences in one request are independent, including an alias such as @key = {{$guid}}. Use a supplied/file-level literal value when several requests must share one idempotency key, and captured response tokens when later requests need a created ID. Header names must be literal; placeholders in names are rejected rather than expanded.

Both runner entry points throw HttpFileAssertionException before sending an offending request if its resolved URL, header name/value, or body still contains {{...}} or an unterminated {{. The message identifies the token (or unterminated opening), request title, and location without dumping the body or header values. This includes missing static values, missing named responses/JSON paths/headers, and unsupported dynamic variables such as {{$timestamp}}, {{$randomInt}}, and {{$datetime}}. Captured response values are not recursively evaluated as templates; any placeholder they introduce is rejected too.

RunAsync stops at that request; earlier sends are not rolled back and later requests are not sent. On successful return the caller owns every response. If the sequence throws, the runner disposes responses from earlier completed requests because no result list is returned.

For a CI guard over a service's .http examples, use a WebApplicationFactory client and HttpFileAssertions.AssertExpectationsMet in-process; no separately running host or PowerShell is required. The Showcase replay-api-http.ps1 is a separate live-host transcript tool: it supports environment JSON/-Set variables and the same per-occurrence {{$guid}}, but does not implement file-level variables or named-response chaining. Unresolved tokens terminate that script before sending the offending request, outside its transport-error reporting. Fresh GUIDs do not reset seeded state or fixed-key idempotency scenarios.

HttpFileAssertions

public static class HttpFileAssertions
Signature Returns Description
public static void AssertExpectationsMet(HttpFileResult result) void Enforces the request's ExpectedOutcome: status range, required header presence, and — when ContentType is set — the response media type. If no expectations were declared, asserts the status is in the non-error range 100-399. Throws HttpFileAssertionException on failure.

# @expect content-type: compares the media type only, case-insensitively, ignoring parameters on both sides — so application/problem+json matches an actual application/problem+json; charset=utf-8. Assert it on error responses to pin RFC 9457 behaviour: [Produces("application/json")] can rewrite MVC ProblemDetails object results (including explicit Problem() / ValidationProblem() responses) to application/json while leaving the status and problem body intact, so a status-only assertion misses the regression. Automatic invalid-ModelState responses can be affected without the Trellis scalar-validation filter; with that filter registered, both scalar and DataAnnotations validation failures retain application/problem+json.

Do not assert a content type on a failure produced before the endpoint runs. A route-constraint rejection (for example a {id:AccountId} template refusing a malformed id) returns an empty 404 with no media type at all, because Trellis' error mapping never executes.

Data records and support types

Type Declaration Description
ExpectedOutcome public sealed record ExpectedOutcome(int? StatusMin, int? StatusMax, IReadOnlyList<string> RequiredHeaders, string? ContentType = null) Parsed # @expect status, header, and content-type assertions. ContentType is the expected content-type value exactly as written in the file; it is not normalized on write, and parameters such as charset are ignored when matching.
HttpFileRequest public sealed record HttpFileRequest(string Title, string Method, string Url, IReadOnlyDictionary<string, string> Headers, string? Body, string? Name, ExpectedOutcome? Expected, string? ParityMode = null) One parsed request. Url, header values, and Body may contain deferred response/GUID placeholders. Header names must be literal.
HttpFileResult public sealed record HttpFileResult(HttpFileRequest Request, HttpResponseMessage Response, string? Body, ExpectedOutcome? Expected) One executed request and response. Caller owns Response disposal.
HttpFileTheoryData public static class HttpFileTheoryData Provides FromFile(string path, IReadOnlyDictionary<string,string>? vars = null) : IEnumerable<object[]> for xUnit-style member data without taking an xUnit dependency.
ScenarioContext public sealed class ScenarioContext Records named responses and resolves {{name.response.*}} tokens. Public members: Record(...) and TryResolve(...).
HttpFileAssertionException public sealed class HttpFileAssertionException : Exception Thrown by HttpFileAssertions for unmet expectations or HttpFileRunner for unresolved/unterminated placeholders. Constructors: (), (string message), and (string message, Exception inner).

Common examples

Authenticated in-process client

using Trellis.Testing.AspNetCore;

var client = factory.CreateClientWithActor("user-1", "orders:read", "orders:write");

Replace EF provider and fake time

using Microsoft.EntityFrameworkCore;
using Trellis.Testing.AspNetCore;

factory = factory.WithFakeTimeProvider(out var fakeTime);
fakeTime.SetUtcNow(WebApplicationFactoryTimeExtensions.DefaultTestStartInstant.AddDays(3));

builder.ConfigureServices(services =>
    services.ReplaceDbProvider<AppDbContext>(options =>
        options.UseSqlite(connection)));
Note

SQLite cannot translate DateTimeOffset in ORDER BY clauses. If your aggregate queries sort on inherited audit columns (CreatedAt, LastModified), register an Acl layer ValueConverter per the canonical pattern in Provider-specific column mapping. Without it, integration tests using the SQLite swap above will fail with a DateTimeOffset translation error at query time.

Replay a .http file

using Trellis.Testing.AspNetCore.Http;

var requests = HttpFileParser.ParseFile("Scenarios/orders.http", new Dictionary<string, string>
{
    ["baseUrl"] = factory.Server.BaseAddress!.ToString().TrimEnd('/')
});

var results = await HttpFileRunner.RunAsync(client, requests, cancellationToken);

try
{
    foreach (var result in results)
        HttpFileAssertions.AssertExpectationsMet(result);
}
finally
{
    foreach (var result in results)
        result.Response.Dispose();
}

See also