T
TanStack•15mo ago
foreign-sapphire

How to make a unit testing helper?

I'm trying to come up with a reusable unit testing helper that wraps components in the necessary RouteTree + QueryClientProvider. However I'm finding that hooks like useParams are not returning expected results, so I can tell I'm not getting it totally right. The issue seems to be that the params object is returning a key of ** instead of workflowId, although the value for the param is actually right. I've created a basic reproduction in StackBlitz @ https://stackblitz.com/edit/vitejs-vite-5wm5gn?file=src%2FWorkflows.spec.tsx. You can run the tests via npm run test in the terminal. For those who dont want to check out the stackblitz, I'm using a test helper which is almost a direct copy-paste of one shared previously in this Discord @ https://discord.com/channels/719702312431386674/1235917869620133938/1236040157506043954 . I'll put the code in the comment below this one due to Discord character limits. In general, if someone has a viable render setup that works for unit testing components and enabling all the TSR hooks I'd love to see how you're solving that. All of the example tests I found in the TSR GitHub (eg https://github.com/TanStack/router/blob/main/packages/react-router/tests/link.test.tsx#L840) involve manually creating a route tree for each test, which seems like a sizable amount of boilerplate for devs to write in every test file
No description
12 Replies
foreign-sapphire
foreign-sapphireOP•15mo ago
helper code:
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});

interface TestOptions {
childRoutePaths?: string[];
initialEntries?: string[];
}

function createChildRoute(
parent: AnyRoute,
currentRoute: string,
childRoutes: string[]
) {
const isLayout = currentRoute.startsWith('_');
let route;

if (isLayout) {
route = createRoute({
getParentRoute: () => parent,
id: currentRoute,
});
} else {
route = createRoute({
getParentRoute: () => parent,
path: currentRoute,
});
}

const childRoute = childRoutes[0]!; // TODO: this probably isnt type safe
const newChildRoutes = childRoutes.slice(1);

if (newChildRoutes.length > 0) {
route.addChildren([createChildRoute(route, childRoute, newChildRoutes)]);
}

return route;
}

export const renderWithTanStack = async (
children: ReactNode,
options?: TestOptions
) => {
const { childRoutePaths, initialEntries } = options ?? {};

const root = createRootRouteWithContext<{
queryClient: QueryClient;
}>()({
component: () => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
),
});

if (childRoutePaths) {
const routes = childRoutePaths.map((childRoutePath) => {
const routes = childRoutePath.split('/').filter((path) => path !== '');

const currentRoute = routes[0];
const childRoutes = routes.slice(1);
return createChildRoute(root, currentRoute, childRoutes);
});

root.addChildren(routes);
}

const router = createRouter({
context: {
queryClient,
},
history: createMemoryHistory({
initialEntries: initialEntries ?? ['/'],
}),
routeTree: root,
});

await router.load();

return render(<RouterProvider router={router} />);
};
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});

interface TestOptions {
childRoutePaths?: string[];
initialEntries?: string[];
}

function createChildRoute(
parent: AnyRoute,
currentRoute: string,
childRoutes: string[]
) {
const isLayout = currentRoute.startsWith('_');
let route;

if (isLayout) {
route = createRoute({
getParentRoute: () => parent,
id: currentRoute,
});
} else {
route = createRoute({
getParentRoute: () => parent,
path: currentRoute,
});
}

const childRoute = childRoutes[0]!; // TODO: this probably isnt type safe
const newChildRoutes = childRoutes.slice(1);

if (newChildRoutes.length > 0) {
route.addChildren([createChildRoute(route, childRoute, newChildRoutes)]);
}

return route;
}

export const renderWithTanStack = async (
children: ReactNode,
options?: TestOptions
) => {
const { childRoutePaths, initialEntries } = options ?? {};

const root = createRootRouteWithContext<{
queryClient: QueryClient;
}>()({
component: () => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
),
});

if (childRoutePaths) {
const routes = childRoutePaths.map((childRoutePath) => {
const routes = childRoutePath.split('/').filter((path) => path !== '');

const currentRoute = routes[0];
const childRoutes = routes.slice(1);
return createChildRoute(root, currentRoute, childRoutes);
});

root.addChildren(routes);
}

const router = createRouter({
context: {
queryClient,
},
history: createMemoryHistory({
initialEntries: initialEntries ?? ['/'],
}),
routeTree: root,
});

await router.load();

return render(<RouterProvider router={router} />);
};
metropolitan-bronze
metropolitan-bronze•15mo ago
Looking at your helper, you aren't taking the returned route-tree from root.addChildren() A typical router definition looks like this.
const rootRoute = createRootRouteWithContext<{ qc: QueryClient }>()({
component: () => {
return (
<div>
<div>Root</div>
<Outlet />
</div>
}
})
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
component: () => "Index Route"
})

