Skip to content

Instantly share code, notes, and snippets.

@martin-fv
Last active April 5, 2024 19:41
Show Gist options
  • Star 13 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save martin-fv/9bad75363245a16c918e112eb6a19f0d to your computer and use it in GitHub Desktop.
Save martin-fv/9bad75363245a16c918e112eb6a19f0d to your computer and use it in GitHub Desktop.
Storybook mocking and Supertest with tRPC v10
import { t } from "./trpcRouter";
import { inferRouterInputs, inferRouterOutputs } from "@trpc/server";
export const appRouter = t.router({
account: accountRoutes,
post: postRoutes,
});
// export type definition of API
export type AppRouter = typeof appRouter;
export type RouterInput = inferRouterInputs<AppRouter>;
export type RouterOutput = inferRouterOutputs<AppRouter>;
import { RouterInput, RouterOutput } from "../appRouter";
import { jsonRpcSuccessResponse } from "../trpc";
import { rest } from "msw";
import path from "path";
/**
* Mocks a TRPC endpoint and returns a msw handler for Storybook.
* Only supports routes with two levels.
* The path and response is fully typed and infers the type from your routes file.
* @todo make it accept multiple endpoints
* @param endpoint.path - path to the endpoint ex. ["post", "create"]
* @param endpoint.response - response to return ex. {id: 1}
* @param endpoint.type - specific type of the endpoint ex. "query" or "mutation" (defaults to "query")
* @returns - msw endpoint
* @example
* Page.parameters = {
msw: {
handlers: [
getTRPCMock({
path: ["post", "getMany"],
type: "query",
response: [
{ id: 0, title: "test" },
{ id: 1, title: "test" },
],
}),
],
},
};
*/
export const getTRPCMock = <
K1 extends keyof RouterInput,
K2 extends keyof RouterInput[K1], // object itself
O extends RouterOutput[K1][K2] // all its keys
>(endpoint: {
path: [K1, K2];
response: O;
type?: "query" | "mutation";
}) => {
const fn = endpoint.type === "mutation" ? rest.post : rest.get;
const route = path.join(
process.env.BASE_URL,
"/trpc/",
endpoint.path[0] + "." + (endpoint.path[1] as string)
);
return fn(route, (req, res, ctx) => {
return res(ctx.json(jsonRpcSuccessResponse(endpoint.response)));
});
};
import { Meta } from "@storybook/react/types-6-0";
import { PostList } from "../PostList";
import { getTRPCMock } from "../getTrpcMock";
export default {
title: "Components/PostList",
component: PostList,
} as Meta;
export const PostListPage = () => {
return <PostList />;
};
PostList.parameters = {
msw: {
handlers: [
getTRPCMock({
path: ["post", "listPosts"],
response: [
{
id: "1",
title: "Hello",
description: "World",
},
],
}),
],
},
};
//https://github.com/mswjs/msw-storybook-addon
//Install ths package first
import { initialize, mswDecorator } from 'msw-storybook-addon';
// Initialize MSW
initialize();
// Provide the MSW addon decorator globally
export const decorators = [mswDecorator];
import { RouterInput, RouterOutput } from "../../src/trpc/appRouter";
import { TRPCSuccessResponse } from "@trpc/server/rpc";
import server from "../../src/server";
import supertest from "supertest";
export type SuperRequestResponse<T = any> = {
body: T;
};
/**
* Endpoint testing with TRPC using supertest
* Only supports routes with two levels
* @todo make it accept multiple endpoints
* @param enpoint.token - auth token
* @param enpoint.input - Input to the endpoint
* @param enpoint.method - HTTP method, defaults to GET
* @param enpoint.expectStatus - expected status code, defaults to 200
* @returns - The typed HTTP response
* @example
* await superTrpc("follow", "setFollow", {
token: token,
input: {
person_uuid: creator.uuid,
follow: false,
},
method: "POST",
expectStatus: 200,
});
*/
export const superTrpc = async <
K1 extends keyof RouterInput,
K2 extends keyof RouterInput[K1], // object itself
I extends RouterInput[K1][K2], // all its keys
O extends RouterOutput[K1][K2] // all its keys
>(
parentRoute: K1,
childRoute: K2,
{
input,
expectStatus = 200,
token,
method,
}: {
input?: I;
expectStatus?: number;
token?: string;
method?: "GET" | "POST";
}
): Promise<SuperRequestResponse<TRPCSuccessResponse<O>>> => {
let headers: any = {};
if (token) headers["X-Auth-Token"] = token;
const request = await supertest(server);
const route = `${TRPC_ENDPOINT_PREFIX}${parentRoute}.${childRoute.toString()}`;
const res =
method === "POST"
? await request.post(route).send(input).set(headers)
: await request.get(route).send(input).set(headers);
if (res.statusCode !== expectStatus) console.error("Error Body", res.body);
expect(res.statusCode).toBe(expectStatus);
return res;
};
export type RpcResponse<Data> = RpcSuccessResponse<Data> | RpcErrorResponse;
export type RpcSuccessResponse<Data> = {
id: null;
result: { type: "data"; data: Data };
};
export type RpcErrorResponse = {
id: null;
error: {
message: string;
code: number;
data: {
code: string;
httpStatus: number;
stack: string;
path: string; //TQuery
};
};
};
// According to JSON-RPC 2.0 and tRPC documentation.
// https://trpc.io/docs/rpc
export const jsonRpcSuccessResponse = (data: unknown) => ({
id: null,
result: { type: "data", data },
});
@airtonix
Copy link

