Testing Playbook
Use the smallest test surface that can fail for the change you are making, then widen only if the change crosses boundaries.
Main Commands
From repo root:
bun run test:core
bun run test:web
bun run test:backend
bun run test:scripts
bun run type-check
Avoid defaulting to bun run test unless you really need the full suite.
CI Unit Test Workflow
Source of truth:
.github/workflows/test-unit.yml.github/workflows/test-e2e.yml
Unit workflow (test-unit.yml):
- triggers on
push - runs a matrix across
core,web,backend, andscripts - uses
fail-fast: false, so one failing lane does not cancel the others - runs
bun run test:<project>in each lane after dependency install - runs every lane with
TZ: Etc/UTCset
Local parity commands:
bun run test:core
bun run test:web
bun run test:backend
bun run test:scripts
E2E workflow (test-e2e.yml) is separate and runs on pull requests to main via bun run test:e2e.
Current Test Strategy
bun run test:coreusesbun testwith a small compatibility preload for the core BSON mock setup.bun run test:webrunsbun test --cwd packages/webdirectly. Web tests should be isolated enough to run in one Bun process without batching.bun run test:backendandbun run test:scriptsintentionally retain the existing Jest harness while their hoist-heavy module-mocking patterns are migrated.
Retained Jest Layout
Source:
jest.config.js
Projects:
corewebbackendscripts
Each project has its own setup files and module alias mapping.
What To Run By Change Type
Shared type or schema change
Run:
bun run test:core && bun run test:web && bun run test:backend
bun run type-check
Web-only UI or behavior change
Run:
bun run test:web
Add bun run test:core if the change touched shared utilities.
Backend route or service change
Run:
bun run test:backend
Add bun run test:core if a shared type or mapper changed.
CLI, migration, or seeder change
Run:
bun run test:scripts
Web Test Style
Preferred style:
- React Testing Library
- semantic queries by role/name/text
user-eventfor real interactions
Avoid:
- CSS selectors
- implementation-detail assertions
- unnecessary module-wide mocks
Isolation rules:
- Do not use top-level
mock.modulefor 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, orspyOnwith 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/storeorstore.hooks. bun test --cwd packages/webis the acceptance check for web test isolation. A focused test can pass while still leaking into the direct suite.mock.moduleis process-global, not per-file: the preload'safterAll(() => mock.restore())(packages/web/src/__tests__/web.preload.ts) runs once at the very end of the whole run, so amock.module(...)in one test file replaces that module for every file that imports it afterward — an order-dependent failure. Only mock a module if it has a single importer with no dedicated test of its own; if it has a dedicated test elsewhere, mocking it here will break that test instead.- To focus an element on mount in this jsdom setup, use React's
autoFocusprop or a stable callback ref (ref={useCallback(n => n?.focus(), [])}) — both fire in the commit phase. AuseEffect(() => ref.current?.focus())does not make the elementdocument.activeElementin tests.autoFocustrips biome'slint/a11y/noAutofocus(error) and a JSX-attributebiome-ignorecomment breaks the formatter, so prefer the callback ref. FloatingFocusManagerfights virtual-focus combobox palettes: for a component that keeps real focus in one input while usinguseListNavigation({ virtual: true })+aria-activedescendant(a command-palette style pattern),FloatingFocusManagerasynchronously grabs focus to the panel container and steals it back from the input. Drop it —useDismissstill handles Escape/outside-press without it. It belongs on anchored forms with a real reference element instead (e.g.FloatingEventForm).
Web Jest Harness Defaults (MSW + Globals)
Primary setup files:
packages/web/src/__tests__/web.test.start.tspackages/web/src/__tests__/__mocks__/server/mock.handlers.ts
Current defaults worth knowing:
- MSW runs in strict mode:
server.listen({ onUnhandledRequest: "error" }) - unhandled HTTP requests fail the test (instead of silently passing)
- IndexedDB is provided by
fake-indexeddb/auto structuredCloneis polyfilled for test environments that do not provide it- SuperTokens session existence is reset to
trueinbeforeEach
Important built-in handlers include:
GET http://localhost/version.json(used byuseVersionCheck)- event and user profile/metadata routes under
ENV_WEB.API_BASEURL POST /session/refreshwith both token headers and token cookies
When a component/hook introduces a new request, add a handler in the test (or shared handlers) rather than disabling strict mode.
Example per-test override:
import { rest } from "msw";
import { server } from "@web/__tests__/__mocks__/server/mock.server";
server.use(
rest.get("http://localhost/version.json", (_req, res, ctx) => {
return res(ctx.json({ version: "1.2.3" }));
}),
);
Warning-Free React Updates
When a test drives React state updates outside simple one-off interactions, wrap the update sequence in act imported from react.
import { act } from "react";
Use this pattern for:
- grouped
user-eventinteractions that trigger multiple updates - manual callback triggers (for example
matchMediachange handlers) - awaiting async values returned from spies before asserting final UI state
Example:
await act(async () => {
await user.type(screen.getByLabelText(/email/i), "invalid");
await user.tab();
});
Testing Responsive Layout State (useResponsiveLayout)
Files:
packages/web/src/components/AuthenticatedLayout/useResponsiveLayout.tspackages/web/src/views/Day/components/ShortcutsSidebar/ShortcutsSidebar.tsxpackages/web/src/views/Day/view/DayViewContent.tsx
Reliable setup pattern:
- mock
window.matchMediawithaddEventListener/removeEventListenersupport (mount state comes from the mockedmatches, breakpoint crossings fromchangeevents) - expose a small test helper to trigger media-query changes and wrap the trigger in
act - assert against the view store (
selectIsSidebarOpen(useViewStore.getState())) — the hook writes to the store rather than returning state
Assertions to prefer:
- query the sidebar by landmark role and label (
role="complementary",name: "Shortcuts sidebar") - when asserting presence in JSDOM for desktop-only markup (
hidden xl:flex), use role queries that allow hidden elements where needed - verify both pathways for toggle behavior:
- user interaction (header toggle button)
- keyboard interaction (
[shortcut via view shortcut hooks)
Seeding Event Data And Client State
Persisted events live in TanStack Query; transient client state lives in per-domain Zustand stores (see Frontend Runtime Flow). Seed both explicitly rather than reaching into module internals:
- The render harnesses (
mock.render.tsx'srender/renderHook, andrender-with-store.tsx'screateStoreWrapper/renderWithStore/renderHookWithStore) take aneventsoption that callsseedEventQueries(queryClient, events)(@web/__tests__/utils/event-query-test-data.ts). - They take a
stateoption (shape mirrors the old ReduxRootState:{ events: { draft }, view, settings, userMetadata }), routed throughseedStoresFromState()(@web/__tests__/utils/state/seed-stores.ts) into the real Zustand stores. - Zustand stores are module singletons; isolation comes from
resetAllStores(), registered in a globalafterEachinweb.preload.ts. A new store must be added to both the reset registry (@web/__tests__/utils/state/reset-stores.ts) and the seeder, or it leaks state across tests silently.
A gotcha that produces confusing failures far from its actual cause:
- Pending-mutation seeding: a pending event is derived from an in-flight
mutation whose
mutationKeyis a full 3-segmenteventMutationKeys.operation("edit" | "create" | ...). A bare["events", "mutation"]key is not recognized. Convert payloads nest the id undervariables.event._id; a reorder mutation marks no events pending by design.
Route-Aware Component Tests
For components that depend on routing context (Outlet, nested routes, route transitions), prefer the shared memory-router helper:
packages/web/src/__tests__/utils/providers/MemoryRouter.tsx
Pass initialEntries when asserting nested or non-root routes.
Global And Console Cleanup
If a test overrides globals (for example window.location or window.indexedDB) or spies on console.*, always restore them in teardown (afterEach/afterAll) to prevent cross-test leakage and noisy output.
Floating UI-Dependent Tests
If a test exercises components that rely on @floating-ui/react refs/styles (for example Day view task/context-menu interactions), import the shared setup:
@web/__tests__/floating-ui.setup
This keeps tests on production code paths while avoiding brittle layout coupling in JSDOM.
Jest Unbound-Method Rule In Tests
Test linting enforces jest/unbound-method. If you need to assert method calls on non-mock objects, spy on the method first so assertions are bound to a Jest mock/spy.
Useful anchors:
packages/web/src/__tests__packages/web/src/views/**/*.test.tsxpackages/web/src/sse/**/*.test.tsx
Backend Test Style
Preferred style:
- controller/service behavior tests
- realistic request flows when possible
- mock only external services, not internal business logic
Do not import mongoService (or other persistence implementations) directly in tests. Use test drivers instead (e.g. UserDriver, GoogleWatchDriver in packages/backend/src/__tests__/drivers/). Drivers encapsulate persistence so that switching away from Mongo (or another store) in the future does not require changing test code.
CI runs every lane with TZ: Etc/UTC (see CI Unit Test Workflow above). If a backend test only fails locally, match that explicitly rather than relying on your machine's default timezone: TZ=UTC ./node_modules/.bin/jest --selectProjects backend.
Useful anchors:
packages/backend/src/__tests__packages/backend/src/__tests__/drivers/packages/backend/src/event/services/*.test.tspackages/backend/src/sync/**/*.test.ts
Core Test Style
Preferred style:
- pure function coverage
- edge cases and schema validation
- date and recurrence invariants
Useful anchors:
packages/core/src/util/**/*.test.tspackages/core/src/types/*.test.tspackages/core/src/validators/*.test.ts
E2E Notes
E2E tests live in e2e.
Use them for:
- critical user flows
- integration between auth, UI, and persistence
- regressions that unit tests cannot model cleanly
CI-Only Flakiness From Worker Contention
test-e2e.yml runs in a container-limited GitHub Actions runner. Two
Playwright workers there compete with each other and with the shared dev
server for CPU, so whichever spec happens to be mid-render/mid-save when
both workers spike can blow through its assertion timeout — a different
spec each time, unrelated to the diff under test. Signature: a form-save or
element-visibility timeout in a spec the PR did not touch, passing on retry
or on a rerun of just the failed job. playwright.config.ts now runs a
single worker in CI (workers: process.env.CI ? 1 : 2) specifically to
remove this contention; if it recurs, treat it as environmental before
assuming a spec regressed, and do not "fix" it by deleting the test.
A test that fails deterministically isn't automatically a real product
bug either — it can be the harness racing a CSS transition. One
create-event-mouse spec failed 100% of the time in reduced-day-count mode
(narrow viewport + sidebar open): ensureSidebarOpen
(e2e/utils/event-test-utils.ts) waited for the sidebar to become visible
but not for its transition-[width] to finish, so the test measured column
positions mid-animation and then dragged after the grid had reflowed to a
different column count. Fixed by waitForMainGridWidthToSettle (polls
#mainGrid's measured width until two consecutive reads match), called from
the just-opened branch of ensureSidebarOpen — this benefits any spec that
opens the sidebar and then immediately depends on grid geometry. Verify
empirically (a throwaway debug spec dumping live getBoundingClientRect()s)
before concluding either way: "flaky = environment" and "deterministic = app
regression" are both assumptions, not defaults.
CI-Only Flakiness From Branch Divergence
A web unit test that times out only on GitHub Actions and never locally
(even when replaying CI's exact file execution order or running
--rerun-each=25) is not always pure environmental flake. If the failing
test lives in shortcut or view-store code, first check whether the PR branch
is behind main on those exact files — merging/rebasing main in has
resolved this class of failure before, because divergent shortcut/store code
interacting with CI's file ordering was the real contributor, not the
environment. Only treat it as a genuine flake
(gh run rerun <run-id> --failed) once the branch is confirmed current and
the diff doesn't touch the failing area.
Testing Realtime And Sync Changes
For SSE or sync work:
- test backend emitters/handlers where possible
- test web SSE hooks for listener registration and dispatch behavior
- test event listeners and operations if refetch or optimistic behavior changed
Common Gaps To Watch
- optimistic event ids
- recurring event scope handling
- local-only versus authenticated repository behavior
- storage migration paths
- date parsing around all-day events and UTC formatting