Skip to content

Sending requests ​

ITestWebApp is a test parameter that sends a request through the application's pipeline and returns a TestWebResponse. Routing, the filters, parameter binding, the handler and serialization run. No socket is opened and no host is started.

csharp
using DependencyModules.xUnit.Attributes;
using Hardened.Web.Testing;

namespace Todos.Tests;

public class TodoRequestTests
{
    [ModuleTest]
    public async Task GetTodo_ReturnsTheTodo(ITestWebApp app)
    {
        var response = await app.Get("/todos/1");

        response.Assert.Ok();
        Assert.Equal("Read the generated code", response.Deserialize<Todo>().Title);
    }

    [ModuleTest]
    public async Task CreateTodo_AnswersCreated(ITestWebApp app)
    {
        var response = await app.Post(new NewTodo("Write a test"), "/todos");

        Assert.Equal(201, response.StatusCode);
        Assert.Equal("/todos/3", response.Headers["Location"].ToString());
    }
}

The tests on this page are classes in the tests/Todos.Tests project that dotnet new hardened-web -n Todos creates. Todo and NewTodo are the application's own records. The template's test project references the library in src/Todos. The store starts with two todos, so the POST answers 201 with Location: /todos/3.

ITestWebApp and TestWebResponse are in the Hardened.Web.Testing namespace and package.

Setup ​

The test project references the Hardened.Web.Testing package. The test project of the hardened-web template already does. [assembly: WebTesting] turns the package on. It goes beside [assembly: HardenedTestEntryPoint], which Writing a test covers with the rest of the project setup. This excerpt is from the template's tests/Todos.Tests/Bootstrap.cs:

csharp
using Hardened.Shared.Testing.Attributes;
using Hardened.Web.Testing;
using Todos;

[assembly: WebTesting]
[assembly: HardenedTestEntryPoint(typeof(TodosLibrary))]

[WebTesting] is valid on the assembly only. It registers ITestWebApp, a principal source for test credentials, and a client for each test parameter of a client type. Typed clients covers the clients. Without the attribute, a test that takes ITestWebApp fails with InvalidOperationException: Instances of abstract classes cannot be created.

ITestWebApp is also an ITestContext, so it carries Step, Retry, Logger and CancellationRequest. Writing a test covers them.

Request methods ​

Each method returns Task<TestWebResponse>.

MethodSignature
GetGet(string path, Action<TestWebRequest>? webRequest = null)
PostPost(object value, string path, Action<TestWebRequest>? webRequest = null)
PutPut(object value, string path, Action<TestWebRequest>? webRequest = null)
PatchPatch(object value, string path, Action<TestWebRequest>? webRequest = null)
DeleteDelete(string path, Action<TestWebRequest>? webRequest = null)
RequestRequest(string method, object? value, string path, Action<TestWebRequest>? webRequest = null)

The value comes before the path. The application's own JSON serializer writes the value as JSON, with the application's resolvers. A string, a byte[] or a ReadOnlyMemory<byte> is sent as it is. Request sends any method. A null value sends no body.

A request with a body and no Content-Type header is sent with Content-Type: application/json. A body in another format needs its Content-Type, set in the callback or with RawBody.

The query string goes in the path. The path is percent-decoded the way Kestrel decodes a path:

In the pathReaches the pipeline as
%20A space
%C3%A9é
%2F%2F, as written
+A plus

Headers, raw bodies and cancellation ​

The callback receives a TestWebRequest:

MemberSets
HeadersThe request headers, an IDictionary<string, StringValues> matched without regard to case
RawBody(string text, string contentType = "application/json")The body's bytes and its Content-Type
RawBody(byte[] bytes, string contentType)The body's bytes and its Content-Type
TokenThe request's CancellationToken

The request carries Accept-Encoding: gzip by default. A value that the callback sets for Accept-Encoding is sent instead. In this example, TodoController.All carries [Compress], as on the Compression page:

csharp
using DependencyModules.xUnit.Attributes;
using Hardened.Web.Testing;

namespace Todos.Tests;

public class TodoHeaderTests
{
    [ModuleTest]
    public async Task ListTodos_Uncompressed(ITestWebApp app)
    {
        var response = await app.Get(
            "/todos",
            request => request.Headers["Accept-Encoding"] = "identity"
        );

        Assert.False(response.Headers.ContainsKey("Content-Encoding"));
    }
}

A Cookie header is split into the request's cookies, which [FromCookie] binds.

A raw body replaces the value that the method was called with. This test sends a malformed JSON body:

csharp
using DependencyModules.xUnit.Attributes;
using Hardened.Web.Testing;

namespace Todos.Tests;

public class TodoBodyTests
{
    [ModuleTest]
    public async Task CreateTodo_MalformedJson_IsBadRequest(ITestWebApp app)
    {
        var response = await app.Request(
            "POST",
            null,
            "/todos",
            request => request.RawBody("{\"title\":")
        );

        response.Assert.BadRequest();
    }
}

