GrpcAssertions.TUnit
TUnit-native gRPC assertions for .NET tests. Fluent entry points over TUnit's Assert.That(...) pipeline for asserting on gRPC call outcomes, with a framework-agnostic core (GrpcAssertions) that a future xUnit, NUnit, or MSTest adapter can reuse. AOT-compatible, trimmable, no runtime reflection in the assertion path.
Scope: Test projects only. Not intended for production code.
Part of the DotNetAssertions family of assertion extensions for TUnit.
Table of contents
- Why this package
- Install
- Package layout
- Namespaces (and a
GlobalUsings.csrecommendation) - Quick start
- Entry points
- Failure diagnostics
- Cookbook: common patterns
- Design notes
- Stability intent (pre-1.0)
- Roadmap
- Family compatibility
- Pair with
- Contributing
- License
Why this package
gRPC failures surface as a single RpcException carrying a Status (a StatusCode plus a detail string). Asserting on that with raw try/catch plus Assert.That(ex.StatusCode).IsEqualTo(...) is verbose and easy to get subtly wrong: forgetting to assert that an exception was thrown at all, or matching the wrong status. Typical hand-rolled code:
var ex = await Assert.That(() => client.GetOrderAsync(request)).Throws<RpcException>();
await Assert.That(ex!.StatusCode).IsEqualTo(StatusCode.Unavailable);
await Assert.That(ex.Status.Detail).Contains("connection refused");
This library collapses that to one chain, and ships the GrpcCallBuilder test-double helper that removes the five-parameter AsyncUnaryCall<T> constructor every hand-rolled gRPC fake repeats.
Install
dotnet add package GrpcAssertions.TUnit
Requirements: TUnit 1.61.38 or later, .NET 10. The framework-agnostic GrpcAssertions core and its single Grpc.Core.Api dependency come transitively. The package is AOT-compatible, trimmable, and uses no runtime reflection in the assertion path.
Package layout
This repo ships two NuGet packages:
| Package | Purpose | Depends on |
|---|---|---|
GrpcAssertions |
Framework-agnostic core: GrpcCallBuilder test-double builders, GrpcOutcomeRendering failure-message formatting, and GrpcExceptions predicates |
Grpc.Core.Api |
GrpcAssertions.TUnit |
TUnit Assert.That(...) entry points: ThrowsGrpcException, the StatusCode shorthands, detail refinements, and DoesNotThrowGrpcException. Most users want this one. |
GrpcAssertions + TUnit.Assertions + TUnit.Core |
You install GrpcAssertions.TUnit; GrpcAssertions and Grpc.Core.Api come transitively. Adapters for other test frameworks (NUnit, xUnit, MSTest) are not shipped today: they would reuse the GrpcAssertions core. Open a feature request if you need one.
Namespaces (and a GlobalUsings.cs recommendation)
The two packages place types in namespaces with deliberately-different scopes:
| Type / member | Namespace | Auto-imported? |
|---|---|---|
ThrowsGrpcException(), DoesNotThrowGrpcException(), IsRpcException() and the IsUnavailable() / WithDetail() chain |
TUnit.Assertions.Extensions |
Yes: TUnit auto-imports |
GrpcExceptionAssertion, GrpcDoesNotThrowAssertion<T> (the assertion classes behind the chain) |
GrpcAssertions.TUnit |
No: rarely referenced directly |
GrpcCallBuilder, GrpcExceptions, GrpcOutcomeRendering (test-double builder, predicates, rendering) |
GrpcAssertions |
No: needed at the call site; recommended for GlobalUsings.cs |
RpcException, StatusCode, Status, Metadata (the gRPC types) |
Grpc.Core |
No: needed at the call site; recommended for GlobalUsings.cs |
Recommended: put the two non-auto-imported namespaces into a single GlobalUsings.cs in your test project so every test file sees them without ceremony:
global using Grpc.Core; // StatusCode, RpcException, Status, Metadata
global using GrpcAssertions; // GrpcCallBuilder, GrpcExceptions, GrpcOutcomeRendering
Quick start
// Assert a call faults with a specific status and detail, in one chain:
await Assert.That(() => client.GetOrderAsync(request, ct))
.ThrowsGrpcException(StatusCode.Unavailable)
.WithDetailContaining("connection refused", StringComparison.Ordinal);
// Status shorthands read fluently:
await Assert.That(() => client.GetServerInfoAsync(request, ct))
.ThrowsGrpcException()
.IsUnimplemented();
// Assert a benign error is swallowed and the call completes:
await Assert.That(() => client.CancelOrderAsync(request, ct))
.DoesNotThrowGrpcException();
// Build AsyncUnaryCall<T> test doubles without the five-parameter constructor:
var ok = GrpcCallBuilder.Success(new OrderReply());
var bad = GrpcCallBuilder.Faulted<OrderReply>(StatusCode.NotFound, "no such order");
Entry points
Delegate assertions, on Assert.That(() => client.Method(...)) (auto-imported from TUnit.Assertions.Extensions):
| Entry point | Behavior |
|---|---|
ThrowsGrpcException() |
Asserts the call throws a gRPC RpcException of any status. Returns a chain. |
ThrowsGrpcException(StatusCode expected) |
Asserts the call throws an RpcException with the given status. Returns a chain. |
DoesNotThrowGrpcException() |
Asserts the call completes without throwing an RpcException. |
Chain off ThrowsGrpcException() to refine:
| Chain method | Behavior |
|---|---|
IsOk(), IsCancelled(), IsInvalidArgument(), IsDeadlineExceeded(), IsNotFound(), IsAlreadyExists(), IsPermissionDenied(), IsResourceExhausted(), IsFailedPrecondition(), IsAborted(), IsUnimplemented(), IsInternal(), IsUnavailable(), IsUnauthenticated() |
Assert the status equals the corresponding StatusCode. |
WithDetail(string) |
Assert Status.Detail exactly equals the string (ordinal). |
WithDetailContaining(string, StringComparison) |
Assert Status.Detail contains the substring using the given comparison. |
WithTrailer(string key, string value) (v0.2.0+) |
Assert the exception's Trailers contain a text entry at key equal to value (ordinal). Keys match case-insensitively (gRPC lowercases keys). |
WithTrailer(string key, ReadOnlySpan<byte> value) (v0.2.0+) |
Assert the exception's Trailers contain a binary (-bin) entry at key whose bytes equal value. A byte[] converts implicitly. |
Exception discriminator, on a caught Exception:
| Entry point | Behavior |
|---|---|
IsRpcException() |
Asserts the exception is a gRPC RpcException. The failure message names the actual exception type. |
Server-streaming assertions (v0.3.0+), on Assert.That(call) where call is an AsyncServerStreamingCall<T>:
| Entry point / chain method | Behavior |
|---|---|
Streams(CancellationToken = default) |
Begins a server-streaming assertion. Reads the response stream once when awaited; returns a chain. A stream that faults mid-read fails with the partial count and the rendered gRPC outcome. |
StreamsAtLeast(int) / StreamsExactly(int) |
Assert the response count is at least / exactly the given value. |
StreamContains(Func<T, bool>) |
Assert at least one streamed response matches the predicate. The predicate's source text is captured for the failure message. |
AndStreamItems(Func<IReadOnlyList<T>, Task>) |
Run follow-on assertions against the materialized responses after the count and content expectations pass, without re-reading the stream. |
Framework-agnostic core (GrpcAssertions namespace), for test doubles and non-TUnit consumers:
| Core API | Behavior |
|---|---|
GrpcCallBuilder.Success<T>(T response) / Success<T>(T, Metadata?, Metadata?) (v0.2.0+) |
Builds a successful AsyncUnaryCall<T> (response, optional response headers and trailers, terminal OK). The trailers accessor returns a stable instance. |
GrpcCallBuilder.Faulted<T>(RpcException) / Faulted<T>(StatusCode, string?) / Faulted<T>(StatusCode, string?, Metadata) (v0.2.0+) |
Builds a faulted AsyncUnaryCall<T> surfacing the exception's status and trailers. |
GrpcCallBuilder.ServerStreaming<T>(IEnumerable<T> responses) (v0.3.0+) |
Builds a successful AsyncServerStreamingCall<T> whose response stream yields the responses in order then ends with a terminal OK. |
GrpcCallBuilder.ServerStreamingFaulted<T>(StatusCode, IEnumerable<T> responses, string? detail = null) (v0.3.0+) |
Builds an AsyncServerStreamingCall<T> that yields the responses then throws an RpcException on the next read. |
GrpcExceptions.IsRpcException(Exception?) |
true when the argument is a gRPC RpcException; false for null or any other type. |
GrpcOutcomeRendering.Describe(RpcException) |
Renders RpcException with StatusCode X, Detail "..." for failure messages. |
Failure diagnostics
Every failed assertion renders the actual gRPC outcome alongside the expectation. A status mismatch:
Expected the gRPC call to throw an RpcException with StatusCode Unavailable
but it threw RpcException with StatusCode Internal, Detail "Unhandled exception in pipeline"
A call that should have thrown but completed:
Expected the gRPC call to throw an RpcException
but no exception was thrown
A DoesNotThrowGrpcException() that faulted:
Expected the gRPC call not to throw an RpcException
but it threw RpcException with StatusCode Unavailable, Detail "connection refused"
Status.Detail is truncated at GrpcOutcomeRendering.MaxDetailLength (200 characters) with a horizontal-ellipsis suffix, so a verbose server detail does not flood the test output.
Cookbook: common patterns
Replacing hand-rolled AsyncUnaryCall<T> factories
The single highest-value use of GrpcCallBuilder is deleting the fake-client constructor boilerplate. A generated gRPC client method returns AsyncUnaryCall<T>, and its public constructor takes five arguments: the response task, a response-headers task, a status accessor, a trailers accessor, and a dispose callback. Every hand-rolled client fake repeats that shape for every method.
Before, a fake that returns a canned response or faults on demand:
public sealed class FakeGreeterClient : Greeter.GreeterClient
{
private readonly bool _fail;
private readonly MyResponse _reply;
public FakeGreeterClient(MyResponse reply, bool fail = false) => (_reply, _fail) = (reply, fail);
public override AsyncUnaryCall<MyResponse> SayHelloAsync(MyRequest request, CallOptions options)
{
if (_fail)
{
var ex = new RpcException(new Status(StatusCode.Unavailable, "server down"));
return new AsyncUnaryCall<MyResponse>(
Task.FromException<MyResponse>(ex),
Task.FromResult(new Metadata()),
() => ex.Status,
() => ex.Trailers,
() => { });
}
return new AsyncUnaryCall<MyResponse>(
Task.FromResult(_reply),
Task.FromResult(new Metadata()),
() => new Status(StatusCode.OK, string.Empty),
() => new Metadata(),
() => { });
}
}
After:
public sealed class FakeGreeterClient : Greeter.GreeterClient
{
private readonly bool _fail;
private readonly MyResponse _reply;
public FakeGreeterClient(MyResponse reply, bool fail = false) => (_reply, _fail) = (reply, fail);
public override AsyncUnaryCall<MyResponse> SayHelloAsync(MyRequest request, CallOptions options)
=> _fail ? GrpcCallBuilder.Faulted<MyResponse>(StatusCode.Unavailable, "server down")
: GrpcCallBuilder.Success(_reply);
}
Success<T>(T) infers T from the response argument, so GrpcCallBuilder.Success(_reply) needs no type argument. Faulted<T>(RpcException) and Faulted<T>(StatusCode, string?) cannot infer T (the response type appears only in the return), so name it explicitly: GrpcCallBuilder.Faulted<MyResponse>(...).
To fault with a pre-built RpcException (for example to attach trailers), use the Faulted<T>(RpcException) overload:
var ex = new RpcException(new Status(StatusCode.NotFound, "no such greeting"));
return GrpcCallBuilder.Faulted<MyResponse>(ex);
When not to use ThrowsGrpcException
ThrowsGrpcException(code) asserts the call threw an RpcException carrying a given status. That is the right contract for "the wrapper translates a failure into this status." It is the wrong contract for a test that asserts the wrapper rethrows the same RpcException instance it received: matching the status code is weaker than asserting reference identity, so migrating such a test to ThrowsGrpcException(code) would silently weaken it.
Identity-propagation tests stay on Throws<RpcException>() plus IsSameReferenceAs:
var thrown = new RpcException(new Status(StatusCode.Internal, "boom"));
var sut = new RetryingGreeterClient(new FakeGreeterClient(reply: null!, throwOnCall: thrown));
var caught = await Assert.That(() => sut.SayHelloAsync(request, ct)).Throws<RpcException>();
await Assert.That(caught!).IsSameReferenceAs(thrown);
The point is that the exact instance propagated unchanged: same status, same trailers, same stack, no re-wrapping. Use ThrowsGrpcException(code) when you care that the status is correct; keep Throws<RpcException>() + IsSameReferenceAs when you care that the instance is preserved.
Await the call against a generated client
The delegate forms in this README assume client is a wrapper whose method returns a Task (or Task<T>), which the assertion awaits. A generated gRPC client is different: its XAsync method returns AsyncUnaryCall<T>, and the failure lives in the call's ResponseAsync, not in constructing the call. A delegate that just returns the call is not awaited, so the fault never surfaces: ThrowsGrpcException reports "no exception was thrown" and DoesNotThrowGrpcException passes for the wrong reason.
// Footgun: the AsyncUnaryCall is returned but never awaited, so a faulted call looks like success.
await Assert.That(() => generatedClient.GetOrderAsync(request)).ThrowsGrpcException();
// Correct: await the call, or assert on its ResponseAsync.
await Assert.That(async () => await generatedClient.GetOrderAsync(request)).ThrowsGrpcException();
await Assert.That(() => generatedClient.GetOrderAsync(request).ResponseAsync).ThrowsGrpcException();
This package is built for testing client wrappers (which return Task), so the wrapper examples above need no change; the note matters only when you assert directly against a raw generated client.
Assert a specific failure, status and detail in one chain:
await Assert.That(() => client.GetOrderAsync(request, ct))
.ThrowsGrpcException(StatusCode.InvalidArgument)
.WithDetailContaining("field 'id' is required", StringComparison.Ordinal);
Assert any gRPC failure without pinning the status (when the status is environment-dependent):
await Assert.That(() => client.GetOrderAsync(request, ct)).ThrowsGrpcException();
Assert a benign-error swallow (the client catches a known RpcException and completes):
await Assert.That(() => client.CancelOrderAsync(request, ct)).DoesNotThrowGrpcException();
Remove the five-parameter constructor from a gRPC client fake:
// before: new AsyncUnaryCall<OrderReply>(Task.FromResult(reply), Task.FromResult(new Metadata()),
// () => new Status(StatusCode.OK, ""), () => new Metadata(), () => { });
// after:
public override AsyncUnaryCall<OrderReply> GetOrderAsync(OrderRequest request, CallOptions options)
=> _fail ? GrpcCallBuilder.Faulted<OrderReply>(StatusCode.Unavailable, "server down")
: GrpcCallBuilder.Success(_reply);
Discriminate a caught exception before inspecting it:
var caught = await Assert.That(() => client.GetOrderAsync(request, ct)).Throws<Exception>();
await Assert.That(caught!).IsRpcException();
Design notes
- Assertions on delegates, not on
RpcExceptioninstances. The primary entry point isAssert.That(() => client.Method(...)). The library executes the call and inspects the thrownRpcException, cleaner than catching the exception in the test. A non-RpcExceptionthrow, or nothing thrown, fails the assertion rather than the test. Grpc.Core.Apionly. The package depends onGrpc.Core.Api(the minimal API surface containingRpcException,StatusCode,Status,Metadata), notGrpc.Net.ClientorGoogle.Protobuf, so it works with any gRPC implementation. The dependency is intrinsic: the public surface is typed against the consumer's realRpcException, so a home-grown enum would not compile against thrown exceptions.- No Protobuf dependency. The library asserts on gRPC transport-level outcomes (status, detail), not on Protobuf message structure. Assert on response message fields with standard TUnit assertions on the deserialized response object.
- Explicit
StringComparisonon detail assertions.WithDetailContainingrequires aStringComparison, matching the convention across the assertion family.WithDetailis exact and ordinal. GrpcCallBuilderis test infrastructure, not an assertion. It buildsAsyncUnaryCall<T>instances for fakes and lives in the framework-agnostic core, so a future non-TUnit adapter reuses it. Both builders guard their arguments withArgumentNullException.ThrowIfNull, unlike hand-rolled fakes that dereference null later.- No runtime reflection in the assertion path; AOT-clean and trimmable.
Stability intent (pre-1.0)
Every release through 1.0 is additive. The public API of both assemblies is pinned by a snapshot test (PublicApiTests) that fails on any change to a public type, member, signature, attribute, or visibility, and EnablePackageValidation strict-mode ApiCompat validates each release against its previous baseline at pack time. New surface is added; existing surface is not reshaped within a 0.x line. The 1.0.0 release locks the SemVer contract.
Roadmap
Scoped to what real consumer suites use; later minor releases add surface as demand appears. All additive.
- 0.4.0: deadline and cancellation assertions (
ThrowsDeadlineExceeded,ThrowsCancelled,CompletesWithin(TimeSpan, TimeProvider)per the cross-familyTimeProviderconvention). - 1.0.0: stable SemVer contract, full snapshot coverage, and an optional
GrpcAssertions.Analyzerspackage.
Family compatibility
The nine assertion-family packages: LogAssertions.TUnit, TimeAssertions.TUnit, SnapshotAssertions.TUnit, MathAssertions.TUnit, JsonAssertions.TUnit, SseAssertions.TUnit, GrpcAssertions.TUnit, TracingAssertions.TUnit, and MetricsAssertions.TUnit: release independently and target the same .NET TFM at any moment (LTS-anchored, multi-target during STS support windows; see the TFM policy in CONVENTIONS.md for the rotation schedule). Mix versions freely. Each package ships under SemVer with EnablePackageValidation strict-mode ApiCompat against its previous baseline, so binary breaks within a version line are caught at pack time.
For per-package release notes:
- LogAssertions.TUnit CHANGELOG
- TimeAssertions.TUnit CHANGELOG
- SnapshotAssertions.TUnit CHANGELOG
- MathAssertions.TUnit CHANGELOG
- JsonAssertions.TUnit CHANGELOG
- SseAssertions.TUnit CHANGELOG
- GrpcAssertions.TUnit CHANGELOG
- TracingAssertions.TUnit CHANGELOG
- MetricsAssertions.TUnit CHANGELOG
Pair with
LogAssertions.TUnit: fluent log assertions overMicrosoft.Extensions.Logging.Testing.FakeLogCollector.TimeAssertions.TUnit:TimeProvider-aware time assertions and cross-cutting.WithinTimeBudget(...)chain methods.SnapshotAssertions.TUnit: text-snapshot assertions for API-surface tests and similar deterministic-string scenarios. Coexists with Verify; covers the 80% case without coverage friction.MathAssertions.TUnit: tolerance-aware fluent assertions over numeric and geometric types (vectors, quaternions, matrices, planes, complex numbers, arrays).JsonAssertions.TUnit: fluent JSON assertions overSystem.Text.Json, HTTP response bodies (including RFC 7807 ProblemDetails), and source-generatedJsonSerializerContextregistration.SseAssertions.TUnit: Server-Sent Events wire-format and stream assertions over HTTP response bodies, streams, and strings.TracingAssertions.TUnit: fluent OpenTelemetry distributed-tracing (Activity/ span) assertions: operation name, tags, status, and parent/child and same-trace relationships, captured via a rawActivityListenerwith no OpenTelemetry SDK dependency.MetricsAssertions.TUnit: fluent assertions overSystem.Diagnostics.Metricsinstruments (counters, histograms, gauges), built onMetricCollector.
Contributing
Issues and pull requests are welcome. See CONTRIBUTING.md for the build, test, and snapshot-acceptance workflow, and CONVENTIONS.md for the family-wide structure and policy. By participating you agree to the Code of Conduct.
License
MIT. Takes a single runtime dependency on Grpc.Core.Api (Apache-2.0).