Mocking gRPC-web responses
When creating integration tests, it is beneficial to mock API calls at the network level rather than mocking parts of your application that perform the API calls. Many testing libraries provide this functionality for JSON APIs, but it is slightly more difficult for gRPC-web requests. Let's have a look how it's done!
We're going to create the following function that takes a protobuf message with its encode function, and returns a response object:
// this type is tailored towards ts-proto code generation which uses protobufjs, Writer comes from there
export default function createGrpcResponse<Message>(data: Message, encode: (message: Message) => Writer) {
//...
return {
statusCode: 200,
body: buffer, // ArrayBuffer
headers: {"content-type": "application/grpc-web+proto"},
};
}
The encode function
I am a big fan of the ts-proto library thanks to its idiomatic Typescript code generation. This library generates encode and decode functions for all of your protobuf messages. I'll use these in the examples, but your protobuf library of choice should have equivalent functions; the official grpc-web has serializeBinary and deserializeBinary, for example.
Getting binary data of the message as a Uint8Array is as simple as this:
const data = params.encodeFn(params.response).finish();
However! This is not a complete gRPC-web response, it is only the binary version of the protobuf message.
Building a gRPC-web response
Shout-out to protobuf-ts which I used as a reference when figuring out how the response is actually constructed.
The response has two parts: the data frame (containing the binary message data), and the trailers frame (containing a string with the grpc status code and message). Each frame starts with a magic number to identify it, and four-bytes identifying the length of the frame.
Here's the function in all its glory:
export default function createGrpcResponse<Message>(data: Message, encode: (message: Message) => Writer) {
const data = encode(data).finish();
// create the data length bytes - there is probably a more concise way, but this works
const dataLengthBytes = new Uint8Array(
new Uint32Array([data.byteLength]).buffer
);
dataLengthBytes.reverse();
const dataFrame = new Uint8Array(data.byteLength + 5);
dataFrame.set([0x00], 0); // set the magic byte 0x00 to identify the data frame
dataFrame.set(dataLengthBytes, 1); // set the length bytes
dataFrame.set(data, 5); // set the actual data
// you can add mock errors by tweaking the trailers string with different status codes/messages
const trailersString = `grpc-status: 0\r\ngrpc-message: `;
const encoder = new TextEncoder();
const trailers = encoder.encode(trailersString);
const trailersLengthBytes = new Uint8Array(
new Uint32Array([trailers.byteLength]).buffer
);
trailersLengthBytes.reverse();
const trailersFrame = new Uint8Array(trailers.byteLength + 5);
trailersFrame.set([0x80], 0); // magic byte for trailers is 0x80
trailersFrame.set(trailersLengthBytes, 1);
trailersFrame.set(trailers, 5);
// create the final body by combining the data frame and trailers frame
const body = new Uint8Array(dataFrame.byteLength + trailersFrame.byteLength);
body.set(dataFrame, 0);
body.set(trailersFrame, dataFrame.byteLength);
return {
statusCode: 200,
body: body.buffer,
headers: {
"content-type": "application/grpc-web+proto",
},
};
}
How do I use this?
I am using something similar to this function with both Cypress and MSW.
Cypress
cy.intercept(
"http://localhost/path.to.servicer/PerformGreeting",
createGrpcResponse({greeting: "Hello!"}, PerformGreetingRes.encode)
);
MSW
rest.post(
"http://localhost/path.to.servicer/PerformGreeting",
(req, res) => {
const message = createGrpcResponse({greeting: "Hello!"}, PerformGreetingRes.encode);
return res(grpcRes(message));
/* if using MSW v2+
HttpResponse.arrayBuffer(message.body, {
headers: message.headers,
}); */
}
)
function grpcRes(
grpcResponse: ReturnType<typeof createGrpcResponse>
): ResponseTransformer {
return (res) => {
res.body = grpcResponse.body;
Object.entries(grpcResponse.headers).forEach(([key, value]) =>
res.headers.set(key, value)
);
res.status = grpcResponse.statusCode;
return res;
};
}
What about requests?
If you are intercepting requests and want to deserialize the binary data into your protobuf message, it's simple. Just strip the first five bytes (the magic number and length bytes), and pass the data to your decode function!
Here's a little Cypress example to see why it might be useful.
cy.intercept(
"http://localhost/path.to.servicer/UpdateData",
createGrpcResponse({status: "success"}, UpdateDataRes.encode)
).as("updateData");
// ...do something to cause an update
cy.wait("@updateData")
.its("request.body")
.then((body: ArrayBuffer) =>
UpdateDataReq.decode(new Uint8Array(body.slice(5))) //strip the first 5 bytes
)
.should("deep.equal", {
myRequestData: "should have this string",
});