Skip to main content

Queries and mutations

There are three ways to describe an operation. Pick per test — they all end in the same ExecuteAsync().

StyleBest for
Shape-drivenQuery("root", args).Select(shape)one root field, where the selection is also what you assert
FluentQuery("Name", q => q.Field(...))several root fields, aliases, connections with filters and paging
RawRequest("query { … }")fragments, directives, anything the builders don't cover

Shape-driven operations

GraphQLRequestBuilder Query(string rootField, object? arguments = null, string? operationName = null);
GraphQLRequestBuilder Mutation(string rootField, object? arguments = null, string? operationName = null);
GraphQLRequestBuilder Subscription(string rootField, object? arguments = null, string? operationName = null);

GraphQLRequestBuilder Select<TShape>(TShape selectionShape);
GraphQLRequestBuilder Select<TShape>();

Task<GraphQLResponse> ExecuteAsync(CancellationToken cancellationToken = default);
Task<GraphQLResponse> ExpectAsync<TShape>(TShape expectedShape, CancellationToken cancellationToken = default);

The operation name defaults to the root field in PascalCase (createOrderCreateOrder).

Select, then execute

using var response = await Proto.Context.GraphQL()
.Query("me")
.Select(new { id = Gql.Field, email = Gql.Field, role = Gql.Field })
.ExecuteAsync();

Gql.Field is a placeholder meaning "select this scalar, I don't care about its value".

Select and assert in one step

ExpectAsync(shape) is Select(shape) + ExecuteAsync() + ShouldMatchShape(shape); on a shape mismatch it disposes the response and rethrows:

using var controlPlane = await Proto.Context.GraphQL()
.Query("controlPlane")
.ExpectAsync(new
{
userCount = 1,
workspaceCount = 1,
releaseCount = 0,
monthlyRecurringRevenue = 199m
});

controlPlane.ShouldHaveNoErrors();

Arrays work too — the element shape becomes the selection, and the whole array is asserted:

using var workspaces = await Proto.Context.GraphQL()
.Query("workspaces")
.ExpectAsync(new[]
{
new { name = "analytics", region = "eu-central", plan = "growth" }
});

Select a type

private sealed record ViewerSelection(string Id, string Email, string Role);

using var response = await Proto.Context.GraphQL()
.Query("me")
.Select<ViewerSelection>()
.ExecuteAsync();

var viewer = response.ReadDataAs<ViewerSelection>();

How a shape becomes a selection set

  • Every public property becomes a field, named by [JsonPropertyName] or else camelCase. [JsonIgnore] properties are skipped.
  • Recursion stops at a leaf: Gql.Field, any JsonValue matcher, or a value of a primitive, enum, string, decimal, DateTime, DateTimeOffset, DateOnly, TimeOnly, Guid or Uri type.
  • An array or enumerable is unwrapped to its first element, so new[] { new { id = Gql.Field } } selects { id }.
  • IDictionary<string, …> shapes are supported; the keys are the field names and the values describe their selections.
  • A nested object with no public properties throws ArgumentException (GraphQL selection type '…' has no selectable public properties.) — GraphQL doesn't allow an empty selection. A fluent operation with no fields at all throws InvalidOperationException.
caution

Don't put Gql.Enum(...) inside a selection shape — it isn't treated as a leaf. It belongs in arguments.

Arguments and variables

Anything in the arguments object is inlined as a literal — except Gql.Variable(...), which declares an operation variable and sends the value separately:

.Mutation("createOrder", new
{
input = Gql.Variable("CreateOrderInput!", new CreateOrderRequest("notebook", 2, 12.50m))
})

produces mutation CreateOrder($input: CreateOrderInput!) { createOrder(input: $input) { … } }.

The type string is parsed when you create the variable, so a typo fails immediately. You can also build types with GqlType:

Gql.Variable(GqlType.Named("CreateOrderInput").NonNull(), order)
Gql.Variable(GqlType.Id.NonNull().List(), ids) // [ID!]

GqlType has Id, String, Int, Float, Boolean, Upload and Named(name). GraphQLOperationBuilder.Variable(name, type) takes the same syntax as a string or a GraphQLTypeReference.

For enum literals in arguments, use Gql.Enum("DESC").

A shape contributes the variables it declares (as Gql.Variable or Gql.Upload) to the ones set with .Variables(...): both are merged and the shape wins on a name collision. A shape that contributes no variables leaves .Variables(...) untouched.

Fluent operations

GraphQLRequestBuilder Query(string? name, Action<GraphQLOperationBuilder> configure);
GraphQLRequestBuilder Mutation(string? name, Action<GraphQLOperationBuilder> configure);
GraphQLRequestBuilder Subscription(string? name, Action<GraphQLOperationBuilder> configure);

The second parameter decides the overload: a lambda gives you the fluent builder, an object gives you shape-driven.

