Your first GraphQL suite
This page goes from a fresh test project to a passing query, a mutation whose result is asserted, and a subscription waiting for an event. It uses NUnit and an in-process server. A deployed endpoint needs one configuration change. Other runners differ only in the setup class (Test runners).
1. Add the packages
dotnet new nunit -n Api.Tests
cd Api.Tests
dotnet add reference ../Api/Api.csproj
dotnet add package ProtoTest.NUnit
dotnet add package ProtoTest.GraphQL
dotnet add package ProtoTest.AspNetCore
ProtoTest.NUnit needs NUnit 4.6.1 or newer; the standard dotnet new nunit template pins an older version, so update NUnit first.
2. Configure the host
One [SetUpFixture] per test project builds the host, with GraphQL registered under the application so its client shares the application's address:
using ProtoTest.AspNetCore;
using ProtoTest.Core;
using ProtoTest.GraphQL;
using ProtoTest.NUnit;
namespace Api.Tests;
[SetUpFixture]
public sealed class Setup : ProtoTestAssembly
{
protected override void Configure(IProtoHostBuilder builder) =>
builder.AddApplication("Api", app => app
.AddAspNetCoreServer<Program>()
.AddGraphQL(graphQL => graphQL.AddClient("GraphQL", endpoint: "GraphQL")));
}
endpoint: "GraphQL" appends ProtoTest:Applications:Api:Endpoints:GraphQL to the application's address; set the key when the path differs. The in-process server steps aside when ProtoTest:Applications:Api:BaseUrl is configured, so the same suite runs against a deployed environment.
3. Query with a shape
using NUnit.Framework;
using ProtoTest.Core;
using ProtoTest.GraphQL;
using ProtoTest.Json;
namespace Api.Tests;
[Application("Api")]
public sealed class ViewerTests
{
[ProtoTest]
public async Task CountsWorkspaces()
{
using var response = await Proto.Context.GraphQL()
.Query("controlPlane")
.ExpectAsync(new { workspaceCount = JsonValue.GreaterThan(0) });
response.Should.HaveNoErrors();
}
}
ExpectAsync builds the selection set from the shape and asserts the same shape against the result; JsonValue adds constraints beyond equality (greater than, not null, one of). Run it with dotnet test. Queries and mutations covers variables, the fluent builder and raw documents.
dotnet test passes after this step. The trace lands at TestResults/prototest-{runId}.prototrace with a graphql.operation entry for the query. Steps 4 and 5 build on this host without changing it.
4. Mutate and read the result
using var created = await Proto.Context.GraphQL()
.Mutation("createOrder", new
{
input = Gql.Variable("CreateOrderInput!", new CreateOrderRequest("notebook", 2, 25m))
})
.Select(new { id = Gql.Field, status = Gql.Field })
.ExecuteAsync();
created.Should.HaveNoErrors();
created.Should.MatchShape(new { id = JsonValue.GreaterThan(0), status = "pending" });
Select names the fields the assertion needs, so the response stays small; ReadRequired<T>() deserializes the same response into a record when the test wants a typed value. See Responses.
5. Subscribe to an event
await using var subscription = await Proto.Context.GraphQL()
.Subscription("orderCreated")
.Select(new { id = Gql.Field, status = Gql.Field })
.SubscribeAsync();
// The server acknowledges the connection, not the subscription, so trigger until the event lands.
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var triggers = TriggerAsync(timeout.Token);
using var notification = await subscription.ExpectNextAsync(
new { id = JsonValue.GreaterThan(0), status = "pending" },
timeout.Token);
timeout.Cancel();
await triggers;
notification.Should.HaveNoErrors();
async Task TriggerAsync(CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
using var created = await Proto.Context.GraphQL()
.Mutation("createOrder", new
{
input = Gql.Variable("CreateOrderInput!", new CreateOrderRequest("live-notebook", 1, 15m))
})
.Select(new { id = Gql.Field })
.ExecuteAsync();
created.Should.HaveNoErrors();
}
}
Subscriptions has the full surface, including SSE, connection payloads and custom sockets. Pass a cancellation token. Without one, a subscription with no events waits indefinitely.
dotnet test passes with all three tests. The trace holds graphql.operation entries for the query and the mutation plus graphql.subscription.start|next|complete events for the subscription. If the subscription test flakes, check the two items in the list below: a token on every wait, and triggers that run until the event lands.
Where the run is recorded
Every call is a ProtoTest trace entry: graphql.operation for queries and mutations, graphql.subscription.start|next|complete for subscriptions, and graphql.response/graphql.failure observations for coverage. The tables are on the GraphQL overview and ProtoTrace. The trace lands at TestResults/prototest-{runId}.prototrace unless ConfigureTracing set OutputPath; open it on trace.prototest.dev.
Where to next
- Queries and mutations - shape-driven, fluent and raw documents, variables and uploads.
- Responses - errors, data and assertions.
- Subscriptions - WebSocket or SSE, connection payloads, custom sockets.
- Schema coverage - which types, fields and arguments the suite exercised.