const routeTree = rootRoute.addChildren([indexRoute])

const router = createRouter({ routeTree })
const rootRoute = createRootRouteWithContext<{ qc: QueryClient }>()({
component: () => {
return (
<div>
<div>Root</div>
<Outlet />
</div>
}
})
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
component: () => "Index Route"
})

const routeTree = rootRoute.addChildren([indexRoute])

const router = createRouter({ routeTree })
Your route-tree is the returned value of rootRoute.addChildren. You may want to first get your helper working in a way that you are generating your route-tree the correct way.
foreign-sapphire
foreign-sapphireOP•15mo ago
Hey @Sean Cassiere , thanks for looking at this on a Sunday. If Im understanding you right, the only change necessary here is captured in this git diff (Ive also updated the original StackBlitz with the change). Even with this change, I still see the same issue with params from useParams in my component logging as {"**": "4"} Im also reviewing the createChildRoute helper to see if I have any obvious problems there since its mostly copy-pasted from the previous Discord post about this topic. Havent found any obvious issue yet, but Im also not experienced enough with TSR to be sure
No description
metropolitan-bronze
metropolitan-bronze•15mo ago
If I'm not mistaken ** identifier is a possible splat which means you don't have a route that's configured for the useParams caller. Also, the let value should be let routeTree = root.addChildren([])
foreign-sapphire
foreign-sapphireOP•15mo ago
It looks like that might be a factor here, but I'd be confused why its showing as no route being configured for the component invoking useParams . I wonder if maybe the createChildRoute helper needs to include something like component: ({children}) => <div>{children}</div> for all of the fake parent paths other than the final path that is meant to render the component being tested. Let me try that out in the stackblitz
metropolitan-bronze
metropolitan-bronze•15mo ago
Oh I also noticed that your rootRoute doesn't ever render the <Outlet /> component
foreign-sapphire
foreign-sapphireOP•15mo ago
Yeah I was thinking thatd be a problem as well when I was reading the other person's original version of this code. Im working on a local refactor to use the Outlet and update the createChildRoute helper to hopefully make that work as expected. Will update the stackblitz + this thread when Ive got that knocked out
metropolitan-bronze
metropolitan-bronze•15mo ago
Cool stuff, best of luck I played around with this and found out that happy-dom was being abit wonky. Using jsdom worked. You may want to try substituting it and giving your original example a shot.
metropolitan-bronze
metropolitan-bronze•15mo ago
I only found out the happy-dom thing later on though. I've used a bit of recursion and got this working. I haven't implemented layout routes, but currently that "Workflow ID is 4" test does pass. https://github.com/SeanCassiere/super-duper-octo-barnacle/blob/master/src/renderWithTanstack.tsx
GitHub
super-duper-octo-barnacle/src/renderWithTanstack.tsx at master · Se...
Contribute to SeanCassiere/super-duper-octo-barnacle development by creating an account on GitHub.
metropolitan-bronze
metropolitan-bronze•15mo ago
Nvm, i got layout routes also working
foreign-sapphire
foreign-sapphireOP•15mo ago
You're a real one Sean, just sent you a small donation on GH for your help 🫡 I did wind up making a few small tweaks and it seemed to work fine with happy-dom. Not really sure whether jsdom would make a meaningful difference, but all I changed was adding an await router.load() before invoking the return render(<RouterProvider router={router} />) because I was seeing a React error in the console about a state update not being wrapped in act. This did require adding an act and await in the test itself, but this seemed to be necessary to remove the warning. Its possible theres a better solution here and I just didnt dig deep enough. --- Unrelated to my original post since your solution seems to work fine for everything Ive tested so far, but would it make sense for the Tanstack team to add something like your helper into the Guides (similar to what Redux has https://redux.js.org/usage/writing-tests#setting-up-a-reusable-test-render-function) or create a dedicated package for it (similar to the Remix testing package https://remix.run/docs/en/main/other-api/testing )? When I was initially diving into this testing work I spent a decent amount of time reading through the docs, github issues, and the discord history to try and find a reusable solution but was surprised that there wasn't really any official solution. I know integration/e2e testing in a browser is usually preferable, but unit testing w/ Vite or Jest definitely has its place and it seems like a recommended solution would help the community
metropolitan-bronze
metropolitan-bronze•15mo ago
Thanks! The issue would be the type-safety sides of things. Plus having to worry about jest vs vitest vs chai vs etc... for the time being I don't think we'll be adding it, but I'll link others to this when they ask

Did you find this page helpful?