BATSHIT_REPORT.md: full context on bulk query batching — business goal, approach, all changes, verification status, and how to run. FRONTEND_TEST_RESEARCH.md: research on unit testing react-query hooks with jest.mock, renderHook, and batcher testing patterns.
16 KiB
Frontend Hook Testing Research
Context
Hooks in www/app/lib/apiHooks.ts use two patterns:
$api.useQuery("get", "/v1/rooms", ...)-- openapi-react-query wrapping openapi-fetchuseQuery({ queryFn: () => meetingStatusBatcher.fetch(roomName!) })-- plain react-query with batshit batcher
Key dependencies: module-level client and $api in apiClient.tsx, module-level meetingStatusBatcher in meetingStatusBatcher.ts, useAuth() context, useError() context.
1. Recommended Mocking Strategy
Use jest.mock() on the module-level clients + renderHook from @testing-library/react.
Reasons against MSW:
openapi-fetchcreates itsclientat module import time. MSW'sserver.listen()must run before the client is created (see openapi-ts/openapi-typescript#1878). This requires dynamic imports or Proxy workarounds -- fragile.- MSW adds infrastructure overhead (handlers, server lifecycle) for unit tests that only need to verify hook behavior.
- The batcher calls
client.POST(...)directly, notfetch(). Mocking the client is more direct.
Reasons against openapi-fetch-mock middleware:
- Requires
client.use()/client.eject()per test -- less isolated than module mock. - Our
clientalready has auth middleware; test middleware ordering gets messy.
Winner: Direct module mocking with jest.mock().
Pattern for $api hooks
Mock the entire apiClient module. The $api.useQuery / $api.useMutation from openapi-react-query are just wrappers around react-query that call client.GET, client.POST, etc. Mock at the client method level:
// __mocks__ or inline
jest.mock("../apiClient", () => {
const mockClient = {
GET: jest.fn(),
POST: jest.fn(),
PUT: jest.fn(),
PATCH: jest.fn(),
DELETE: jest.fn(),
use: jest.fn(),
};
const createFetchClient = require("openapi-react-query").default;
return {
client: mockClient,
$api: createFetchClient(mockClient),
API_URL: "http://test",
WEBSOCKET_URL: "ws://test",
configureApiAuth: jest.fn(),
};
});
Problem: openapi-react-query calls client.GET(path, init) and expects { data, error, response }. So the mock must return that shape:
import { client } from "../apiClient";
const mockClient = client as jest.Mocked<typeof client>;
mockClient.GET.mockResolvedValue({
data: { items: [], total: 0 },
error: undefined,
response: new Response(),
});
Pattern for batcher hooks
Mock the batcher module:
jest.mock("../meetingStatusBatcher", () => ({
meetingStatusBatcher: {
fetch: jest.fn(),
},
}));
import { meetingStatusBatcher } from "../meetingStatusBatcher";
const mockBatcher = meetingStatusBatcher as jest.Mocked<typeof meetingStatusBatcher>;
mockBatcher.fetch.mockResolvedValue({
roomName: "test-room",
active_meetings: [{ id: "m1" }],
upcoming_events: [],
});
2. renderHook: Use @testing-library/react (NOT @testing-library/react-hooks)
@testing-library/react-hooks is deprecated since React 18. The renderHook function is now built into @testing-library/react v13+.
import { renderHook, waitFor } from "@testing-library/react";
Key differences from the old package:
- No
waitForNextUpdate-- usewaitFor(() => expect(...))instead - No separate
result.current.errorfor error boundaries - Works with React 18 concurrent features
3. Mocking/Intercepting openapi-fetch Client
Three viable approaches ranked by simplicity:
A. Mock the module (recommended)
As shown above. jest.mock("../apiClient") replaces the entire module. The $api wrapper can be reconstructed from the mock client using the real openapi-react-query factory, or you can mock $api methods directly.
Caveat: If you reconstruct $api from a mock client, $api.useQuery will actually call mockClient.GET(path, init). This is good -- it tests the integration between openapi-react-query and the client.
B. Inject mock fetch into client
const mockFetch = jest.fn().mockResolvedValue(
new Response(JSON.stringify({ data: "test" }), { status: 200 })
);
const testClient = createClient<paths>({ baseUrl: "http://test", fetch: mockFetch });
Problem: requires the hooks to accept the client as a parameter or use dependency injection. Our hooks use module-level $api -- doesn't work without refactoring.
C. MSW (not recommended for unit tests)
As discussed, the module-import-time client creation conflicts with MSW's server lifecycle. Workable but fragile.
4. Testing Batshit Batcher Behavior
Unit test the batcher directly (no hooks)
The batcher is a pure module -- test it without React:
import { create, keyResolver, windowScheduler } from "@yornaath/batshit";
test("batches multiple room queries into one POST", async () => {
const mockPost = jest.fn().mockResolvedValue({
data: {
"room-a": { active_meetings: [], upcoming_events: [] },
"room-b": { active_meetings: [], upcoming_events: [] },
},
});
const batcher = create({
fetcher: async (roomNames: string[]) => {
const unique = [...new Set(roomNames)];
const { data } = await mockPost("/v1/rooms/meetings/bulk-status", {
body: { room_names: unique },
});
return roomNames.map((name) => ({
roomName: name,
active_meetings: data?.[name]?.active_meetings ?? [],
upcoming_events: data?.[name]?.upcoming_events ?? [],
}));
},
resolver: keyResolver("roomName"),
scheduler: windowScheduler(10),
});
// Fire multiple fetches within the 10ms window
const [resultA, resultB] = await Promise.all([
batcher.fetch("room-a"),
batcher.fetch("room-b"),
]);
// Only one POST call
expect(mockPost).toHaveBeenCalledTimes(1);
expect(mockPost).toHaveBeenCalledWith("/v1/rooms/meetings/bulk-status", {
body: { room_names: ["room-a", "room-b"] },
});
expect(resultA.active_meetings).toEqual([]);
expect(resultB.active_meetings).toEqual([]);
});
test("deduplicates same room name", async () => {
const mockPost = jest.fn().mockResolvedValue({
data: { "room-a": { active_meetings: [{ id: "m1" }], upcoming_events: [] } },
});
const batcher = create({
fetcher: async (roomNames: string[]) => {
const unique = [...new Set(roomNames)];
const { data } = await mockPost("/v1/rooms/meetings/bulk-status", {
body: { room_names: unique },
});
return roomNames.map((name) => ({
roomName: name,
active_meetings: data?.[name]?.active_meetings ?? [],
upcoming_events: data?.[name]?.upcoming_events ?? [],
}));
},
resolver: keyResolver("roomName"),
scheduler: windowScheduler(10),
});
const [r1, r2] = await Promise.all([
batcher.fetch("room-a"),
batcher.fetch("room-a"),
]);
expect(mockPost).toHaveBeenCalledTimes(1);
// Both resolve to same data
expect(r1.active_meetings).toEqual([{ id: "m1" }]);
expect(r2.active_meetings).toEqual([{ id: "m1" }]);
});
Test the actual batcher module with mocked client
jest.mock("../apiClient", () => ({
client: { POST: jest.fn() },
}));
import { client } from "../apiClient";
import { meetingStatusBatcher } from "../meetingStatusBatcher";
const mockClient = client as jest.Mocked<typeof client>;
test("meetingStatusBatcher calls bulk-status endpoint", async () => {
mockClient.POST.mockResolvedValue({
data: { "room-x": { active_meetings: [], upcoming_events: [] } },
error: undefined,
response: new Response(),
});
const result = await meetingStatusBatcher.fetch("room-x");
expect(result.active_meetings).toEqual([]);
expect(mockClient.POST).toHaveBeenCalledWith(
"/v1/rooms/meetings/bulk-status",
expect.objectContaining({ body: { room_names: ["room-x"] } }),
);
});
5. Minimal Jest Setup
Install dependencies
cd www && pnpm add -D @testing-library/react @testing-library/jest-dom jest-environment-jsdom
Note: jest v30 and ts-jest are already in devDependencies. @types/jest v30 is already present. @testing-library/react v16 supports React 18.
jest.config.ts
import type { Config } from "jest";
const config: Config = {
testEnvironment: "jest-environment-jsdom",
transform: {
"^.+\\.tsx?$": ["ts-jest", {
tsconfig: "tsconfig.json",
}],
},
moduleNameMapper: {
// Handle module aliases if you add them later
// "^@/(.*)$": "<rootDir>/$1",
},
setupFilesAfterSetup: ["<rootDir>/jest.setup.ts"],
// Ignore Next.js build output
testPathIgnorePatterns: ["<rootDir>/.next/", "<rootDir>/node_modules/"],
};
export default config;
jest.setup.ts
import "@testing-library/jest-dom";
Mocking modules that fail in jsdom
The apiClient.tsx module calls getSession() and getClientEnv() at import time. These will fail in jsdom. Mock them:
// In test file or __mocks__
jest.mock("next-auth/react", () => ({
getSession: jest.fn().mockResolvedValue(null),
signIn: jest.fn(),
signOut: jest.fn(),
useSession: jest.fn().mockReturnValue({ data: null, status: "unauthenticated" }),
}));
jest.mock("../next", () => ({
isBuildPhase: false,
}));
jest.mock("../clientEnv", () => ({
getClientEnv: () => ({
API_URL: "http://test-api",
WEBSOCKET_URL: "ws://test-api",
FEATURE_REQUIRE_LOGIN: false,
FEATURE_PRIVACY: null,
FEATURE_BROWSE: null,
FEATURE_SEND_TO_ZULIP: null,
FEATURE_ROOMS: null,
}),
}));
6. Testing Hooks Without Prop-Based DI
The hooks use module-level $api and meetingStatusBatcher. No way to inject via props. Two approaches:
A. jest.mock() the modules (recommended)
Already shown. Works cleanly. Each test can mockResolvedValue differently.
B. Manual mock files (__mocks__/)
Create www/app/lib/__mocks__/apiClient.tsx:
const mockClient = {
GET: jest.fn(),
POST: jest.fn(),
PUT: jest.fn(),
PATCH: jest.fn(),
DELETE: jest.fn(),
use: jest.fn(),
};
// Use real openapi-react-query wrapping the mock client
const createFetchClient = jest.requireActual("openapi-react-query").default;
export const client = mockClient;
export const $api = createFetchClient(mockClient);
export const API_URL = "http://test";
export const WEBSOCKET_URL = "ws://test";
export const configureApiAuth = jest.fn();
Then in tests: jest.mock("../apiClient") with no factory -- picks up the __mocks__ file automatically.
7. Complete Example Test
// www/app/lib/__tests__/apiHooks.test.ts
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import React from "react";
// Mock modules before imports
jest.mock("next-auth/react", () => ({
getSession: jest.fn().mockResolvedValue(null),
signIn: jest.fn(),
signOut: jest.fn(),
useSession: jest.fn().mockReturnValue({
data: null,
status: "unauthenticated",
}),
}));
jest.mock("../clientEnv", () => ({
getClientEnv: () => ({
API_URL: "http://test",
WEBSOCKET_URL: "ws://test",
FEATURE_REQUIRE_LOGIN: false,
FEATURE_PRIVACY: null,
FEATURE_BROWSE: null,
FEATURE_SEND_TO_ZULIP: null,
FEATURE_ROOMS: null,
}),
}));
jest.mock("../next", () => ({ isBuildPhase: false }));
jest.mock("../meetingStatusBatcher", () => ({
meetingStatusBatcher: { fetch: jest.fn() },
}));
// Must mock apiClient BEFORE importing hooks
jest.mock("../apiClient", () => {
const mockClient = {
GET: jest.fn(),
POST: jest.fn(),
PATCH: jest.fn(),
DELETE: jest.fn(),
use: jest.fn(),
};
const createFetchClient =
jest.requireActual("openapi-react-query").default;
return {
client: mockClient,
$api: createFetchClient(mockClient),
API_URL: "http://test",
WEBSOCKET_URL: "ws://test",
configureApiAuth: jest.fn(),
};
});
// Now import the hooks under test
import { useRoomActiveMeetings, useTranscriptsSearch } from "../apiHooks";
import { client } from "../apiClient";
import { meetingStatusBatcher } from "../meetingStatusBatcher";
import { ErrorProvider } from "../../(errors)/errorContext";
const mockClient = client as jest.Mocked<typeof client>;
const mockBatcher = meetingStatusBatcher as { fetch: jest.Mock };
// Wrapper with required providers
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
return function Wrapper({ children }: { children: React.ReactNode }) {
return React.createElement(
QueryClientProvider,
{ client: queryClient },
React.createElement(ErrorProvider, null, children),
);
};
}
describe("useRoomActiveMeetings", () => {
afterEach(() => jest.clearAllMocks());
it("returns active meetings from batcher", async () => {
mockBatcher.fetch.mockResolvedValue({
roomName: "test-room",
active_meetings: [{ id: "m1", room_name: "test-room" }],
upcoming_events: [],
});
const { result } = renderHook(
() => useRoomActiveMeetings("test-room"),
{ wrapper: createWrapper() },
);
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual([
{ id: "m1", room_name: "test-room" },
]);
expect(mockBatcher.fetch).toHaveBeenCalledWith("test-room");
});
it("is disabled when roomName is null", () => {
const { result } = renderHook(
() => useRoomActiveMeetings(null),
{ wrapper: createWrapper() },
);
expect(result.current.fetchStatus).toBe("idle");
expect(mockBatcher.fetch).not.toHaveBeenCalled();
});
});
describe("useTranscriptsSearch", () => {
afterEach(() => jest.clearAllMocks());
it("fetches transcripts via $api", async () => {
mockClient.GET.mockResolvedValue({
data: { items: [{ id: "t1", title: "Test" }], total: 1 },
error: undefined,
response: new Response(),
});
const { result } = renderHook(
() => useTranscriptsSearch("hello"),
{ wrapper: createWrapper() },
);
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual({
items: [{ id: "t1", title: "Test" }],
total: 1,
});
expect(mockClient.GET).toHaveBeenCalledWith(
"/v1/transcripts/search",
expect.objectContaining({
params: expect.objectContaining({
query: expect.objectContaining({ q: "hello" }),
}),
}),
);
});
});
8. Summary of Decisions
| Question | Answer |
|---|---|
| Mocking approach | jest.mock() on module-level clients |
| renderHook source | @testing-library/react (not deprecated hooks lib) |
| Intercept openapi-fetch | Mock client.GET/POST/... methods, reconstruct $api with real openapi-react-query |
| Test batcher | Unit test batcher directly with mock POST fn; test hooks with mock batcher module |
| Auth context | Mock next-auth/react, disable requireLogin feature flag |
| Error context | Wrap with real ErrorProvider (it's simple state) |
| QueryClient | New instance per test, retry: false |
New packages needed
cd www && pnpm add -D @testing-library/react @testing-library/jest-dom @testing-library/dom jest-environment-jsdom
Files to create
www/jest.config.ts-- jest configurationwww/jest.setup.ts--import "@testing-library/jest-dom"www/app/lib/__tests__/apiHooks.test.ts-- hook testswww/app/lib/__tests__/meetingStatusBatcher.test.ts-- batcher unit tests
Potential issues
ts-jestv29 may not fully support Jest 30. Watch for compatibility errors. May needts-jest@nextor switch to@swc/jest.openapi-react-queryimports may need ESM handling in Jest. Ifjest.requireActual("openapi-react-query")fails, mock$apimethods directly instead of reconstructing."use client"directive at top ofapiHooks.ts/apiClient.tsx-- Jest ignores this (it's a no-op outside Next.js bundler), but verify it doesn't cause parse errors.