Testland
Browse all skills & agents

grpc-streaming-test-author

Workflow-driven skill that builds gRPC 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). Use when adding tests for a new streaming RPC or auditing a suite for uncovered categories. Different test surface from grpc-interceptor-test-author (interceptor layer) and grpc-mock (harness); for wire-level streaming semantics use grpc-streaming-tests, not this.

Install with skills.sh (any agent)

npx skills add testland/qa --skill grpc-streaming-test-author
View source

grpc-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 grpc-status-code-mapping-reference for status-code assertions.

When to use

  • Adding tests for a new streaming RPC.
  • Auditing an existing streaming-RPC test suite - is each pattern's required category covered?
  • Investigating a streaming-RPC bug - minimal repro from the test matrix.
  • PR review of changes to streaming RPCs (the protobuf-versioning-strategy-reference rules on stream changes are subtle).

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
}
RPCPatternRequired test categories
Sendunarysuccess, every status code per grpc-status-code-mapping-reference
Subscribeserver-streamsuccess, ordering, server-side close after N messages, server-side mid-stream error, client-side cancel mid-stream, deadline-exceeded mid-stream
Uploadclient-streamsuccess, server completes before client finishes, server-side error mid-upload, client-side cancel before send, empty stream
Conversationbidisuccess, 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_ARGUMENT

Server-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) == 2

Server-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 grpc-status-code-mapping-reference
    with pytest.raises(grpc.RpcError) as exc:
        next(events_iter)
    assert exc.value.code() == grpc.StatusCode.INTERNAL

Client 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.CANCELLED

Deadline-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_EXCEEDED

Client-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              -       -        -         X

Empty cells in covered patterns = coverage gap. PR must justify or add a test.

Step 4 - Pick the test harness

Per grpc-mock:

LanguageHarness
Gobufconn in-process server + t.Cleanup
Pythonpytest fixture with [::]:0 port + iterator-based stream API
JVMInProcessServerBuilder + 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 matrix

The 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 protobuf-versioning-strategy-reference, 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-patternWhy it failsFix
Single happy-path test per streaming RPCMisses ordering / cancellation / deadline categoriesUse Step 2's per-pattern matrix
Test asserts on the last message onlyMisses ordering bugs in earlier messagesCollect entire stream, assert on the full sequence
time.sleep to wait for server messagesRace-prone; flakes in CIUse channel close / iterator exhaustion as completion signal
Cancellation test sleeps then cancelsRace: server may finish before cancelInject a controllable blocker in the fake server
No deadline-exceeded testProduction deadlines surface in mid-streamAlways include - per grpc-status-code-mapping-reference, DEADLINE_EXCEEDED is its own code
Mock at interface level for streamingSkips marshalling + orderingIn-process server only for streaming
Bidi test where client and server are deterministic-interleavedMisses race conditions inherent in "operate independently"One test per direction + one test with concurrent send/recv
Don't CloseSend() in client-streaming testsServer waits foreverAlways close the send side explicitly

Limitations

  • Wire-level fault injection. Partial-byte cutoffs and middlebox-induced disconnects aren't reachable through bufconn / InProcessServer. For these, use toxiproxy + a real server.
  • Backpressure semantics differ per stack. Go bufconn buffer size affects when Send blocks; Java StreamObserver is push-based; Python is iterator-pull. Tests are stack-specific.
  • HTTP/2 flow control isn't exercised. In-process transports skip H2 framing; flow-control bugs need real transports.
  • Doesn't test contract compliance. Mocks reflect what this test expects. Real-server contract tests are separate; see protobuf-compat-checking (in the qa-contract-testing plugin).
  • Concurrent-client stress tests not in scope. For that see ghz-load (unary load) or hand-rolled multi-client streaming harnesses.

References

  • gRPC core concepts (four streaming patterns): grpc.io/docs/what-is-grpc/core-concepts/ (opens in new window).
  • Status-code assertions: grpc-status-code-mapping-reference.
  • Test harness: grpc-mock.
  • Proto evolution rules: protobuf-versioning-strategy-reference.
  • Breaking-change detection: buf-cli-lint-breaking-build.
  • Sibling wire-level streaming patterns: grpc-streaming-tests.

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())
    }
}

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; for the catalog of what is breaking and why use protobuf-versioning-strategy-reference, and 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-interceptor-test-author

Authors unit tests for gRPC interceptor logic: Go grpc.UnaryServerInterceptor/UnaryClientInterceptor, Java ServerInterceptor/ClientInterceptor, and grpc-js client interceptors. Covers auth (Unauthenticated on bad token), retry (backoff on Unavailable), logging/tracing (metadata extraction + propagation), error-mapping (status translation), and chained interceptor ordering - by calling the interceptor directly with a spy handler, no live backend. Use when a gRPC interceptor is written or modified. Different test surface from grpc-streaming-test-author (multi-message stream sequences) and grpc-mock (service handler logic) - use those, not this, for streams or handlers.

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. Use when writing client-side tests that need a controllable gRPC server response (success cases, error cases per grpc-status-code-mapping-reference, timeouts, and single-response error injection) without spinning up a real backend. 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).

grpc-status-code-mapping-reference

Pure-reference catalog of gRPC standard status codes - the 17 canonical codes (OK..UNAUTHENTICATED), their numeric values, semantics, retry behaviour per AIP-194 (only UNAVAILABLE is auto-retry-safe), and the gRPC-to-HTTP status mapping used by grpc-gateway (NOT_FOUND→404, INVALID_ARGUMENT→400, PERMISSION_DENIED→403, UNAUTHENTICATED→401, RESOURCE_EXHAUSTED→429, FAILED_PRECONDITION→400 not 412, ABORTED→409, UNAVAILABLE→503, DEADLINE_EXCEEDED→504, etc.). Use when designing a gRPC service's error vocabulary, writing assertions in gRPC client tests, configuring retry policies, or mapping gRPC errors to HTTP via a gateway. Consumed by buf-cli-lint-breaking-build, ghz-load, grpcurl-cli, grpc-mock, grpc-streaming-test-author.

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.

protobuf-versioning-strategy-reference

Pure-reference catalog of protobuf3 versioning and breaking-change rules: field-number reservation (reserve on delete; 1..536870911; 19000-19999 reserved), wire-safe vs wire-incompatible changes (add/remove safe with reservation; changing a field number always breaks), compatible type conversions (int32/uint32/int64/uint64/bool; sint32/sint64; string/bytes for UTF-8; enum/int), oneof + map constraints, and buf's four breaking categories (FILE/PACKAGE/WIRE_JSON/WIRE) with rule IDs. Use when designing a schema change or picking a buf breaking ruleset. This is the catalog of what is breaking and why, not a scanner; to detect changes in CI use buf-cli-lint-breaking-build, for the gRPC status-code vocabulary use grpc-status-code-mapping-reference, and for cross-service contract testing use protobuf-compat-checking.