using var response = await Proto.Context.GraphQL()
.Query("Dashboard", query => query
.Variable("tenant", "String!")
.Field("me", me => me.Fields("id", "email"))
.Field("workspaces", workspaces => workspaces
.Alias("active")
.Argument("tenant", Gql.Var("tenant"))
.Select(workspace => workspace
.Fields("name", "region")
.Field("owner", owner => owner.Fields("email")))))
.Variables(new { tenant = "acme" })
.ExecuteAsync();
BuilderMembers
operationVariable(name, type), Field(name, configure?), Connection(name, configure)
fieldAlias(alias), Argument(name, value), Fields(params names), Select(configure)
selectionField(name, configure?), Fields(params names)

Gql.Var("tenant") references a declared variable ($tenant); supply its value with .Variables(...).

Connections

Connection understands the common cursor-connection shape — filtering, ordering and paging:

using var response = await Proto.Context.GraphQL()
.Query("FindNotebooks", query => query
.Connection("orders", orders => orders
.Where(filter => filter.Contains("product", "notebook"))
.OrderBy(order => order.Descending("total"))
.First(1)
.Nodes("product", "total", "status")
.PageInfo("hasNextPage", "hasPreviousPage")
.TotalCount()))
.ExecuteAsync();

response.ShouldHaveNoErrors().ShouldMatchShape(new
{
orders = new
{
nodes = new[] { new { product = "notebook-pro", total = 40m, status = "pending" } },
pageInfo = new { hasNextPage = true, hasPreviousPage = false },
totalCount = 2
}
});
Connection memberProduces
First(n), Last(n), After(cursor), Before(cursor)paging arguments
Where(filter => …)a where: argument
OrderBy(order => order.Ascending(f).Descending(g))an order: argument with ASC/DESC enums
Nodes(params fields) / Nodes(field => …)nodes { … }
PageInfo(params fields)pageInfo { … }hasNextPage, hasPreviousPage, startCursor, endCursor when none are given
TotalCount()totalCount

The filter builder emits the { field: { op: value } } convention used by Hot Chocolate: Equal, NotEqual, Contains, StartsWith, EndsWith, GreaterThan, GreaterThanOrEqual, LessThan, LessThanOrEqual, In, plus Nested(field, …), Some(field, …) for lists, and Or(...).

Fluent responses aren't unwrapped

With shape-driven operations, ShouldMatchShape compares against the root field's value. With fluent and raw operations there's no single root, so it compares against the whole data object — which is why the example above wraps its shape in orders = ….

Raw documents

using var response = await Proto.Context.GraphQL()
.Request(
"""
query ViewerCard {
viewer: me { ...ViewerFields }
}

fragment ViewerFields on UserResponse { id email role }
""",
operationName: "ViewerCard")
.ExecuteAsync();

response.ShouldHaveNoErrors().ShouldMatchShape(new
{
viewer = new
{
id = JsonValue.NotNull(),
email = JsonValue.StringContaining("@example.test"),
role = "member"
}
});

The document is parsed when you call Request; if the named operation isn't in it, the call throws ArgumentException.

Headers and variables

GraphQLRequestBuilder Header(string name, string value);
GraphQLRequestBuilder Variables(object variables);
GraphQLRequestBuilder ConnectionPayload(object payload);

Header values are never traced; the trace records the count and each header's name. ConnectionPayload sets the optional connection_init payload subscriptions send — see Subscriptions.

File uploads

ProtoTest implements the GraphQL multipart request spec. Put Gql.Upload(...) in shape-driven arguments or anywhere inside .Variables(...), and it's declared as Upload! automatically:

var expected = new
{
fileName = "example.txt",
contentType = "text/plain",
length = JsonValue.GreaterThan(0)
};

using var uploaded = await Proto.Context.GraphQL()
.Mutation("uploadDocument", new
{
file = Gql.Upload("ProtoTest GraphQL"u8.ToArray(), "example.txt", "text/plain")
})
.Select(expected)
.ExecuteAsync();

uploaded.ShouldHaveNoErrors().ShouldMatchShape(expected);
static GraphQLUpload Upload(ReadOnlyMemory<byte> content, string fileName, string contentType = "application/octet-stream");
static GraphQLUpload Upload(Func<Stream> openRead, string fileName, string contentType = "application/octet-stream");

The request is sent as multipart/form-data with operations, map and numbered file parts, plus the GraphQL-preflight: 1 header that CSRF-protected servers expect. The Func<Stream> overload opens a fresh stream per send.

Fluent uploads are not normalized

Gql.Upload is only intercepted in shape-driven arguments and in .Variables(...). A fluent .Argument("file", Gql.Upload(...)) renders as a literal and is not routed through the multipart normalizer.

Transport details

Queries and mutations are sent as POST with Accept: application/graphql-response+json, application/json;q=0.9. The endpoint must be an absolute HTTP(S) URI; the resolve step is traced as graphql.endpoint.resolve with the server address. Calling ExecuteAsync() on a subscription throws — use SubscribeAsync().