Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion docs/development/testing-playbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ E2E workflow (`test-e2e.yml`) is separate and runs on pull requests to `main` vi
## Current Test Strategy

- `bun run test:core` uses `bun test` with a small compatibility preload for the core BSON mock setup.
- `bun run test:web`, `bun run test:backend`, and `bun run test:scripts` intentionally retain the existing Jest harness while their hoist-heavy module-mocking patterns are migrated.
- `bun run test:web` runs `bun test --cwd packages/web` directly. Web tests should be isolated enough to run in one Bun process without batching.
- `bun run test:backend` and `bun run test:scripts` intentionally retain the existing Jest harness while their hoist-heavy module-mocking patterns are migrated.
- `bun run test:<project>` is the stable CI-facing entrypoint for every package; the root dispatcher chooses the correct runner per project.

## Retained Jest Layout
Expand Down Expand Up @@ -116,6 +117,14 @@ Avoid:
- implementation-detail assertions
- unnecessary module-wide mocks

Isolation rules:

- Do not use top-level `mock.module` for shared production modules unless the test imports the subject through a local factory and the mock cannot affect later files. Prefer provider wrappers, real stores, explicit dependency factories, or `spyOn` with teardown.
- Avoid mocking shared UI primitives such as `TooltipWrapper`, `@floating-ui/react`, or session hooks in broad component tests. A mock that only helps one file can change unrelated tests later in the same Bun process.
- If a test replaces globals (`fetch`, `document.getElementById`, storage, timers, console methods), restore the original value in teardown.
- Prefer `renderWithStore`, `createStoreWrapper`, or a focused provider harness over mocking `@web/store` or `store.hooks`.
- `bun test --cwd packages/web` is the acceptance check for web test isolation. A focused test can pass while still leaking into the direct suite.

### Web Jest Harness Defaults (MSW + Globals)

Primary setup files:
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
"test:e2e": "bunx playwright test",
"test:backend": "bun packages/scripts/src/testing/run.ts backend",
"test:core": "bun packages/scripts/src/testing/run.ts core",
"test:web": "bun packages/scripts/src/testing/run.ts web",
"test:web": "bun test --cwd packages/web",
"test:scripts": "bun packages/scripts/src/testing/run.ts scripts",
"type-check": "bunx typescript@6.0.3 --noEmit && bunx typescript@6.0.3 -p packages/web/tsconfig.app.json --noEmit && bun run type-check:web-tests",
"type-check:web-tests": "bunx typescript@6.0.3 -p packages/web/tsconfig.test.json --noEmit",
Expand Down
39 changes: 3 additions & 36 deletions packages/scripts/src/testing/run.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { existsSync, readdirSync, statSync } from "node:fs";
import { relative, resolve } from "node:path";
import { existsSync } from "node:fs";
import { resolve } from "node:path";

