Skip to main content

Testing

Testing a client application can be a crucial part of ensuring its functionality and performance. When it comes to web applications, spinning up a full server to test against may not always be the best option. In the following sections, we will go through a couple of alternatives.

Mocking services

The function createRouterTransport from @connectrpc/connect creates an in-memory server with your own RPC implementations. To illustrate, let's implement a very simple ELIZA service:

import { ElizaService } from "@buf/connectrpc_eliza.connectrpc_es/connectrpc/eliza/v1/eliza_connect";
import { SayResponse } from "@buf/connectrpc_eliza.connectrpc_es/connectrpc/eliza/v1/eliza_pb";
import { createRouterTransport } from "@connectrpc/connect";

const mockTransport = createRouterTransport(({ service }) => {
service(ElizaService, {
say: () => new SayResponse({ sentence: "I feel happy." }),
});
});

In your tests, you can then use the mockTransport with the function createPromiseClient or createCallbackClient, just like you would use any other transport:

import { ElizaService } from "@buf/connectrpc_eliza.connectrpc_es/connectrpc/eliza/v1/eliza_connect";
import { SayResponse } from "@buf/connectrpc_eliza.connectrpc_es/connectrpc/eliza/v1/eliza_pb";
import { createRouterTransport, createPromiseClient } from "@connectrpc/connect";

describe("simple ELIZA mock", function () {
const mockTransport = createRouterTransport(({ service }) => {
service(ElizaService, {
say: () => new SayResponse({ sentence: "I feel happy." }),
});
});
it("returns mocked answer", async () => {
const client = createPromiseClient(ElizaService, mockTransport);
const { sentence } = await client.say({ sentence: "how do you feel?" });
expect(sentence).toEqual("I feel happy.");
});
});

Expectations in the service

So far, we have only returned a mock response from our server, but we can also use expectations to assert that our client sends requests as expected:

const mockTransport = createRouterTransport(({ service }) => {
service(ElizaService, {
say(request) {
expect(request.sentence).toBe("how do you feel?");
return new SayResponse({ sentence: "I feel happy." });
},
});
});

Raising errors

Under the hood, the transport runs nearly the same code that a server running on Node.js would run. This means that all features from implementing real services are available: You can access request headers, raise errors with details, and also mock streaming responses. Here is an example that raises an error on the fourth request:

const mockTransport = createRouterTransport(({ service }) => {
const sentences: string[] = [];
service(ElizaService, {
say(request: SayRequest) {
sentences.push(request.sentence);
if (sentences.length > 3) {
throw new ConnectError(
"I have no words anymore.",
Code.ResourceExhausted,
);
}
return new SayResponse({
sentence: `You said ${sentences.length} sentences.`,
});
},
});
});

Transport options

Other transports take options like interceptors. They can be passed to createRouterTransport in the optional second argument, an object with the property transport for transport options.

Jest and the jsdom environment

If you are using jest-environment-jsdom, you will very likely see an error when you run tests with the router transport, the protobuf binary format, or any other code relying on the otherwise widely available encoding API:

ReferenceError: TextEncoder is not defined

If you see this error, consider to use @bufbuild/jest-environment-jsdom instead.

What about mocking fetch itself?

Mocking fetch itself is a common approach to testing network requests, but it has some drawbacks. Instead, using a schema-based serialization chain with an in-memory transport can be a better approach. Here are some reasons why:

  • With schema-based serialization, the request goes through the same process as it would in your actual code, allowing you to test the full flow of your application.
  • You can create stateful mocks with an in-memory transport, which can test more complex workflows and scenarios.
  • An in-memory transport is fast, so you can quickly set up your tests without worrying about resetting mocks.
  • With an in-memory transport, you can eliminate the need for spy functions because you can implement any checks directly in your server implementation. This can simplify your testing code and make it easier to understand.
  • You can leverage expect directly within the code of your mock implementation to verify particular scenarios pertaining to the requests or responses.

End-to-end testing

Playwright is a powerful tool for testing complex web applications. It can intercept requests and return mocked responses to the web application under test. If you want to use Playwright with a Connect client, consider using @connectrpc/connect-playwright to bring the type-safety of your schema to Playwright's API Mocks.

A basic example:

test.describe("mocking Eliza", () => {
let mock: MockRouter;
test.beforeEach(({ context }) => {
mock = createMockRouter(context, {
baseUrl: "https://demo.connectrpc.com",
});
});
test("mock RPCs at service level", async ({ page }) => {
await mock.service(ElizaService, {
say: () => new SayResponse({ sentence: "I feel happy." }),
});
// Any calls to Eliza.Say in test code below will be intercepted and invoke
// the implementation above.
});
});

To get started, take a look at the connect-playwright repository, and the example project.