airtonix commented Feb 9, 2023

If we're in storybook, what's the point of the superTrpc.ts ?

Seems like distracting noise towards the example of "mock response of trpc in storybook".

Remember that this also needs to work on static builds of storybook hosted on a static website.

@airtonix
Copy link

airtonix commented Feb 9, 2023

I went with https://github.com/maloguertin/msw-trpc instead :

npx msw init ./public/

preview.ts

import { initialize, mswDecorator } from "msw-storybook-addon";

...

initialize();

...

export const decorators = [mswDecorator, ...OtherDecorators];

src/services/Api/mock.ts

import { createTRPCMsw } from "msw-trpc";
import { getBaseUrl } from "./getBaseUrl";
import type { AppRouter } from "./router";

export const trpcMsw = createTRPCMsw<AppRouter>({
  basePath: "/api/trpc",
  baseUrl: getBaseUrl(),
});

src/components/TheComponent/TheComponent.stories.tsx

import React from "react";
import type { ComponentStory, ComponentMeta } from "@storybook/react";

import { trpcMsw } from "../../../services/Api/mock";
import { TheComponent } from "./TheComponent";

export default {
  title: "Your/Storybook/Story",
  component: TheComponent,
  decorators: [
    (Story) => (
      <TrpcProvider> // :one: 
        <Story />
      </TrpcProvider>
    ),
  ],
  parameters: {
    msw: {
      handlers: [
        trpcMsw.the.query.path.for.your.schema.query((req, res, ctx) => { // :two: 
          return res(ctx.status(200), ctx.data({ id: 'trololololololol' }))
        })
      ],
    },
  },
  // More on argTypes: https://storybook.js.org/docs/react/api/argtypes
} as ComponentMeta<typeof TheComponent>;

export const Basic:ComponentStory<typeof TheComponent> = (args) => (
    <TheComponent {...args} />
);
  • 1️⃣ is for mocking the client, mine is a nextjs project so by default it ends up using the trpc/next hoc, here in storybook we just use a plain react provider.
  • 2️⃣ the.query.path.for.your.schema is my specific schema... yours will be different.

@thathurtabit
Copy link

thathurtabit commented Mar 7, 2023

This is super helpful @airtonix !

My setup is also using TRPC and Storybook with Next - though I'm still confused with something here: what exactly is <TrpcProvider> as you've mentioned it will probably use withTRPC hoc? I.e. my _app.tsx has trpc.withTRPC(MyApp) - but not a <ContextWrapper>?

I'm getting: Cannot destructure property 'abortOnUnmount' of 'useContext(...)' as it is null. in my Stories, so I'm obviously missing some of the trpc context it needs.

Edit: just spotted this ...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment