Skip to content
Merged
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
12 changes: 10 additions & 2 deletions docs/development/testing-playbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +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:<project>` is the stable CI-facing entrypoint for every package; the root dispatcher chooses the correct runner per project.
- `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.

## Retained Jest Layout

Expand Down Expand Up @@ -116,6 +116,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
10 changes: 5 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,12 @@
"lint:fix": "biome check --write .",
"format": "biome format --write .",
"format:check": "biome format .",
"test": "bun packages/scripts/src/testing/run.ts",
"test": "bun test:core && bun test:web && bun test:backend && bun test:scripts",
"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:scripts": "bun packages/scripts/src/testing/run.ts scripts",
"test:backend": "./node_modules/.bin/jest --selectProjects backend",
"test:core": "bun test packages/core/src --preload packages/scripts/src/testing/core.preload.ts",
"test:web": "bun test --cwd packages/web",
"test:scripts": "./node_modules/.bin/jest 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",
"verify": "bun packages/scripts/src/testing/verify.ts",
Expand Down
128 changes: 0 additions & 128 deletions packages/scripts/src/testing/run.ts

This file was deleted.

4 changes: 2 additions & 2 deletions packages/scripts/src/testing/verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { execSync } from "node:child_process";
* Detects which packages changed via git diff and runs the minimum
* necessary test suites plus type-check.
*
* All test execution is delegated to run.ts — no commands are duplicated here.
* Test execution is delegated to the root `test:<project>` package.json scripts.
*
* Usage:
* bun run verify — auto-detect from git diff
Expand Down Expand Up @@ -79,7 +79,7 @@ function mapFilesToPackages(files: string[]): Package[] {
function runPackage(pkg: Package): boolean {
console.log(`\n→ test:${pkg}`);
const result = bunRuntime.spawnSync({
cmd: ["bun", "packages/scripts/src/testing/run.ts", pkg],
cmd: ["bun", "run", `test:${pkg}`],
cwd: process.cwd(),
env: {
...process.env,
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