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 set up a very simple ELIZA service:
import { createRouterTransport } from "@connectrpc/connect";
import { ElizaService } from "@buf/connectrpc_eliza.bufbuild_es/connectrpc/eliza/v1/eliza_pb";
const mockTransport = createRouterTransport(({ service }) => {
service(ElizaService, {
say: () => { 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) {
sentences.push(request.sentence);
if (sentences.length > 3) {
throw new ConnectError(
"I have no words anymore.",
Code.ResourceExhausted,
);
}
return {
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 { 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: () => { 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.