grpc-streaming-test-author
The single gRPC-streaming test home: builds streaming-RPC test suites from a proto definition. Classifies each RPC by pattern (unary, server-streaming, client-streaming, bidi), then emits the required categories per pattern - ordering preservation, completion semantics (server close after stream end, client half-close), cancellation, deadline handling, partial-stream failure. Produces skeletons for Go (bufconn + Send/Recv), Python (iterators), JVM (StreamObserver), Node (call.write/end); carries the 17-code gRPC status catalog (retry semantics per AIP-194, grpc-gateway HTTP mapping) in references/status-codes.md and the wire-level / live-server streaming patterns (deadline propagation, server-side cancellation, metadata, ghz load) in references/wire-level-testing.md. Use when adding tests for a new or existing streaming RPC, auditing a suite for uncovered categories, or asserting status-code behavior. Different test surface from grpc-mock (the in-process harness itself).
Install with skills.sh (any agent)
npx skills add testland/qa --skill grpc-streaming-test-authorgrpc-streaming-test-author
Overview
Streaming RPCs are where gRPC clients and servers most often diverge from the proto contract - message ordering, completion signalling, cancellation, and partial-failure semantics are all testable surfaces. gRPC defines four streaming patterns - unary, server-streaming, client-streaming, bidirectional - per grpc.io/docs/what-is-grpc/core-concepts/ (opens in new window); Step 1 classifies each RPC by pattern.
This skill walks through producing a comprehensive test suite from the proto file. It composes grpc-mock for the test harness and references/status-codes.md for status-code assertions; live-server (non-bufconn) patterns are in references/wire-level-testing.md.
When to use
Step 1 - Classify each RPC
Walk the .proto file and tabulate:
service Chat {
rpc Send(Message) returns (Ack); // unary
rpc Subscribe(SubReq) returns (stream Event); // server streaming
rpc Upload(stream Chunk) returns (UploadResult); // client streaming
rpc Conversation(stream Message) returns (stream Reply); // bidi
}| RPC | Pattern | Required test categories |
|---|---|---|
Send | unary | success, every status code per references/status-codes.md |
Subscribe | server-stream | success, ordering, server-side close after N messages, server-side mid-stream error, client-side cancel mid-stream, deadline-exceeded mid-stream |
Upload | client-stream | success, server completes before client finishes, server-side error mid-upload, client-side cancel before send, empty stream |
Conversation | bidi | success, ordering per direction, client closes send while still receiving, server closes send while still receiving, both close, deadline mid-conversation, error mid-conversation |
Step 2 - Test categories per pattern
Unary (covered for completeness)
def test_send_returns_ok(stub):
resp = stub.Send(Message(body="hi"))
assert resp.ok
def test_send_returns_invalid_argument(stub):
with pytest.raises(grpc.RpcError) as exc:
stub.Send(Message(body=""))
assert exc.value.code() == grpc.StatusCode.INVALID_ARGUMENTServer-streaming
Ordering preservation - per gRPC docs: "gRPC guarantees message ordering within an individual RPC call."
def test_subscribe_preserves_ordering(stub_with_fake):
stub_with_fake.fake_response_stream = [
Event(seq=1), Event(seq=2), Event(seq=3),
]
received = list(stub_with_fake.Subscribe(SubReq()))
assert [e.seq for e in received] == [1, 2, 3]Server closes after N messages - completion is the finalisation signal:
def test_subscribe_completes_after_finite_stream(stub_with_fake):
stub_with_fake.fake_response_stream = [Event(seq=1), Event(seq=2)]
events = list(stub_with_fake.Subscribe(SubReq()))
# If list(...) terminates, the server-side close was signalled.
assert len(events) == 2Server-side error mid-stream:
def test_subscribe_propagates_error_mid_stream(stub_with_fake):
stub_with_fake.fake_response_stream = [Event(seq=1)]
stub_with_fake.fake_post_yield_status = grpc.StatusCode.INTERNAL
events_iter = stub_with_fake.Subscribe(SubReq())
# Receive first event OK
next(events_iter)
# Subsequent receive fails with INTERNAL per references/status-codes.md
with pytest.raises(grpc.RpcError) as exc:
next(events_iter)
assert exc.value.code() == grpc.StatusCode.INTERNALClient cancel mid-stream:
def test_client_cancel_mid_stream(stub_with_fake):
stub_with_fake.fake_response_stream = [Event(seq=i) for i in range(100)]
events_iter = stub_with_fake.Subscribe(SubReq())
next(events_iter) # receive one
events_iter.cancel()
with pytest.raises(grpc.RpcError) as exc:
next(events_iter)
assert exc.value.code() == grpc.StatusCode.CANCELLEDDeadline-exceeded mid-stream:
def test_subscribe_deadline_exceeded(stub_with_slow_fake):
events_iter = stub_with_slow_fake.Subscribe(SubReq(), timeout=0.1)
with pytest.raises(grpc.RpcError) as exc:
for _ in events_iter:
pass
assert exc.value.code() == grpc.StatusCode.DEADLINE_EXCEEDEDClient-streaming
Categories: success, server completes before client finishes (further Send yields io.EOF), server-side error mid-upload, client-side cancel before send, empty stream. Go bufconn skeletons: references/test-skeletons.md.
Bidirectional
The two streams operate independently, so test each direction plus a concurrent send/recv case. Categories: ordering per direction, client closes send while still receiving (CloseSend), server closes send while still receiving, both close, deadline mid-conversation, error mid-conversation. Go skeletons: references/test-skeletons.md.
Step 3 - Coverage matrix
For each streaming RPC, generate the matrix:
Send Subscribe Upload Conversation
---- --------- ------ ------------
success X X X X
ordering preserved - X X X (both)
N msgs then close - X X X
mid-stream server error - X X X
deadline exceeded X X X X
client cancel X X X X
server cancel - X X X
empty stream - X X X
client half-close - - - X
server half-close - - - XEmpty cells in covered patterns = coverage gap. PR must justify or add a test.
Step 4 - Pick the test harness
Per grpc-mock:
| Language | Harness |
|---|---|
| Go | bufconn in-process server + t.Cleanup |
| Python | pytest fixture with [::]:0 port + iterator-based stream API |
| JVM | InProcessServerBuilder + StreamObserver |
| Node | @grpc/grpc-js server with bindAsync("127.0.0.1:0") + call.write / call.end |
Don't use mockgen / interface-mocks for streaming - they skip the marshalling + ordering guarantees that the streaming contract depends on.
Step 5 - Emit the test directory
tests/grpc-streaming/
__init__.py
conftest.py # shared fixtures (in-process server)
test_subscribe.py # server-streaming
test_upload.py # client-streaming
test_conversation.py # bidi
test_send.py # unary (sibling)
README.md # the coverage matrixThe README.md should document the matrix from Step 3 so reviewers and new contributors can see at a glance what is and isn't covered.
Streaming evolution - version-safety reminder
Per the versioning-strategy reference in buf-cli-lint-breaking-build, changing an RPC's streaming pattern is always breaking:
service Chat {
- rpc Subscribe(SubReq) returns (Event);
+ rpc Subscribe(SubReq) returns (stream Event);
}The wire format differs, so old clients won't parse the new response. Verify breaking-change detection via buf-cli-lint-breaking-build catches this with FILE or PACKAGE category.
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Single happy-path test per streaming RPC | Misses ordering / cancellation / deadline categories | Use Step 2's per-pattern matrix |
| Test asserts on the last message only | Misses ordering bugs in earlier messages | Collect entire stream, assert on the full sequence |
time.sleep to wait for server messages | Race-prone; flakes in CI | Use channel close / iterator exhaustion as completion signal |
| Cancellation test sleeps then cancels | Race: server may finish before cancel | Inject a controllable blocker in the fake server |
| No deadline-exceeded test | Production deadlines surface in mid-stream | Always include - per references/status-codes.md, DEADLINE_EXCEEDED is its own code |
| Mock at interface level for streaming | Skips marshalling + ordering | In-process server only for streaming |
| Bidi test where client and server are deterministic-interleaved | Misses race conditions inherent in "operate independently" | One test per direction + one test with concurrent send/recv |
Don't CloseSend() in client-streaming tests | Server waits forever | Always close the send side explicitly |
Limitations
References
gRPC status codes, metadata, and load testing
View source (opens in new window)gRPC status codes, metadata, and load testing
Cross-cutting call semantics beyond the four streaming patterns: the full status-code matrix, request/response metadata round-trip, and load testing with ghz.
Status codes
| Code | When |
|---|---|
| OK | Success |
| CANCELLED | Client cancelled |
| DEADLINE_EXCEEDED | Deadline elapsed |
| INVALID_ARGUMENT | Client error in request |
| UNAUTHENTICATED | No / bad credentials |
| PERMISSION_DENIED | Authenticated but not authorized |
| RESOURCE_EXHAUSTED | Quota / rate limit |
| INTERNAL | Server bug |
| UNAVAILABLE | Server transient unreachable (clients should retry) |
Test the error path returns the right code, not just "an error":
def test_invalid_argument_returns_correct_code():
with grpc.insecure_channel("localhost:50051") as ch:
stub = OrdersStub(ch)
with pytest.raises(grpc.RpcError) as exc:
stub.CreateOrder(OrderRequest(item_count=-1))
assert exc.value.code() == grpc.StatusCode.INVALID_ARGUMENTMetadata
Per the gRPC core concepts docs (opens in new window), metadata is "key-value pairs" case-insensitive ASCII keys; binary values use -bin suffix.
def test_request_metadata_round_trip():
with grpc.insecure_channel("localhost:50051") as ch:
stub = OrdersStub(ch)
metadata = (("x-trace-id", "abc123"),)
resp, call = stub.CreateOrder.with_call(OrderRequest(), metadata=metadata)
# Server reflects request-id in response trailing metadata
trailing = call.trailing_metadata()
assert ("x-trace-id-echo", "abc123") in trailingLoad test with ghz
ghz \
--insecure \
--proto orders.proto \
--call orders.Orders/CreateOrder \
-d '{"item_count":1}' \
-c 50 \
-n 10000 \
localhost:50051Reports RPS, p50/p95/p99 latency. For streaming RPCs use --stream-call-count flag (consult ghz docs).
gRPC status codes: semantics, retries, HTTP mapping
View source (opens in new window)gRPC status codes: semantics, retries, HTTP mapping
gRPC defines 17 standard status codes (per grpc.io/docs/guides/status-codes/ (opens in new window)) that every implementation respects. They are the wire-level error vocabulary; using the wrong code breaks retry behaviour, HTTP gateway translation, and observability dashboards.
Three things to get right:
This reference serves client-test authors, server implementers, and gRPC-to-HTTP gateway configurators.
When to use
The canonical 17 codes
Per grpc.io/docs/guides/status-codes/ (opens in new window):
| # | Code | Definition (gRPC docs) | Typical example |
|---|---|---|---|
| 0 | OK | "Not an error; returned on success." | Successful RPC |
| 1 | CANCELLED | "The operation was cancelled, typically by the caller." | Client terminates stream |
| 2 | UNKNOWN | "Unknown error... errors raised by APIs that do not return enough error information." | Wrapping a non-gRPC error |
| 3 | INVALID_ARGUMENT | "The client specified an invalid argument... problematic regardless of the state of the system." | Malformed request payload |
| 4 | DEADLINE_EXCEEDED | "The deadline expired before the operation could complete... even if the operation has completed successfully." | Server slow + deadline expired |
| 5 | NOT_FOUND | "Some requested entity (e.g., file or directory) was not found." | Resource doesn't exist |
| 6 | ALREADY_EXISTS | "The entity that a client attempted to create already exists." | Duplicate unique key |
| 7 | PERMISSION_DENIED | "The caller does not have permission to execute the specified operation." | Authenticated but unauthorised |
| 8 | RESOURCE_EXHAUSTED | "Some resource has been exhausted, perhaps a per-user quota." | Rate limit hit |
| 9 | FAILED_PRECONDITION | "System is not in a state required for the operation's execution... client should not retry until the system state has been explicitly fixed." | Delete non-empty bucket |
| 10 | ABORTED | "The operation was aborted, typically due to a concurrency issue such as a sequencer check failure or transaction abort." | Optimistic-locking conflict |
| 11 | OUT_OF_RANGE | "The operation was attempted past the valid range... problem that may be fixed if the system state changes." | Seek past EOF |
| 12 | UNIMPLEMENTED | "The operation is not implemented or is not supported/enabled in this service." | Method not in this version |
| 13 | INTERNAL | "Internal errors... invariants expected by the underlying system have been broken." | Database corruption |
| 14 | UNAVAILABLE | "The service is currently unavailable... most likely a transient condition, which can be corrected by retrying with a backoff." | Backend restarting |
| 15 | DATA_LOSS | "Unrecoverable data loss or corruption." | Storage volume failed |
| 16 | UNAUTHENTICATED | "The request does not have valid authentication credentials for the operation." | Missing JWT |
Retry behaviour
Per AIP-194 (opens in new window):
| Code | Retry? | Notes |
|---|---|---|
OK | n/a | Success |
CANCELLED | No | Client requested cancellation; honour it |
UNKNOWN | No | Unsafe; retrying may compound state |
INVALID_ARGUMENT | No | Argument won't change |
DEADLINE_EXCEEDED | No | Application deadline must be respected |
NOT_FOUND | No | Requires state change |
ALREADY_EXISTS | No | Requires state change |
PERMISSION_DENIED | No | Requires permission change |
RESOURCE_EXHAUSTED | Maybe | Quota may take hours; consider billing |
FAILED_PRECONDITION | No | "Client should not retry until the system state has been explicitly fixed" (gRPC docs) |
ABORTED | Application-level | "Retry at the transaction level, not individual request level" |
OUT_OF_RANGE | No | Requires state change |
UNIMPLEMENTED | No | Method doesn't exist |
INTERNAL | No | Surface bugs immediately |
UNAVAILABLE | Yes | "The only error code explicitly recommended for automatic retry" per AIP-194 |
DATA_LOSS | No | Unrecoverable; surface immediately |
UNAUTHENTICATED | No | Re-auth first, then retry application-level |
Client-side retry policy
{
"methodConfig": [{
"name": [{"service": "example.v1.UserService"}],
"retryPolicy": {
"maxAttempts": 4,
"initialBackoff": "0.1s",
"maxBackoff": "1s",
"backoffMultiplier": 2,
"retryableStatusCodes": ["UNAVAILABLE"]
}
}]
}Adding RESOURCE_EXHAUSTED to retryableStatusCodes is sometimes seen but per AIP-194 has billing implications.
gRPC ↔ HTTP mapping (grpc-gateway)
Per grpc-gateway/runtime/errors.go (opens in new window):
| gRPC code | HTTP status | Notes |
|---|---|---|
OK | 200 | Standard success |
CANCELLED | 499 | Client Closed Request (nginx-originated, non-standard) |
UNKNOWN | 500 | Internal Server Error |
INVALID_ARGUMENT | 400 | Bad Request |
DEADLINE_EXCEEDED | 504 | Gateway Timeout |
NOT_FOUND | 404 | Not Found |
ALREADY_EXISTS | 409 | Conflict |
PERMISSION_DENIED | 403 | Forbidden |
UNAUTHENTICATED | 401 | Unauthorized |
RESOURCE_EXHAUSTED | 429 | Too Many Requests |
FAILED_PRECONDITION | 400 | Bad Request - NOT 412 "Precondition Failed" despite the name. grpc-gateway code comment: "deliberately doesn't translate to the similarly named '412 Precondition Failed'" |
ABORTED | 409 | Conflict (concurrency) |
OUT_OF_RANGE | 400 | Bad Request |
UNIMPLEMENTED | 501 | Not Implemented |
INTERNAL | 500 | Internal Server Error |
UNAVAILABLE | 503 | Service Unavailable |
DATA_LOSS | 500 | Internal Server Error |
Implications for HTTP clients
Choosing the right code - disambiguators
The hardest pairs:
INVALID_ARGUMENT vs FAILED_PRECONDITION vs OUT_OF_RANGE
| Question | Answer |
|---|---|
| Is the arg malformed regardless of system state? | INVALID_ARGUMENT |
| Is the arg valid but the system isn't in the right state? | FAILED_PRECONDITION |
| Is the arg past a valid range that may shift over time? | OUT_OF_RANGE |
Per gRPC docs: FAILED_PRECONDITION "client should not retry"; OUT_OF_RANGE "may be fixed if the system state changes."
FAILED_PRECONDITION vs ABORTED vs UNAVAILABLE
| Question | Answer |
|---|---|
| Concurrency conflict (transaction abort, sequencer check)? | ABORTED |
| System needs state change before retry? | FAILED_PRECONDITION |
| System transient unavailability? | UNAVAILABLE |
Per AIP-194: ABORTED retries at the transaction level, not the request level. UNAVAILABLE retries at the request level.
NOT_FOUND vs UNIMPLEMENTED
| Question | Answer |
|---|---|
| Resource doesn't exist at this point in time? | NOT_FOUND |
| Method doesn't exist in this server version? | UNIMPLEMENTED |
UNKNOWN vs INTERNAL
| Question | Answer |
|---|---|
| Server caught a non-gRPC error and is wrapping it? | UNKNOWN |
| Server detected its own invariant violation? | INTERNAL |
UNKNOWN is for upstream noise; INTERNAL is for "I detected something wrong with me."
Tests should assert these codes
In client tests (per grpcurl-cli and language-specific clients):
import grpc
import pytest
def test_get_user_not_found_returns_not_found(stub):
with pytest.raises(grpc.RpcError) as exc_info:
stub.GetUser(GetUserRequest(id="nonexistent"))
assert exc_info.value.code() == grpc.StatusCode.NOT_FOUND
def test_create_user_with_duplicate_email_returns_already_exists(stub, existing_user):
with pytest.raises(grpc.RpcError) as exc_info:
stub.CreateUser(CreateUserRequest(email=existing_user.email))
assert exc_info.value.code() == grpc.StatusCode.ALREADY_EXISTS
def test_unauthenticated_call_returns_unauthenticated(stub_no_auth):
with pytest.raises(grpc.RpcError) as exc_info:
stub_no_auth.GetUser(GetUserRequest(id="any"))
assert exc_info.value.code() == grpc.StatusCode.UNAUTHENTICATEDAnti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
Returning INTERNAL for caller-side errors | Surfaces server bugs that don't exist; alarms fire | Use INVALID_ARGUMENT for bad inputs |
Returning UNKNOWN everywhere | No retry semantics, no HTTP mapping clarity | Pick the specific code |
Returning FAILED_PRECONDITION for transient unavailability | Clients don't retry → user-visible failures | Use UNAVAILABLE |
Using NOT_FOUND for "you don't have permission" | Information leak (tenant probes) - OR - opacity (debugging hard); document the choice | Per cross-tenant-data-leak-tests (in the qa-multi-tenancy plugin) the 404-vs-403 trade-off is project policy |
OK with error message in payload | Breaks every gRPC client's error handling | Always use a non-OK status for errors |
| Adding all codes to retryableStatusCodes | Retries non-idempotent operations; data corruption | Only UNAVAILABLE per AIP-194 (case-by-case for others) |
Treating HTTP 412 as FAILED_PRECONDITION in gateway | grpc-gateway maps FP→400 (not 412) | Verify the mapping in runtime/errors.go |
| Asserting on error message string in tests | Breaks on i18n / wording tweaks | Assert on code() only |
Custom error details
For richer error info beyond the code, embed google.rpc.ErrorInfo / BadRequest / Help in status.Details:
from google.rpc import error_details_pb2, status_pb2
from grpc_status import rpc_status
def get_user_with_details(stub, user_id):
try:
return stub.GetUser(GetUserRequest(id=user_id))
except grpc.RpcError as e:
status = rpc_status.from_call(e)
for detail in status.details:
if detail.Is(error_details_pb2.BadRequest.DESCRIPTOR):
bad_req = error_details_pb2.BadRequest()
detail.Unpack(bad_req)
for v in bad_req.field_violations:
print(f"{v.field}: {v.description}")Tests should assert on detail messages where richer signal exists.
Limitations
References
Per-language streaming test skeletons
View source (opens in new window)Per-language streaming test skeletons
Go bufconn skeletons for the client-streaming and bidirectional categories. The body of SKILL.md keeps the Python server-streaming and unary examples as the representative inline set; these are the language variants. Harness selection is in grpc-mock.
Client-streaming (Go bufconn)
Success:
func TestUpload_Success(t *testing.T) {
fake := &fakeUploader{accept: 3}
client := setupClient(t, fake)
stream, err := client.Upload(context.Background())
if err != nil { t.Fatal(err) }
chunks := []*pb.Chunk{{Data: []byte("a")}, {Data: []byte("b")}, {Data: []byte("c")}}
for _, c := range chunks {
if err := stream.Send(c); err != nil { t.Fatal(err) }
}
result, err := stream.CloseAndRecv()
if err != nil { t.Fatal(err) }
if result.Bytes != 3 { t.Fatalf("got %d, want 3", result.Bytes) }
}Server completes before client finishes - per gRPC docs the server response may arrive "typically but not necessarily after it has received all the client's messages"; further Send yields io.EOF:
func TestUpload_ServerCompletesEarly(t *testing.T) {
fake := &fakeUploader{completeAfter: 1}
client := setupClient(t, fake)
stream, _ := client.Upload(context.Background())
stream.Send(&pb.Chunk{Data: []byte("a")})
// Sending more after server completes should yield io.EOF
err := stream.Send(&pb.Chunk{Data: []byte("b")})
if err != io.EOF {
t.Fatalf("got %v, want io.EOF", err)
}
result, _ := stream.CloseAndRecv()
if result.Bytes != 1 { t.Fatalf("got %d, want 1", result.Bytes) }
}Empty stream:
func TestUpload_EmptyStream(t *testing.T) {
client := setupClient(t, &fakeUploader{})
stream, _ := client.Upload(context.Background())
result, err := stream.CloseAndRecv()
if err != nil { t.Fatal(err) }
if result.Bytes != 0 { t.Fatalf("got %d, want 0", result.Bytes) }
}Bidirectional (Go)
Ordering per direction:
func TestConversation_Ordering(t *testing.T) {
fake := &fakeChatter{
clientMsgs: []*pb.Message{},
replies: []*pb.Reply{{Seq: 1}, {Seq: 2}, {Seq: 3}},
}
client := setupClient(t, fake)
stream, _ := client.Conversation(context.Background())
// Client sends 3 messages
for i := 0; i < 3; i++ {
stream.Send(&pb.Message{Seq: int32(i)})
}
stream.CloseSend()
// Server sends back 3 replies in order
var got []int32
for {
r, err := stream.Recv()
if err == io.EOF { break }
if err != nil { t.Fatal(err) }
got = append(got, r.Seq)
}
want := []int32{1, 2, 3}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
}Client closes send while still receiving (half-close):
func TestConversation_ClientHalfClose(t *testing.T) {
// Server keeps sending after client CloseSend()
fake := &fakeChatter{repliesAfterCloseSend: []*pb.Reply{{Seq: 99}}}
client := setupClient(t, fake)
stream, _ := client.Conversation(context.Background())
stream.Send(&pb.Message{Seq: 0})
stream.CloseSend() // half-close: no more sends, still receiving
r, err := stream.Recv()
if err != nil { t.Fatal(err) }
if r.Seq != 99 { t.Fatalf("got %d, want 99", r.Seq) }
}Cancellation from either side:
func TestConversation_ServerSideCancel(t *testing.T) {
fake := &fakeChatter{cancelAfterMsg: 1}
client := setupClient(t, fake)
stream, _ := client.Conversation(context.Background())
stream.Send(&pb.Message{Seq: 0})
_, err := stream.Recv()
st, _ := status.FromError(err)
if st.Code() != codes.Cancelled {
t.Fatalf("got %v, want Cancelled", st.Code())
}
}Wire-level / live-server streaming tests
View source (opens in new window)Wire-level / live-server streaming tests
The SKILL.md categories run against in-process harnesses (bufconn / InProcessServer); this reference covers the live-server variant - real channels against localhost:50051 - where deadline propagation, cancellation observed server-side, metadata round-trips, and ghz load behaviour are exercised over a real transport.
When to use
How to use
Step 1 - Pick the test tool
| Tool | Strength |
|---|---|
Language-native stubs (Go grpc.WithBlock(), Python grpc.aio, Java ManagedChannel) | Unit/integration tests |
grpcurl | Ad-hoc + smoke tests + scripts |
ghz | Load testing + benchmarks (concurrency, RPS) |
mockgrpc / mockery (Go), grpc-mock (Node) | Mock server stubs in unit tests |
Step 2 - Unary RPC sanity (baseline)
Per the gRPC core concepts docs (opens in new window), Unary = "single request, single response." Use this to verify the RPC plumbing before testing streams:
import grpc
from orders_pb2 import OrderRequest
from orders_pb2_grpc import OrdersStub
def test_unary_create_order():
with grpc.insecure_channel("localhost:50051") as ch:
stub = OrdersStub(ch)
resp = stub.CreateOrder(OrderRequest(item_count=2), timeout=5.0)
assert resp.order_id != ""Step 3 - Server-streaming test
Per the gRPC core concepts docs (opens in new window), server-streaming = "client sends a request and gets a stream to read a sequence of messages back."
def test_server_streaming_price_ticker():
with grpc.insecure_channel("localhost:50051") as ch:
stub = PricesStub(ch)
stream = stub.SubscribePrices(SubscribeRequest(symbol="AAPL"), timeout=10.0)
ticks = []
for tick in stream:
ticks.append(tick)
if len(ticks) >= 5:
stream.cancel()
break
assert len(ticks) == 5
assert all(t.symbol == "AAPL" for t in ticks)Step 4 - Client-streaming test
Per the gRPC core concepts docs (opens in new window), client-streaming = "client writes a sequence of messages and sends them to the server."
def test_client_streaming_upload():
def chunks():
for i in range(10):
yield UploadChunk(seq=i, data=b"x" * 1024)
with grpc.insecure_channel("localhost:50051") as ch:
stub = UploadsStub(ch)
resp = stub.Upload(chunks(), timeout=10.0)
assert resp.total_chunks == 10
assert resp.total_bytes == 10 * 1024Step 5 - Bidirectional streaming + ordering
Per the gRPC core concepts docs (opens in new window), bidirectional streams "operate independently" - server may emit messages before reading any client message, after, or interleaved.
import asyncio
async def test_bidi_chat():
async def client_messages():
for msg in ["hello", "how are you", "bye"]:
yield ChatMessage(text=msg)
await asyncio.sleep(0.1)
async with grpc.aio.insecure_channel("localhost:50051") as ch:
stub = ChatStub(ch)
responses = []
async for resp in stub.Chat(client_messages()):
responses.append(resp)
assert len(responses) >= 3Step 6 - Deadline propagation
Per the gRPC core concepts docs (opens in new window), "Clients specify maximum wait time; RPCs terminate with DEADLINE_EXCEEDED if exceeded."
def test_deadline_returns_correct_status():
with grpc.insecure_channel("localhost:50051") as ch:
stub = SlowStub(ch)
with pytest.raises(grpc.RpcError) as exc_info:
stub.SlowOperation(SlowRequest(), timeout=0.5)
assert exc_info.value.code() == grpc.StatusCode.DEADLINE_EXCEEDEDVerify the server-side:
def test_server_observes_deadline_propagation():
# Service should respect deadline and cancel its own downstream calls
with grpc.insecure_channel("localhost:50051") as ch:
stub = OrchestratorStub(ch)
with pytest.raises(grpc.RpcError):
stub.Compose(ComposeRequest(), timeout=0.1)
# Verify downstream call observed the cancellation
downstream_state = fetch_downstream_state()
assert downstream_state.cancelled_count >= 1Step 7 - Cancellation behavior
Per the gRPC core concepts docs (opens in new window), "Either party can terminate an RPC immediately. Changes made before a cancellation are not rolled back."
def test_cancellation_is_observed_server_side():
with grpc.insecure_channel("localhost:50051") as ch:
stub = LongRunningStub(ch)
future = stub.LongOperation.future(LongRequest())
time.sleep(0.5)
future.cancel()
# Server should record cancellation
time.sleep(0.5)
state = fetch_server_metrics()
assert state.cancelled_count >= 1Status codes, metadata, and load testing
See status-codes-metadata-load.md (opens in new window) for the full status-code matrix (OK, CANCELLED, DEADLINE_EXCEEDED, INVALID_ARGUMENT, UNAVAILABLE, ...), a request/response metadata round-trip test, and load testing with ghz.
Worked example
A prices service exposes SubscribePrices, a server-streaming RPC. QA needs to confirm the client receives ordered ticks and that cancelling the stream is observed server-side.
Result: the ticker stream is verified for ordered delivery, a clean 10s deadline, and server-side cancellation - the three behaviors a server-streaming RPC most often regresses on.
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Skip deadline + cancellation tests | Production cancellation orphans server-side work | Steps 6 + 7 |
| Test only OK and INTERNAL paths | Status-code regressions go silently | Test the matrix (status-codes reference) |
| Use BatchSpanProcessor or similar buffering on test client | Streams "complete" before all messages flush | Always synchronous in tests |
| Tests share a single channel across goroutines | Channel state contamination flakes | Per-test channel |
| Generate proto stubs at test runtime | CI flakes on plugin churn | Generate in build phase + commit |
Limitations
References
Related skills
buf-cli-lint-breaking-build
Wraps the buf CLI for protobuf PR gating: `buf build` (compile .proto), `buf lint` (STANDARD rules: snake_case fields, Service suffix), `buf breaking --against {ref}` (detect wire/codegen breakage vs a git/BSR baseline), and `buf format`. Use as the CI proto-lint + breaking-change gate, or to debug a breaking failure by rule ID (e.g. FIELD_NO_DELETE_UNLESS_NUMBER_RESERVED) and pick the FILE/PACKAGE/WIRE_JSON/WIRE ruleset per consumer. This is the detection TOOL that enforces the rules and carries the catalog of what is breaking and why (field-number reservation, wire-safe vs wire-incompatible changes, oneof/map constraints, the four buf categories) in references/versioning-strategy.md; for cross-service schema contract testing use protobuf-compat-checking - not this.
ghz-load
Wraps ghz, the gRPC load testing tool, for throughput and latency benchmarking. Covers test invocation (--proto + --call + host:port; or --protoset for compiled descriptors), load parameters (-n total requests, -c concurrency, -r RPS rate limit, -z duration), output formats (json/csv/html/influx-summary for CI consumption), the metrics reported (RPS achieved, latency p50/p95/p99, status-code distribution, errors), and CI integration patterns for regression gating. Use when benchmarking a gRPC service's throughput or detecting latency regressions in CI.
grpc-mock
Wraps gRPC server-mocking patterns for client-side tests: Go bufconn (in-memory net.Listener via google.golang.org/grpc/test/bufconn) + mockgen-generated interface mocks, Python pytest-grpc fixtures + unittest.mock patching of stubs, JVM grpc-mock library / in-process gRPC server (InProcessServerBuilder), Node @grpc/grpc-js fake server with NewServer-on-port-0. Also carries the interceptor-layer test patterns (Go / Java / grpc-js auth, retry, logging, error-mapping, chained ordering via a spy handler) in references/interceptors.md. Use when writing client-side tests that need a controllable gRPC server response (success cases, error cases, timeouts, single-response error injection) without spinning up a real backend, or when testing a gRPC interceptor. For multi-message streaming-sequence tests (server-streaming, bidi), use grpc-streaming-test-author instead. Distinct from grpcurl-cli (ad-hoc CLI invocation against a real server) and ghz-load (perf against a real server).
grpcurl-cli
Wraps grpcurl, the curl-equivalent CLI for gRPC. Covers descriptor sources (server reflection default, --import-path + --proto for proto files, --protoset for compiled descriptor sets), service discovery (`list`, `describe`), invoking unary RPCs (`-d '{...}'`, `-d @file.json`, `-d @` for stdin), streaming RPCs (newline-delimited JSON via stdin), TLS configuration (--cacert, --cert, --key, --insecure, --plaintext), header injection (-H 'Authorization: Bearer ...'), and exit codes. Use for ad-hoc gRPC debugging, smoke testing, scriptable PR-time gates, and CLI-based interaction with reflective gRPC services.