The 400 is the validation envelope, with "field":"request" and "code":"invalid". Validation covers it.

Without Token, the request carries the test's cancellation token. A handler's CancellationToken parameter receives the token. A handler that throws on the cancelled token answers 500. The response's Failure is then the TaskCanceledException. A token cancelled after a streamed response has started makes the call throw TaskCanceledException.

Reading the response ​

The response has these members:

MemberWhat it is
StatusCodeThe status, 200 when the pipeline set none
HeadersThe response headers, an IDictionary<string, StringValues> matched without regard to case
BodyThe body as a Stream, as the pipeline wrote it, compressed when it was compressed
FailureThe exception behind a failed request, or null
AssertThe status assertions
Deserialize<T>()The body read as JSON
ReadTextAsync()The body as a string
DeserializeAsyncEnumerable<T>()An NDJSON body, one line at a time

A test runs on the pipeline host by default. There, Headers holds what the pipeline wrote, such as Content-Type, Location and X-Correlation-Id. Headers that a server adds, such as Date and Content-Length, are absent. A cookie that the handler sets is in Headers as Set-Cookie.

Deserialize<T>(), ReadTextAsync() and DeserializeAsyncEnumerable<T>() undo a gzip or Brotli Content-Encoding before reading. Any other content coding throws BadContentEncodingException. Deserialize<T>() and DeserializeAsyncEnumerable<T>() read with what the application's JSON response serializer writes with. That is SerializeOptions from JsonSerializerConfiguration, or System.Text.Json's web defaults when it is not set, with every registered IJsonTypeInfoResolver ahead of reflection. An enum therefore reads back in the vocabulary the build writes for it. Names match without regard to case. A TestWebResponse that the test constructs itself reads with the web defaults.

Deserialize<T>() reads Body from where it stands and does not rewind it. A second Deserialize<T>(), or one after ReadTextAsync(), throws JsonException. Deserialize<T>() on an empty body, such as a 204's, throws JsonException too. ReadTextAsync() and DeserializeAsyncEnumerable<T>() rewind Body before reading.

On the pipeline host, the call returns when the handler has finished, so every item of a stream is in Body. A socket host hands a stream on as the server writes it, as The last response describes. DeserializeAsyncEnumerable<T>() skips blank lines. It throws JsonException on the data: lines of a server-sent-events body. ReadTextAsync() returns those lines. Streaming responses covers the stream formats.

In this example, TodoController.All carries [Compress]. TodoFeedController, from the first example of Streaming responses, answers GET /todos/feed.

csharp
using DependencyModules.xUnit.Attributes;
using Hardened.Web.Testing;

namespace Todos.Tests;

public class TodoResponseTests
{
    [ModuleTest]
    public async Task ListTodos_IsCompressed(ITestWebApp app)
    {
        var response = await app.Get("/todos");

        Assert.Equal("gzip", response.Headers["Content-Encoding"].ToString());
        Assert.Equal(2, response.Deserialize<List<Todo>>().Count);
    }

    [ModuleTest]
    public async Task TheDocumentNamesGetTodo(ITestWebApp app)
    {
        var document = await (await app.Get("/openapi.json")).ReadTextAsync();

        Assert.Contains("\"getTodo\"", document);
    }

    [ModuleTest]
    public async Task Feed_StreamsEveryTodo(ITestWebApp app)
    {
        var response = await app.Get("/todos/feed");

        var titles = new List<string>();

        await foreach (var todo in response.DeserializeAsyncEnumerable<Todo>())
        {
            titles.Add(todo.Title);
        }

        Assert.Equal(["Read the generated code", "Add an endpoint"], titles);
    }
}

In the first test, Body starts with the gzip header 1F 8B.

Status assertions ​

Assert on the response checks the status:

CallPasses on
response.Assert.Ok()Any status from 200 to 299
response.Assert.BadRequest()400
response.Assert.Unauthorized()401
response.Assert.Forbidden()403
response.Assert.NotFound()404

On any other status, the call throws WebAssertionException. xUnit and NUnit report it as the test's failure. Ok() on a 404 fails with Expected a 2xx status, the response was 404. NotFound() on a 200 fails with Expected status 404, the response was 200.

A test asserts any other status on StatusCode, as the first example does for the 201.

Credentials ​

[Grants], [Subject], [Claim] and [Anonymous] name who a test's requests are sent as. They are in Hardened.Web.Testing. They send three headers, X-Test-Grants, X-Test-Subject and X-Test-Claims:

AttributeSends
[Grants("todos:read", "todos:write")]X-Test-Grants: todos:read todos:write
[Grants], with no grantsX-Test-Grants: -
[Subject("pia")]X-Test-Subject: pia, and X-Test-Grants: - when no grants are in scope
[Claim("tenant", "acme")]X-Test-Claims: tenant=acme, and X-Test-Grants: - when no grants are in scope
[Anonymous]None of them

[Claim] is repeatable, one per claim. Two claims send X-Test-Claims: tenant=acme&region=eu, with each name and value percent-encoded.

ITestWebApp, an HttpClient parameter and every client built for a parameter send the credential. They send it on a socket host too.

In this example, TodoController.ById carries [AuthorizeGrants("todos:read")], as in the first example of Authorization:

csharp
using DependencyModules.xUnit.Attributes;
using Hardened.Web.Testing;

namespace Todos.Tests;

[Grants("todos:read")]
public class TodoReaderTests
{
    [ModuleTest]
    public async Task AReaderGetsTheTodo(ITestWebApp app)
    {
        (await app.Get("/todos/1")).Assert.Ok();
    }

    [ModuleTest]
    [Anonymous]
    public async Task NobodyIsUnauthorized(ITestWebApp app)
    {
        (await app.Get("/todos/1")).Assert.Unauthorized();
    }

    [ModuleTest]
    public async Task AWriterIsForbidden([Grants("todos:write")] ITestWebApp writer)
    {
        (await writer.Get("/todos/1")).Assert.Forbidden();
    }
}

The attributes are valid on a parameter, a method, a class and the assembly. They apply widest first: the assembly, then the class, then the method, then the parameter. Each one changes what the wider levels set:

AttributeGrantsSubjectClaims
[Grants]ReplacedKeptKept
[Subject]KeptReplacedKept
[Claim]KeptKeptAdded, replacing a wider claim of the same name
[Anonymous]ClearedClearedCleared

A parameter with no attribute takes the method's credential.

[Shared] beside a credential attribute on a parameter sends that parameter's requests to one container, as it does alone. Writing a test covers [Shared].

How the caller is authenticated ​

[WebTesting] registers TestGrantsPrincipalSource beside the application's own principal sources. The source is in Hardened.Requests.Testing. Authentication covers principal sources.

The source authenticates a request that carries X-Test-Grants. The caller holds the grants that the header names, separated by spaces. A value of - names no grants. The caller's subject is the value of X-Test-Subject, or integration-test when that header is absent. The caller's claims are the pairs in X-Test-Claims, which ICallerPrincipal.TryGetClaim reads. The caller's scheme is test. A request without X-Test-Grants is left to the application's own sources, including a request that carries only X-Test-Subject.

Authorization sees the caller. [AuthorizeGrants] and ICurrentCaller read its grants and subject. A caller with no grants gets 403. A request with no caller gets 401.

Outside a test, nothing registers the source, so the test headers authenticate nobody. The application with [AuthorizeGrants("todos:read")] on ById, run on Kestrel, answers a request that carries them with a 401:

http
GET /todos/1
X-Test-Grants: todos:read
X-Test-Subject: pia

HTTP/1.1 401 Unauthorized
Content-Type: application/problem+json
WWW-Authenticate: Bearer

{"detail":"This request requires authentication.","type":"urn:hardened:problem:unauthorized","title":"Unauthorized","status":401}

Setting the caller in the test ​

A parameter's attribute applies to that parameter alone, so two parameters of one type with different attributes are two callers. A callback that sets X-Test-Grants, X-Test-Subject or X-Test-Claims sends what it set, and nothing from the attributes. A callback that sets only X-Test-Subject therefore sends no caller. TestGrantsPrincipalSource.FormatClaims writes a value for X-Test-Claims.

The attributes resolve to a TestCredential, declared as TestCredential(IReadOnlyList<string>? Grants, string? Subject = null), with the claims in its Claims property. TestCredential.Anonymous sends none of the headers. An empty grant list sends X-Test-Grants: -. app.CreateClient<T>(credential) and app.CreateHttpClient(credential) build a client that sends the credential given. Without a credential, they send the credential in scope.

ById carries the same [AuthorizeGrants("todos:read")] in this example:

csharp
using DependencyModules.xUnit.Attributes;
using Hardened.Web.Runtime.Responses;
using Hardened.Web.Testing;
using Todos.Client;

namespace Todos.Tests;

public class TodoCallerTests
{
    [ModuleTest]
    public async Task ReadingATodoNeedsTheGrant(
        [Grants("todos:read")] TodosClient reader,
        [Anonymous] TodosClient nobody,
        [Grants("todos:write")] TodosClient writer
    )
    {
        await reader.Todos[1].GetAsync().Returns<Ok<ClientModels.Todo>>();
        await nobody.Todos[1].GetAsync().ReturnsStatus<Unauthorized>();
        await writer.Todos[1].GetAsync().ReturnsStatus<Forbidden>();
    }