type BunRuntime = {
spawnSync(input: {
Expand All @@ -18,9 +18,6 @@ type ProjectConfig = {
};

const bunRuntime = (globalThis as unknown as { Bun: BunRuntime }).Bun;
const WEB_ROOT = resolve(process.cwd(), "packages/web");
const WEB_SRC = resolve(WEB_ROOT, "src");
const WEB_TEST_FILE_PATTERN = /\.(spec|test)\.[tj]sx?$/;

const TEST_PROJECTS = {
backend: {
Expand All @@ -39,29 +36,10 @@ const TEST_PROJECTS = {
cmd: ["./node_modules/.bin/jest", "scripts"],
},
web: {
cmd: [],
cmd: ["bun", "test", "--cwd", "packages/web"],
},
} satisfies Record<string, ProjectConfig>;

function findWebTestFiles(dir: string): string[] {
return readdirSync(dir)
.flatMap((entry) => {
const path = resolve(dir, entry);
const stats = statSync(path);

if (stats.isDirectory()) {
return findWebTestFiles(path);
}

if (!WEB_TEST_FILE_PATTERN.test(entry)) {
return [];
}

return relative(WEB_ROOT, path);
})
.sort();
}

function assertBackendConfigFile() {
const configFilePath = resolve(process.cwd(), "compass.yaml");

Expand Down Expand Up @@ -91,18 +69,7 @@ function runCommand(cmd: string[], cwd = process.cwd()) {
}
}

function runWebProject() {
for (const testFile of findWebTestFiles(WEB_SRC)) {
runCommand([process.execPath, "test", "--cwd", WEB_ROOT, testFile]);
}
}

function runProject(projectName: keyof typeof TEST_PROJECTS) {
if (projectName === "web") {
runWebProject();
return;
}

runCommand(TEST_PROJECTS[projectName].cmd);
}

Expand Down
59 changes: 59 additions & 0 deletions packages/web/src/__tests__/render-with-store.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { configureStore, type PreloadedState } from "@reduxjs/toolkit";
import {
type RenderHookOptions,
render,
renderHook,
} from "@testing-library/react";
import { type PropsWithChildren, type ReactElement } from "react";
import { Provider } from "react-redux";
import { sagaMiddleware } from "@web/common/store/middlewares";
import { type RootState } from "@web/store";
import { reducers } from "@web/store/reducers";

export function createTestStore(preloadedState?: PreloadedState<RootState>) {
return configureStore({
reducer: reducers,
preloadedState,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
thunk: false,
serializableCheck: false,
immutableCheck: false,
}).concat(sagaMiddleware),
});
}

export function createStoreWrapper(preloadedState?: PreloadedState<RootState>) {
const store = createTestStore(preloadedState);

function StoreWrapper({ children }: PropsWithChildren) {
return <Provider store={store}>{children}</Provider>;
}

return { store, wrapper: StoreWrapper };
}

export function renderWithStore(
ui: ReactElement,
preloadedState?: PreloadedState<RootState>,
) {
const { store, wrapper } = createStoreWrapper(preloadedState);

return {
store,
...render(ui, { wrapper }),
};
}

export function renderHookWithStore<Result, Props>(
hook: (initialProps: Props) => Result,
preloadedState?: PreloadedState<RootState>,
options?: Omit<RenderHookOptions<Props>, "wrapper">,
) {
const { store, wrapper } = createStoreWrapper(preloadedState);

return {
store,
...renderHook(hook, { ...options, wrapper }),
};
}
28 changes: 28 additions & 0 deletions packages/web/src/__tests__/web.preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,32 @@ mockNodeModules();
const sessionModule = await import("supertokens-web-js/recipe/session");
const { cleanup } = await import("@testing-library/react");

function resetDocument() {
document.body.innerHTML = "";
document.body.removeAttribute("style");
document.body.removeAttribute("class");
document.body.removeAttribute("data-app-locked");
document.documentElement.removeAttribute("style");
for (const style of document.head.querySelectorAll("style")) {
if (
!style.textContent?.includes(":has(.react-datepicker__day--selected)")
) {
continue;
}

style.textContent = style.textContent.replaceAll(
/[^{}]+:has\(\.react-datepicker__day--selected\)[^{]*\{[^{}]*\}/g,
"",
);
}
}

function resetBrowserState() {
dom.reconfigure({ url: "http://localhost/" });
localStorage.clear();
sessionStorage.clear();
}

beforeEach(() => {
sessionModule.doesSessionExist?.mockResolvedValue(true);
});
Expand All @@ -282,6 +308,8 @@ beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(async () => {
await Promise.resolve();
cleanup();
resetDocument();
resetBrowserState();
server.resetHandlers();
});
afterAll(() => server.close());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
type Dispatch = (action: unknown) => unknown;

export type CompleteAuthenticationDependencies = {
authSuccess: () => unknown;
clearAnonymousCalendarChangeSignUpPrompt: () => void;
markUserAsAuthenticated: (email?: string) => void;
refreshUserMetadata: () => Promise<unknown> | unknown;
syncPendingLocalEvents: () => Promise<unknown>;
triggerFetch: () => unknown;
useAppDispatch: () => Dispatch;
useSession: () => {
setAuthenticated: (isAuthenticated: boolean) => void;
};
};

export function createUseCompleteAuthentication(
dependencies: CompleteAuthenticationDependencies,
) {
return function useCompleteAuthenticationWithDependencies() {
const dispatch = dependencies.useAppDispatch();
const { setAuthenticated } = dependencies.useSession();

return async ({
email,
onComplete,
}: {
email?: string;
onComplete?: () => void;
}) => {
dependencies.clearAnonymousCalendarChangeSignUpPrompt();
dependencies.markUserAsAuthenticated(email);
setAuthenticated(true);
dispatch(dependencies.authSuccess());

void dependencies.refreshUserMetadata();

await dependencies.syncPendingLocalEvents();

dispatch(dependencies.triggerFetch());
onComplete?.();
};
};
}
Loading
Loading