Skip to content
Matthew Adams By Matthew Adams Co-Founder · 4 min read
OpenAPI Code Generation with Corvus: Testing

At endjin, we maintain Corvus.JsonSchema, and in the previous post we looked at filtering large specs down to what you actually need.

Now let's talk about testing. For generated code, it raises an interesting question: what exactly are you testing?

What to test (and what not to)

The generated code itself - the serialization logic, parameter encoding, schema validation, response parsing - is already tested in the Corvus.JsonSchema test suite. You don't need to verify that MatchResult dispatches correctly or that a query parameter gets URL-encoded. That's the generator's job.

What you do need to test is your business logic: the handlers you implement, the decisions your client makes based on responses, and the integration between your code and the generated infrastructure. The generated code gives you clean seams for all three levels.

Unit testing handlers

A generated handler is just a method that takes typed parameters and returns a typed result. You don't need an HTTP server, middleware, or transport to test it. Just construct the params, call the method, and inspect what comes back:

[Test]
public async Task ListPets_WithLimit_ReturnsAtMostLimitItems()
{
    // Arrange
    PetsHandler handler = new(petRepository);
    using JsonWorkspace workspace = JsonWorkspace.Create();

    using var paramsDoc = ParsedJsonDocument<ListPetsParams>.Parse(
        """{"limit": 2}"""u8.ToArray());
    ListPetsParams parameters = paramsDoc.RootElement;

    // Act
    ListPetsResult result = await handler.HandleListPetsAsync(
        parameters, workspace, CancellationToken.None);

    // Assert
    result.MatchResult(
        matchOk: pets =>
        {
            Assert.That(pets.GetArrayLength(), Is.LessThanOrEqualTo(2));
            return 0;
        },
        matchDefault: _ =>
        {
            Assert.Fail("Expected Ok result");
            return 0;
        });
}

This is fast. There's no HTTP stack involved, no network, and no process startup. It exercises your business logic in isolation. The JsonWorkspace provides pooled memory for the response builder, just as it would in production.

Notice that you're testing behaviour, not serialization. You don't need to check that the response body is valid JSON. The generated code guarantees that. You're verifying that your handler returns the right result for the given input.

Testing error paths

Because the handler's result type has factory methods for each declared status code, you can assert on the specific error path:

[Test]
public async Task ShowPet_WithUnknownId_ReturnsNotFound()
{
    PetsHandler handler = new(emptyRepository);
    using JsonWorkspace workspace = JsonWorkspace.Create();

    using var paramsDoc = ParsedJsonDocument<ShowPetByIdParams>.Parse(
        """{"petId": "nonexistent-id"}"""u8.ToArray());

    ShowPetByIdResult result = await handler.HandleShowPetByIdAsync(
        paramsDoc.RootElement, workspace, CancellationToken.None);

    result.MatchResult(
        matchOk: _ =>
        {
            Assert.Fail("Expected NotFound");
            return 0;
        },
        matchDefault: error =>
        {
            Assert.That((string)error.Message, Does.Contain("not found"));
            return 0;
        });
}

The exhaustive MatchResult pattern means your test explicitly handles all response paths. If the spec adds a new status code and you regenerate, the test won't compile until you handle the new case. It's the same safety net you get in production code.

Integration testing with WebApplicationFactory

For end-to-end testing that exercises the full pipeline - routing, middleware validation, your handler, response serialization - use ASP.NET Core's WebApplicationFactory:

[Test]
public async Task CreatePet_EndToEnd_ReturnsCreatedPet()
{
    await using var factory = new WebApplicationFactory<Program>();
    using HttpClient httpClient = factory.CreateClient();
    await using HttpClientTransport transport = new(httpClient);
    await using ApiPetsClient client = new(transport);

    await using CreatePetResponse response = await client.CreatePetAsync(
        body: new NewPet.Source((ref NewPet.Builder b) =>
        {
            b.Create(name: "Luna"u8, tag: "cat"u8);
        }));

    response.MatchResult(
        matchCreated: pet =>
        {
            Assert.That((string)pet.Name, Is.EqualTo("Luna"));
            Assert.That((string)pet.Tag, Is.EqualTo("cat"));
            return 0;
        },
        matchDefault: _ =>
        {
            Assert.Fail("Expected Created");
            return 0;
        });
}

