Skip to content

Commit 923d080

Browse files
committed
chore: revamp tests
1 parent 38ba3b2 commit 923d080

File tree

2 files changed

+133
-67
lines changed

2 files changed

+133
-67
lines changed

site/src/pages/CreateWorkspacePage/useWorkspaceDuplication.test.tsx

+35-51
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,36 @@
1-
import { waitFor, screen } from "@testing-library/react";
2-
import userEvent from "@testing-library/user-event";
3-
import { createMemoryRouter } from "react-router-dom";
4-
import { renderWithRouter } from "testHelpers/renderHelpers";
5-
1+
import { waitFor } from "@testing-library/react";
62
import * as M from "../../testHelpers/entities";
73
import { type Workspace } from "api/typesGenerated";
84
import { useWorkspaceDuplication } from "./useWorkspaceDuplication";
95
import { MockWorkspace } from "testHelpers/entities";
106
import CreateWorkspacePage from "./CreateWorkspacePage";
7+
import { renderHookWithAuth } from "testHelpers/renderHelpers";
118

12-
// Tried really hard to get these tests working with RTL's renderHook, but I
13-
// kept running into weird function mismatches, mostly stemming from the fact
14-
// that React Router's RouteProvider does not accept children, meaning that you
15-
// can't inject values into it with renderHook's wrapper
16-
function WorkspaceMock({ workspace }: { workspace?: Workspace }) {
17-
const { duplicateWorkspace, isDuplicationReady } =
18-
useWorkspaceDuplication(workspace);
19-
20-
return (
21-
<button onClick={duplicateWorkspace} disabled={!isDuplicationReady}>
22-
Click me!
23-
</button>
24-
);
25-
}
26-
27-
function renderInMemory(workspace?: Workspace) {
28-
const router = createMemoryRouter([
29-
{ path: "/", element: <WorkspaceMock workspace={workspace} /> },
9+
function render(workspace?: Workspace) {
10+
return renderHookWithAuth(
11+
({ workspace }: { workspace?: Workspace }) => {
12+
return useWorkspaceDuplication(workspace);
13+
},
3014
{
31-
path: "/templates/:template/workspace",
32-
element: <CreateWorkspacePage />,
15+
initialProps: { workspace },
16+
extraRoutes: [
17+
{
18+
path: "/templates/:template/workspace",
19+
element: <CreateWorkspacePage />,
20+
},
21+
],
3322
},
34-
]);
35-
36-
return renderWithRouter(router);
23+
);
3724
}
3825

26+
type RenderResult = Awaited<ReturnType<typeof render>>;
27+
3928
async function performNavigation(
40-
button: HTMLElement,
41-
router: ReturnType<typeof createMemoryRouter>,
29+
result: RenderResult["result"],
30+
router: RenderResult["router"],
4231
) {
43-
await waitFor(() => expect(button).not.toBeDisabled());
44-
await userEvent.click(button);
32+
await waitFor(() => expect(result.current.isDuplicationReady).toBe(true));
33+
result.current.duplicateWorkspace();
4534

4635
return waitFor(() => {
4736
expect(router.state.location.pathname).toEqual(
@@ -52,34 +41,30 @@ async function performNavigation(
5241

5342
describe(`${useWorkspaceDuplication.name}`, () => {
5443
it("Will never be ready when there is no workspace passed in", async () => {
55-
const { rootComponent, rerender } = renderInMemory(undefined);
56-
const button = await screen.findByRole("button");
57-
expect(button).toBeDisabled();
44+
const { result, rerender } = await render(undefined);
45+
expect(result.current.isDuplicationReady).toBe(false);
5846

5947
for (let i = 0; i < 10; i++) {
60-
rerender(rootComponent);
61-
expect(button).toBeDisabled();
48+
rerender({ workspace: undefined });
49+
expect(result.current.isDuplicationReady).toBe(false);
6250
}
6351
});
6452

6553
it("Will become ready when workspace is provided and build params are successfully fetched", async () => {
66-
renderInMemory(MockWorkspace);
67-
const button = await screen.findByRole("button");
54+
const { result } = await render(MockWorkspace);
6855

69-
expect(button).toBeDisabled();
70-
await waitFor(() => expect(button).not.toBeDisabled());
56+
expect(result.current.isDuplicationReady).toBe(false);
57+
await waitFor(() => expect(result.current.isDuplicationReady).toBe(true));
7158
});
7259

73-
it("duplicateWorkspace navigates the user to the workspace creation page", async () => {
74-
const { router } = renderInMemory(MockWorkspace);
75-
const button = await screen.findByRole("button");
76-
await performNavigation(button, router);
60+
it("Is able to navigate the user to the workspace creation page", async () => {
61+
const { result, router } = await render(MockWorkspace);
62+
await performNavigation(result, router);
7763
});
7864

7965
test("Navigating populates the URL search params with the workspace's build params", async () => {
80-
const { router } = renderInMemory(MockWorkspace);
81-
const button = await screen.findByRole("button");
82-
await performNavigation(button, router);
66+
const { result, router } = await render(MockWorkspace);
67+
await performNavigation(result, router);
8368

8469
const parsedParams = new URLSearchParams(router.state.location.search);
8570
const mockBuildParams = [
@@ -97,9 +82,8 @@ describe(`${useWorkspaceDuplication.name}`, () => {
9782
});
9883

9984
test("Navigating appends other necessary metadata to the search params", async () => {
100-
const { router } = renderInMemory(MockWorkspace);
101-
const button = await screen.findByRole("button");
102-
await performNavigation(button, router);
85+
const { result, router } = await render(MockWorkspace);
86+
await performNavigation(result, router);
10387

10488
const parsedParams = new URLSearchParams(router.state.location.search);
10589
const extraMetadataEntries = [

site/src/testHelpers/renderHelpers.tsx

+98-16
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import { render as tlRender, screen, waitFor } from "@testing-library/react";
1+
import {
2+
render as tlRender,
3+
screen,
4+
waitFor,
5+
renderHook,
6+
} from "@testing-library/react";
27
import { AppProviders, ThemeProviders } from "App";
38
import { DashboardLayout } from "components/Dashboard/DashboardLayout";
49
import { TemplateSettingsLayout } from "pages/TemplateSettingsPage/TemplateSettingsLayout";
@@ -10,15 +15,13 @@ import {
1015
} from "react-router-dom";
1116
import { RequireAuth } from "../components/RequireAuth/RequireAuth";
1217
import { MockUser } from "./entities";
13-
import { ReactNode } from "react";
18+
import { ReactNode, useState } from "react";
1419
import { QueryClient } from "react-query";
1520

16-
export const renderWithRouter = (
17-
router: ReturnType<typeof createMemoryRouter>,
18-
) => {
19-
// Create one query client for each render isolate it avoid other
20-
// tests to be affected
21-
const queryClient = new QueryClient({
21+
function createTestQueryClient() {
22+
// Helps create one query client for each test case, to make sure that tests
23+
// are isolated and can't affect each other
24+
return new QueryClient({
2225
defaultOptions: {
2326
queries: {
2427
retry: false,
@@ -28,16 +31,19 @@ export const renderWithRouter = (
2831
},
2932
},
3033
});
34+
}
3135

32-
const rootComponent = (
33-
<AppProviders queryClient={queryClient}>
34-
<RouterProvider router={router} />
35-
</AppProviders>
36-
);
36+
export const renderWithRouter = (
37+
router: ReturnType<typeof createMemoryRouter>,
38+
) => {
39+
const queryClient = createTestQueryClient();
3740

3841
return {
39-
...tlRender(rootComponent),
40-
rootComponent,
42+
...tlRender(
43+
<AppProviders queryClient={queryClient}>
44+
<RouterProvider router={router} />
45+
</AppProviders>,
46+
),
4147
router,
4248
};
4349
};
@@ -56,7 +62,7 @@ export const render = (element: ReactNode) => {
5662
);
5763
};
5864

59-
type RenderWithAuthOptions = {
65+
export type RenderWithAuthOptions = {
6066
// The current URL, /workspaces/123
6167
route?: string;
6268
// The route path, /workspaces/:workspaceId
@@ -98,6 +104,82 @@ export function renderWithAuth(
98104
};
99105
}
100106

107+
type RenderHookWithAuthOptions<Props> = Partial<
108+
Readonly<
109+
Omit<RenderWithAuthOptions, "children"> & {
110+
initialProps: Props;
111+
}
112+
>
113+
>;
114+
115+
/**
116+
* Custom version of renderHook that is aware of all our App providers.
117+
*
118+
* Had to do some nasty, cursed things in the implementation to make sure that
119+
* the tests using this function remained simple.
120+
*
121+
* @see {@link https://github.com/coder/coder/pull/10362#discussion_r1380852725}
122+
*/
123+
export async function renderHookWithAuth<Result, Props>(
124+
render: (initialProps: Props) => Result,
125+
{
126+
initialProps,
127+
path = "/",
128+
extraRoutes = [],
129+
}: RenderHookWithAuthOptions<Props> = {},
130+
) {
131+
const queryClient = createTestQueryClient();
132+
133+
// Easy to miss – there's an evil definite assignment via the !
134+
let escapedRouter!: ReturnType<typeof createMemoryRouter>;
135+
136+
const { result, rerender, unmount } = renderHook(render, {
137+
initialProps,
138+
wrapper: ({ children }) => {
139+
/**
140+
* Unfortunately, there isn't a way to define the router outside the
141+
* wrapper while keeping it aware of children, meaning that we need to
142+
* define the router as readonly state in the component instance. This
143+
* ensures the value remains stable across all re-renders
144+
*/
145+
// eslint-disable-next-line react-hooks/rules-of-hooks -- This is actually processed as a component; the linter just isn't aware of that
146+
const [readonlyStatefulRouter] = useState(() => {
147+
return createMemoryRouter([
148+
{ path, element: <>{children}</> },
149+
...extraRoutes,
150+
]);
151+
});
152+
153+
/**
154+
* Leaks the wrapper component's state outside React's render cycles.
155+
*/
156+
escapedRouter = readonlyStatefulRouter;
157+
158+
return (
159+
<AppProviders queryClient={queryClient}>
160+
<RouterProvider router={readonlyStatefulRouter} />
161+
</AppProviders>
162+
);
163+
},
164+
});
165+
166+
/**
167+
* This is necessary to get around some providers in AppProviders having
168+
* conditional rendering and not always rendering their children immediately.
169+
*
170+
* The hook result won't actually exist until the children defined via wrapper
171+
* render in full.
172+
*/
173+
await waitFor(() => expect(result.current).not.toBe(null));
174+
175+
return {
176+
result,
177+
rerender,
178+
unmount,
179+
router: escapedRouter,
180+
} as const;
181+
}
182+
101183
export function renderWithTemplateSettingsLayout(
102184
element: JSX.Element,
103185
{

0 commit comments

Comments
 (0)