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

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",
});

Comments


Zack said:

How can I find the path to my servicer? I can hit everything fine in postman but when I run my integration tests in jest, it doesn't seem to pick up the path that I've entered. I've tried all sorts of combinations. Is there a way to print the path that was just in postman?

On

Lucas said:

Hi Zack,
You could look at the network tab in the developer console of your browser or cypress to see, but usually it's `yourbasedomain.com/protobuf_package_name.MyServicer/MethodName`

On

Giancarlo Soverini said:

This article is excellent! Thank you so much for working on it!

On

Afsana Zaman Nipa said:

Where did you import/get the object PerformGreetingRes from? Can you please edit you blog a little big including the necessary imports in the handler files?

On

Lucas said:

PerformGreetingRes would be part of your generated code created by the protoc command line utility (or however you generate you code from your protobuf files)

On

Leave a comment