This test exercises everything: the generated client serializes the request, the generated server middleware parses and validates it, your handler runs, the response is serialized, and the generated client parses and validates the response. Any drift between client and server will surface here. That includes a schema change or a missing required field.

Testing validation rejection

You'll also want to verify that the generated middleware correctly rejects invalid requests before your handler is called:

[Test]
public async Task ListPets_WithLimitExceedingMaximum_Returns400()
{
    await using var factory = new WebApplicationFactory<Program>();
    using HttpClient httpClient = factory.CreateClient();

    // Bypass the generated client's client-side validation
    // to test the server's independent validation
    using var request = new HttpRequestMessage(HttpMethod.Get, "/pets?limit=999");
    using HttpResponseMessage response = await httpClient.SendAsync(request);

    Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
}

This tests the server's validation independently of the client. In production, the generated client would reject limit: 999 before sending. But the server shouldn't trust that. It validates independently. This test confirms both sides of the contract are enforced.

Testing client code with an in-memory transport

When testing code that uses a generated client (not the client itself), you don't want real HTTP. Implement IApiTransport with canned responses:

internal sealed class FakeTransport : IApiTransport
{
    public ValueTask<TResponse> SendAsync<TRequest, TResponse>(
        in TRequest request,
        CancellationToken cancellationToken = default)
        where TRequest : struct, IApiRequest<TRequest>
        where TResponse : struct, IApiResponse<TResponse>
    {
        // Return a canned 200 response for list operations
        return TResponse.CreateAsync(
            200,
            new MemoryStream("""[{"id":1,"name":"Luna","tag":"cat"}]"""u8.ToArray()),
            "application/json",
            cancellationToken: cancellationToken);
    }

    // ... other overloads
    public ValueTask DisposeAsync() => default;
}

Now your tests exercise the application logic that depends on the client, without any HTTP infrastructure:

[Test]
public async Task PetSummaryService_FormatsNamesCorrectly()
{
    await using FakeTransport transport = new();
    ApiPetsClient client = new(transport);
    PetSummaryService service = new(client);

    string summary = await service.GetSummaryAsync();

    Assert.That(summary, Does.Contain("Luna"));
}

The testing pyramid

For Corvus-generated APIs, the testing pyramid looks like this:

  • Unit tests (fast, many): test your handler logic in isolation with constructed params and workspaces. Cover edge cases and business rules.
  • Integration tests (medium, some): test the full round-trip with WebApplicationFactory. Cover the happy path and key validation scenarios.
  • In-memory transport tests (fast, some): test client-consuming code without HTTP. Cover response handling logic.

You don't need to test the generated serialization or validation because that's covered upstream. Instead, you can focus your testing effort on your code.

FAQs

How do I unit test a generated handler without starting a server? Create the params struct directly from JSON, create a JsonWorkspace, and call your handler method. You don't need an HTTP server, middleware, or transport - just your business logic running against typed inputs and producing typed results.
How do I integration test the full client-to-server round-trip? Use ASP.NET Core's WebApplicationFactory to create an in-memory test server, connect a generated client via HttpClientTransport, and exercise the full pipeline including validation, serialization, and response parsing.
Can I test client code without a running server? Yes. Implement IApiTransport with canned responses (the DemoTransport pattern from the example recipes) and test your client-side logic in isolation. The generated response types work the same regardless of transport.

Matthew Adams

Co-Founder

Matthew Adams

Matthew was CTO of a venture-backed technology start-up in the UK & US for 10 years, and is now the co-founder of endjin, which provides technology strategy, experience and development services to its customers who are seeking to take advantage of Microsoft Azure and the Cloud.