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.

Component Testing

For components that interface with Connect, it will generally be desirable to mock your RPCs since a backend may not be available or desirable to access in a unit test. The easiest way to do this is via Connect's createRouterTransport function.

Mocking Transports

The function createRouterTransport from @connectrpc/connect creates an in-memory server with your own RPC implementations. It allows you to mock a backend to cover different behavior in your component.

To illustrate, let's setup 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." }),
});
});

Under the hood, this mock 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.`,
});
},
});
});

You can also use expectations to assert that your 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." });
},
});
});

The createRouterTransport function also accepts an optional second argument, allowing you to pass options like interceptors.

Examples

A recommended way to structure components that need to issue Connect calls is to pass a Transport object to the component. This adds flexibility to components for unit testing, but will vary depending on the framework being used. Below are links to some helpful examples for passing transports to components and mocking them in unit tests.

React

With React, you have actions such as component props or React Context to provide a transport to your component.

For a working example using the Context API, see the Create React App project in the examples-es repo.

Svelte

The suggested method for providing transports to Svelte components makes use of Svelte's Context API.

To view a working example of using the Context API to mock transports in Svelte components, check out the Svelte project in the examples-es repo.

Vue

Structuring a Vue application to allow for easy component testing involves Vue's Provide/Inject API.

For a working example of mocking transports in Vue components, see the Vue project in the examples-es repo.

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.