    [ModuleTest]
    public async Task AHeaderSetInTheTestIsSentAsIs(ITestWebApp app)
    {
        var response = await app.Get(
            "/todos/1",
            request => request.Headers["X-Test-Grants"] = "todos:read"
        );

        response.Assert.Ok();
    }

    [ModuleTest]
    public async Task ACredentialBuiltInTheTest(ITestWebApp app)
    {
        var reader = app.CreateClient<TodosClient>(new TestCredential(["todos:read"], "pia"));

        await reader.Todos[1].GetAsync().Returns<Ok<ClientModels.Todo>>();
    }
}

Returns<T>() and ReturnsStatus<T>() assert a call through a client. Typed clients covers them.

The last response ​

LastResponse holds the most recent response that the pipeline answered in the running test. It is a static class in Hardened.Web.Testing. It records the response to a request sent through ITestWebApp, through an HttpClient or through a client built for a parameter. It also records a response that the client threw on. It holds what a client does not hand back, such as the status and the Location of a 201 that the client returned as a body:

csharp
using DependencyModules.xUnit.Attributes;
using Hardened.Web.Testing;
using Todos.Client;

namespace Todos.Tests;

public class TodoLastResponseTests
{
    [ModuleTest]
    public async Task CreateTodo_AnswersCreated(TodosClient client)
    {
        var todo = await client.Todos.PostAsync(new ClientModels.NewTodo { Title = "ship it" });

        Assert.Equal(201, LastResponse.Status);
        Assert.Equal($"/todos/{todo!.Id}", LastResponse.Headers["Location"]);
    }
}
MemberWhat it is
StatusThe status, 200 when the pipeline set none
HeadersThe headers, an IReadOnlyDictionary<string, StringValues> matched without regard to case
ContentTypeThe content type, null when there is none, as on a 204
BodyThe body as a byte[], as written, compressed when it was compressed
IsAvailableWhether the running test has had a response

The recorded response is kept per running test, so tests that run in parallel each read their own. Reading LastResponse before any response throws InvalidOperationException with this message: LastResponse has nothing to report: no request has been answered through the pipeline in 'Todos.Tests.TodoTimingTests.ReadsLastResponseFirst'. Send one through ITestWebApp, or through a client it built, before reading it. The quoted name is the running test's.

On a socket host, LastResponse holds what came back over the wire, with the server's headers. The body reaches the client as the server writes it, so a test that sends with HttpCompletionOption.ResponseHeadersRead reads each NDJSON line or event when the server flushes it. LastResponse has the status and the headers from the start. It has the body when the client buffered it, which HttpClient does unless a request is sent with ResponseHeadersRead. A body read as a stream, such as an event stream, is recorded as empty.

The exception behind a failed request ​

The response's Failure is the exception that the pipeline recorded when it failed or refused the request, or null. In this example, [Mock] makes the store throw. Substituting services covers [Mock].

csharp
using DependencyModules.xUnit.Attributes;
using Hardened.Web.Testing;
using NSubstitute;

namespace Todos.Tests;

public class TodoFailureTests
{
    [ModuleTest]
    public async Task GetTodo_StoreFails_IsAServerError(ITestWebApp app, [Mock] ITodoStore store)
    {
        var offline = new InvalidOperationException("The store is offline.");

        store.Find(1).Returns(Task.FromException<Todo?>(offline));

        var response = await app.Get("/todos/1");

        Assert.Equal(500, response.StatusCode);
        Assert.Equal("The store is offline.", response.Failure?.Message);
    }
}
CaseStatusFailure
The handler throws500The exception that the handler threw
Validation or binding fails400A ValidationException
The JSON body is malformed400A JsonException
Authorization refuses the request401 or 403An AuthorizationException
The handler returns a status, such as NotFound or ConflictThe returned statusNull
No route matches the path404Null

For a thrown exception, the 500's body is {"detail":"The server could not complete this request.","type":"urn:hardened:problem:internal-server-error","title":"Internal Server Error","status":500}. Failure is null on a socket host, where only the response crosses the wire.

The pipeline host ​

The pipeline host runs the application's chain in the test's own call. It is the chain that app.UseHardened() and the Kestrel host run. A path with no route answers 404, including in an application that names [AspNetCoreRuntime].

On the pipeline host, each request runs against a container of its own. Writing a test covers the container per request and [Shared]. RootServiceProvider on ITestWebApp is the test's container, not a request's. A service read from it after a request does not show what the request changed, unless the parameter carries [Shared].

Kestrel, ASP.NET Core middleware outside Hardened and TLS do not run. Test hosts covers running a test over a socket.

Next ​

PageCovers
Typed clientsA generated client as a test parameter, and Returns<T>()
Substituting servicesA substitute behind a route
Test hostsThe same requests over a socket
AuthorizationThe grants a request is judged against

Released under the MIT License.