gRPC Mocks, Tests and Contract Checks From a .proto
Change one field in a .proto from double to string, and nothing complains. The code compiles, the deploy goes green, and a service still built from the old contract keeps answering with a double. Here’s what Go’s protobuf runtime does when a client on the new contract reads that answer:
msg := dynamicpb.NewMessage(fraudCheckResponse) // risk_score is now a string
err := proto.Unmarshal(recordedBytes, msg) // the server still sends a double
fmt.Println(err, msg.Get(riskScore).String(), len(msg.GetUnknown()))
<nil> 9
No error. risk_score comes back empty, and the nine bytes of the double are tucked away as unknown fields. Protobuf is designed to tolerate a field whose wire type doesn’t match, so the break surfaces later as a zero value in someone else’s service.
This post walks through catching that before it ships, starting from nothing but the .proto and proxymock. You will generate gRPC mocks and tests from the contract and serve the mock to grpcurl. Then you will record the real service’s responses with the generated tests, see what the recording covers, and flag the type change in CI. Everything below ran on a real service, and every output is copied from that run.
flowchart LR
P[fraud.proto] --> G[proxymock generate]
G --> M[Mock for callers]
G --> T[Inbound tests]
T --> R[proxymock record]
S[Real service] --> R
R --> C[proxymock coverage]
R --> V[proxymock validate in CI]
What you need
- proxymock 2.5.1249 or newer (install). It runs locally and needs no account.
- grpcurl and Go, to build the example service.
- A gRPC service and its
.proto. The example is the fraud-check service from Speedscale’s open source microsvc demo, one unary RPC that scores a transaction:
service FraudChecker {
rpc CheckTransaction(TransactionRequest) returns (FraudCheckResponse);
}
message TransactionRequest {
string account_id = 1;
string user_id = 2;
double amount = 3;
string transaction_type = 4;
string merchant_category = 5;
}
message FraudCheckResponse {
bool approved = 1;
double risk_score = 2;
string reason = 3;
}
Clone it and build the service:
git clone https://github.com/speedscale/microsvc && cd microsvc
(cd backend/fraud-service && go build -o ../../fraud-service ./cmd/server)
Step 1: Generate mocks and tests from the .proto
proxymock generate reads the .proto and writes one call per unary RPC. Outbound calls are mocks that stand in for the service. Inbound calls are tests that drive requests at it:
proxymock generate --direction outbound --out ./proxymock/mocks-fraud ./proto/fraud/fraud.proto
proxymock generate --direction inbound --out ./proxymock/tests-fraud ./proto/fraud/fraud.proto
Processing 1 RPCs from protobuf specification...
Processing RPC: /fraud.v1.FraudChecker/CheckTransaction
Generated: grpc_fraud.v1.FraudChecker_CheckTransaction_OK_inbound.md (status OK)
...
PLACEHOLDERS: 1 of 1 inbound test(s) still send generated placeholders ("example_value" or ${{param:...}}).
Each file is a gRPC call over HTTP/2, in the same shape proxymock writes when it records real traffic, so the mock server and replay use it as is. The messages are synthesized from the field types: strings are example_value, numbers sit mid-range, repeated fields get two items, an enum gets its first non-zero value, and fields declared optional stay empty unless you pass --include-optional. Streaming RPCs are skipped and listed in the summary.
If your .proto imports other files, pass their root with -I, just as you would to protoc. To generate part of a large contract, filter by method with --include-paths '/fraud.v1.FraudChecker/*'.
Step 2: Serve the mock to a gRPC client
Start the mock server on a mapped port. The backend address is deliberately dead, and --no-passthrough keeps anything that misses from going anywhere real:
proxymock mock --in ./proxymock/mocks-fraud --map 15051=localhost:15099 --no-passthrough
Call it like the real service:
grpcurl -plaintext -proto proto/fraud/fraud.proto \
-d '{"account_id":"example_value","user_id":"example_value","amount":50.5,"transaction_type":"example_value","merchant_category":"example_value"}' \
localhost:15051 fraud.v1.FraudChecker/CheckTransaction
{
"approved": true,
"riskScore": 50.5,
"reason": "example_value"
}
That is a working gRPC dependency with no service behind it. One thing to know: a gRPC mock matches the whole request message, so a call with different values misses. With --no-passthrough, a gRPC client reports the miss as Unimplemented. The mock’s output shows the closest mock and the fields that differ, with your request on the < line:
< http:requestBodyJSON: {"1:LEN":{"12:I64":3544385894514713443},"2:LEN":"user-7","3:I64":4623226492472524800,"4:LEN":"DEPOSIT","5:LEN":"5411"}
> http:requestBodyJSON: {"1:LEN":"example_value","2:LEN":"example_value","3:I64":4632304060471443456,"4:LEN":"example_value","5:LEN":"example_value"}
The odd-looking field 1 is the account id acct-1001. Step 3 explains why it reads that way.
To make it answer your client’s real requests, edit the request values in the mock file, or better, record the real service once and mock from that recording, as How to Mock gRPC APIs shows.
Step 3: Record the real responses
A generated test is a driver, not a baseline. Its expected response was made up, and its request sends example_value, which a real service may reject. Open the inbound test and replace the placeholders. The body shows fields by number, so match each one to the .proto (account_id = 1, user_id = 2, and so on):
{
"1:LEN": "acct-1001",
"2:LEN": "user-7",
"3:I64": 4632304060471443456,
"4:LEN": "DEPOSIT",
"5:LEN": "5411"
}
I64 is a double shown as its raw 64 bits, here 50.5, so leave it unless the value matters. Then run the service under the recorder and replay the edited test through the recorder’s inbound port:
proxymock record --app-port 18551 --proxy-in-port 14143 --out ./proxymock/recorded-fraud \
-- env GRPC_PORT=18551 METRICS_PORT=18552 ./fraud-service
proxymock replay --in ./proxymock/tests-fraud --test-against http://localhost:14143 \
--out ./proxymock/results/realize-fraud
│ ENDPOINT │ METHOD │ COUNT │ AVG LATENCY │ ... │ FAILED │ STATUS CODE MISMATCH │
│ /fraud.v1.FraudChecker/CheckTransaction │ POST │ 1 │ 17 ms │ ... │ 0 │ 0% │
sequenceDiagram
participant Test as Generated test
participant Rec as proxymock record (14143)
participant Svc as fraud-service (18551)
Test->>Rec: CheckTransaction (edited request)
Rec->>Svc: forwards the call
Svc-->>Rec: real FraudCheckResponse
Rec-->>Test: response, also written to recorded-fraud
The replay reports a mismatch, and that’s expected: its expected response was the synthesized one. Stop the recorder with Ctrl-C. proxymock/recorded-fraud now holds the request and the service’s real answer, approved: true with a risk score of 0.02525 and reason OK. That recording is what you replay as a regression test from now on.
One detail from this recording surprised me. The account id shows up like this:
"1:LEN": {
"12:I64": 3544385894514713443
}
proxymock decodes messages without the .proto, so for every length-delimited field it has to guess: string, bytes or nested message. The nine bytes of acct-1001 happen to also parse as a message holding one 64-bit field. The guess only affects the display. The bytes are written back exactly, which is what the replay sends.
Step 4: See what the recording covers
proxymock coverage --spec ./proto/fraud/fraud.proto --in ./proxymock/recorded-fraud
Recording/schema coverage: 1 inbound HTTP pair(s), 1 matched
Covered: 1/1 operations, 1/1 statuses, 3/3 fields
POST /fraud.v1.FraudChecker/CheckTransaction: 1 pair(s) [localhost/2026-10-26_16-45-46.515954Z.md]
status OK: 1 pair(s) [localhost/2026-10-26_16-45-46.515954Z.md]
$.approved: 1 pair(s) [localhost/2026-10-26_16-45-46.515954Z.md]
$.reason: 1 pair(s) [localhost/2026-10-26_16-45-46.515954Z.md]
$.risk_score: 1 pair(s) [localhost/2026-10-26_16-45-46.515954Z.md]
Every RPC in the .proto is an operation, statuses are gRPC status names, and fields are response message fields. On a real service the useful part is the ranked gap list at the end: RPCs no call reached, then error statuses never seen, then fields no response ever set. Add --json and keep the file next to the recording to track coverage over time.
Step 5: Catch the contract break
Back to the opening problem. Someone changes risk_score to a string in the .proto, but the service is still built from the old contract. Make that change in a copy of the contract, then point validate at it and the recording:
sed 's/double risk_score = 2;/string risk_score = 2;/' proto/fraud/fraud.proto > fraud-v2.proto
proxymock validate --spec ./fraud-v2.proto --in ./proxymock/recorded-fraud
Against the unchanged proto/fraud/fraud.proto the same recording is CONFORMANT and exits 0. Against the changed one:
VIOLATION POST /fraud.v1.FraudChecker/CheckTransaction (0) [proxymock/recorded-fraud/localhost/2026-10-26_16-45-46.515954Z.md]
$.risk_score: type mismatch, expected string, got fixed64
checked 1 pair(s): 0 conformant, 1 violating, 0 without a spec route
The command exits 2. Where the protobuf runtime returned no error and an empty field, validate names the field and both types. It can do that because it walks the wire format itself instead of unmarshalling, so a wire type that cannot be the declared type is a finding rather than an unknown field. It also flags enum numbers the enum does not declare, field numbers the message does not declare, and missing proto2 required fields.
The exit codes are made for CI: 0 conformant, 2 violations, 3 a call whose method is not in the .proto, and 1 when the check cannot run. Run it on a recording to check that a dependency still honors its contract, or on a replay’s output to check that your change did not break your own.
What does not work yet
- Streaming RPCs. Unary RPCs work end to end. A streaming call is recorded as one call with its messages joined, and replay sends every call as unary. Considerations for Testing gRPC Streams covers why streams are harder to test. So
generateskips streaming RPCs, and you should keep them out of a regression baseline for now. - Generated mocks answer one request. As in step 2, a generated mock matches the exact request it was generated with. Record the real traffic, or edit the request, before relying on it for a client that sends real values.
From an AI coding assistant
The same flow runs from an AI coding assistant through the proxymock MCP server. Its generate tool takes the .proto path and import roots. proxymock inventory finds the .proto files in a repository and suggests this exact path when a service has no recorded traffic.
Next steps
- The proxymock gRPC docs cover recording, mocking, generating and validating in more depth.
- Run
proxymock generate --helpto see every flag. - Point it at your own
.proto, and runvalidatein CI on the next contract change.