Skip to content

Contract Tests

The generator reads the contracts your app declares and emits xUnit tests that hold them to it. This page states exactly what each family asserts — and, just as importantly, what it does not.

Tests are grouped per boundary and land in Pragmatic.Tests.Generated.

When you get them. For every [Endpoint] that requires a permission.

What is generated.

TestAssertion
{Action}_WithoutRequiredPermission_IsRejecteda caller holding no permission does not get through (4xx)
{Action}_WithRequiredPermission_IsReachablea caller holding exactly the required permission is not blocked by authorization, and is answered differently from a caller without it
{Action}_WithUnknownId_IsNotFoundfor a GET-by-id, an unknown id yields 404

The unprivileged caller is built with AsUser(request, "contract-noperm"), which writes an empty permission header. That is deliberate: HttpClient copies its DefaultRequestHeaders onto any request that does not already carry them, so merely omitting the header would let the fixture’s grant — often * — authorize the call, and the test would pass while proving nothing.

Why the assertions are wider than “403” and “2xx”. Pragmatic’s pipeline can reject a request before the authorization filter runs: body binding answers 400, an entity lookup answers 404. Requiring a literal 403 would produce false failures on endpoints that legitimately reject earlier. Conversely an authorized call may still end in 404 because the id is random, so the positive contract asserts only that authorization did not block it.

The consequence is worth stating plainly: a rejection test passes on any 4xx, so it confirms the caller did not succeed rather than the authorization filter ran. For endpoints where the distinction matters, add a hand-written test asserting the exact status.

⚠️ Which is why the reachable test sends both callers. “Not forbidden” is satisfied by “never got there”: when something refuses every request before it reaches a route — a tenant middleware answering 400, a header the host requires and the test does not send — the unprivileged call is a 400, which counts as rejected, and the privileged call is the same 400, which counts as not forbidden. Both halves pass and nothing has been measured. It happened: fifty-four generated tests reported success while every request was being refused.

So the reachable test issues the unprivileged request too and asserts, with ShouldBeAuthorizedUnlike, that the two were not answered the same way. The legitimate outcomes all differ — 403 against 404 for a read of a random id, 403 against 400 for a create the shape cannot carry, 403 against 2xx for one it can. Two identical statuses mean the permission changed nothing observable.

List endpoints are skipped. A GET without a route parameter data-scopes rather than rejecting — it returns 200 with filtered results — so asserting a 4xx there would be wrong. Visibility for those is covered by scope tests, not by authorization contracts.

When you get them. For every create endpoint whose request body the generator can synthesize.

TestAssertion
create with a synthesized body2xx
create with an empty body400 — validation rejects it
cross-tenant readan entity created under one tenant is 404 for another

Bodies come from TestDataSynthesizer: strings are unique ("test-" + Guid), numbers in range, an enum takes its first member, ids and timestamps come from the clock. Foreign keys and nested complex types are not synthesizable — when one appears, the success test is skipped and the validation test is kept, rather than asserting 2xx against a body that cannot be valid.

Entities are ordered by FkTopologicalSorter so parents are created before children; circular foreign keys are detected and do not hang the generation.

What the generator cannot know, and where you tell it

Section titled “What the generator cannot know, and where you tell it”

⚠️ The generator fills a request from the operation’s shape and invents an identity carrying the required permission. Neither is always enough, and when it is not, the generated test fails on its own assumption rather than on a defect in the application:

  • 400 — a create whose validity needs more than the shape: a value that must already exist, a pair that must agree, a name that must be unique.
  • 403 — a permission is not always the whole authority. A tenancy operation needs a caller with a tenant. A host that calls UseAuthorization(authz => …) installs a resolver that derives permissions from roles and stops honouring raw permission claims, so a caller carrying the exact permission is still refused.

Both are host wiring rather than module metadata, so the generator cannot see either. It hands over instead, at the seam your fixture already writes to:

public sealed class ContractAppFixture : IAsyncLifetime
{
public async Task InitializeAsync()
{
PragmaticContractHost.Client = _app.CreateClient();
// A body for the creates the shape cannot carry. null for everything else.
PragmaticContractHost.BodyFor = operation => operation switch
{
"CreateWorkspace" => new { Name = $"ws-{Guid.NewGuid():N}", OwnerId = _seededOwnerId },
_ => null
};
// The last word on every contract request, after the generated identity is written.
PragmaticContractHost.PrepareRequest = (operation, request) =>
{
request.Headers.TryAddWithoutValidation("X-Tenant-Id", "tenant-a");
request.Headers.TryAddWithoutValidation("X-User-Roles", "workspace-admin");
};
}
}

The operation name is the generated test’s own — CreateWorkspace, GrantRolePermissionMutation. Returning null from BodyFor keeps the synthesised body, which is what most creates want.

Neither hook reduces what is generated. Every contract the generator emitted before it still gets emitted; the hooks change what the test sends, not whether the test exists. A create the shape cannot carry is still skipped for the success assertion, exactly as before — that decision is CanSynthesizeBody and this section does not touch it.

When you get them. For endpoints marked [TransitionsTo], correlated with the create endpoint of the same entity.

TestAssertion
legal transition from the initial state2xx
illegal transition from the initial state409 Conflict

Whichever case does not apply to a given transition is emitted as a skipped placeholder, so the shape of the state machine stays visible in the test list.

They only appear when the entity can be created. A transition test has to produce the entity in its initial state first, so it is generated only when a correlatable create exists for the same entity. Two conditions have to hold, and both are easy to miss:

  1. The create must be a Mutation<TEntity>. A DomainAction is deliberately excluded from CRUD contracts — it is a command, with business preconditions a synthesized body has no way to satisfy — so it cannot serve as the “arrange” step of a transition test either.
  2. Its body must be fully synthesizable. A required field the synthesizer cannot fill (a nested complex type, a strongly-typed id) makes the whole body unsynthesizable. Foreign keys count here: a Guid property whose name ends in Id is treated as an FK and deliberately not invented, because Guid.NewGuid() would reference a row that does not exist.

If you annotated an endpoint with [TransitionsTo<TState>] and see nothing generated, that correlation is the first thing to check — the generator emits a skipped placeholder naming the reason.

In practice this is a real limit, not a corner case: an entity with a state machine is usually created by a command rather than a plain CRUD mutation, which is exactly the case the generator excludes. Cover those transitions by hand with the typed client.

_Api.{Boundary}.g.cs is not a test — it is the client the hand-written tests use. See Typed test client.

They are a floor, not a ceiling. They assert the contract the metadata describes; they say nothing about business rules, computed results, or side effects. Specifically out of scope:

  • that a rejection is a 403 rather than some other 4xx (see above);
  • response payloads beyond their status code;
  • anything involving more than one request, other than the CRUD round-trip and transition flows;
  • permissions that are enforced somewhere other than the endpoint.

Write those by hand with the typed client.