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.