Skip to main content

Tier 4 — Component Tests

Tier 4 tests verify the contract between the UI and the rest of the application: that hooks derive correct values from their inputs, that forms validate user input before calling server actions, and that components render the right things given their props. They run in a simulated browser environment (jsdom) and mock the network boundary at @/lib/mongoose.

Running Tier 4 tests

npm run test:components

This targets src/lib/hooks/, src/lib/forms/, and src/lib/ui/. No MongoDB process is started.


Mock setup

Tier 4 test files use the default jsdom environment configured in vitest.config.mjs — no // @vitest-environment directive is needed.

Standard mock block for form components

src/lib/forms/ouForm/OuForm.test.jsx
import { vi, describe, it, expect, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";

vi.mock("next-intl", () => ({
useLocale: () => "en",
useTranslations: () => (key) => key,
}));

vi.mock("@/lib/i18n", () => ({
useRouter: () => ({ push: mockPush }),
routing: { locales: ["en"] },
// add any other named exports the component imports
}));

vi.mock("@/lib/mongoose", () => ({
ouAdd: vi.fn(),
ouUpdate: vi.fn(),
// list only the functions the component under test imports
}));

makeTFn — when a component uses both t(key) and t.rich(key, ...)

The simple (key) => key stub only satisfies t("someKey") calls. Components that also call t.rich("someKey", { br: () => <br /> }) will throw because fn.rich is undefined.

Use makeTFn to produce a stub that satisfies both patterns:

const makeTFn = () => {
const fn = (key) => key;
fn.rich = () => null;
return fn;
};

vi.mock("next-intl", () => ({
useTranslations: () => makeTFn(),
useLocale: () => "en",
}));

fn.rich returns null — sufficient for testing that a component renders without crashing when it contains inline rich-text elements.

routing is required in the @/lib/i18n mock. src/lib/schemas/newActSchema.js imports routing from @/lib/i18n at module-load time to derive the locale list. Omitting it causes an import-time error in any test that transitively imports @/lib/schemas. See Pitfalls.

Standard mock block for hooks

Hooks that use nuqs URL state need useQueryState to return controlled values. Because nuqs parsers are objects produced by chaining (parseAsString.withDefault("")), mock the entire nuqs module and configure useQueryState per test:

src/lib/hooks/useUrlParams.test.js
vi.mock("nuqs", () => {
const makeParser = () => ({ withDefault: (def) => ({ _default: def }) });
return {
parseAsArrayOf: () => makeParser(),
parseAsBoolean: makeParser(),
parseAsInteger: makeParser(),
parseAsString: makeParser(),
useQueryState: vi.fn(),
};
});

import { useQueryState } from "nuqs";

// In each test:
useQueryState.mockImplementation((key) => {
if (key === "query") return ["risk", vi.fn()];
return [null, vi.fn()];
});

When the mock references are needed for assertions

Use vi.hoisted to make a mock reference available both inside vi.mock factory closures (which are hoisted) and in test bodies:

const mockPush = vi.hoisted(() => vi.fn());

vi.mock("@/lib/i18n", () => ({
useRouter: () => ({ push: mockPush }),
routing: { locales: ["en"] },
}));

// Later in a test:
await waitFor(() => expect(mockPush).toHaveBeenCalledWith("/e"));

What to assert

For form components

ConcernAssertion
Required fields are renderedexpect(screen.getByRole("textbox", { name: /label/i })).toBeInTheDocument()
Invalid form does not call server actionexpect(serverAction).not.toHaveBeenCalled()
Valid form calls server action with correct payloadexpect(serverAction).toHaveBeenCalledWith({ field: "value", ... })
Error path does not navigateexpect(mockPush).not.toHaveBeenCalled()
Success path navigatesawait waitFor(() => expect(mockPush).toHaveBeenCalledWith(...))
Pre-populated fields have correct valuesexpect(input).toHaveValue("expected")
Disabled button stays disabledexpect(btn).toBeDisabled()

For hooks

ConcernAssertion
Correct value derived from inputsexpect(result.current.field).toBe("expected")
Field absent when condition not metexpect(result.current).not.toHaveProperty("field")
Options filter or override correctlyexpect(result.current).toEqual({ ... })
Throws when used outside its required providerexpect(() => render(<Consumer />)).toThrow("...")

Test files

src/lib/ui/modals/OrganizationDetails.test.jsx — 11 tests

describe blockTestsKey assertions
edit mode (isNew=false)7OrganizationDetailsButtons renders with data-isnew="false"; short and long name visible in header; save disabled with no pending changes; one ContactCard per contact; organizationEntityUsageGet called on mount; usage chips rendered when org is in use
create mode (isNew=true)4data-isnew="true" on buttons; save disabled when color is #000000; save disabled when organizationNameLong is empty; usage check NOT called for new org

Mocks: next-intl (makeTFn pattern + useLocale), @/lib/i18n, react-hot-toast, @/lib/mongoose (all functions the component imports including contractPartnerGet, contractCreate, contractDelete, contractUpdate), @/lib/utils (logger), @/lib/text (including Watermark: () => null), @/lib/ui/buttongroups, @/lib/ui/cards (ContactCard stub), @/lib/ui/icons, @/lib/ui/pickers, @/lib/forms/*, @/lib/hooks (useOrganization).

Key pattern — mock sub-components with async effects to suppress act() warnings:

ContractsSection has a useEffect that calls contractPartnerGet and then setContracts. When rendered inside a parent component test, the promise resolution triggers a React state update outside of act(), producing noisy warnings. Since no parent-level test asserts on contracts, mock it out in the barrel:

vi.mock("./OrganizationDetailsComponents", async (importOriginal) => {
const actual = await importOriginal();
return { ...actual, ContractsSection: () => null };
});

This preserves all real exports (FieldRow, OrganizationDetailsHeader, ContactsSection) while eliminating the async side effect.


src/lib/ui/grids/PartnersGrid.test.jsx — 10 tests

describe blockTestsKey assertions
conditional rendering5Empty state shows noOrganizationsConfigured text; single org renders one OrganizationCaption without Masonry; multiple orgs render inside Masonry
sort order2Self organization (organizationId === 0) is always first; remaining orgs sorted alphabetically by organizationName
usageData2Correct usageData from usageDataMap passed to each caption by organizationId; caption receives undefined when id is absent from the map
add organization1Add button navigates to /e/org/partners/new

Mocks: next-intl, @/lib/i18n (with routing), @mui/lab (Masonry stub), @/lib/ui/cards (OrganizationCaption stub with data-orgid and data-usagedata attrs), @/lib/ui/buttongroups (PartnersGridButtons stub with add button), @/lib/ui/modals/TemplateEditorDialogTitle (null stub).


src/lib/ui/grids/ContractsGrid.test.jsx — 11 tests

describe blockTestsKey assertions
conditional rendering5Empty state shows noContracts text; single contract renders one ContractCaption (no Masonry); multiple contracts render inside Masonry; all contracts sorted alphabetically by name
edit dialog3Clicking edit opens ContractEdit; partnerIdscontractPartners and activityIdscontractActivities mapped correctly; close button unmounts ContractEdit
save4contractUpdate called with correct shape (contractId + updates with partnerIds/activityIds); toastSuccess + dialog closes + router.refresh() on success; toastError + dialog stays open on error
add contract1Add button navigates to /e/org/contracts/new

Mocks: next-intl, @/lib/i18n (with routing), @/lib/mongoose (contractUpdate), @/lib/ui/incidents (toastSuccess, toastError), @mui/lab (Masonry stub — see pitfall below), @/lib/ui/cards (ContractCaption stub with data-testid, data-contractid, edit button), @/lib/ui/buttongroups (ContractsGridButtons stub with add button), @/lib/ui/modals/TemplateEditorDialogTitle (null stub), @/lib/ui/modals/ContractEdit (stub that exposes contract.contractPartners/contractActivities as data-* attrs and passes them back through onSave).

Key pattern — expose mapped props via data-* attrs to verify handleEdit mapping:

handleEdit reshapes the incoming contract before passing it to ContractEdit (partnerIdscontractPartners, activityIdscontractActivities). To assert this without reaching into React state, the ContractEdit stub surfaces those values as data attributes:

vi.mock("@/lib/ui/modals/ContractEdit", () => ({
default: ({ contract, onSave, onClose }) => (
<div
data-testid="contract-edit"
data-partners={JSON.stringify(contract.contractPartners)}
data-activities={JSON.stringify(contract.contractActivities)}
>
...
</div>
),
}));

// In the test:
expect(screen.getByTestId("contract-edit")).toHaveAttribute(
"data-partners",
JSON.stringify(["p1", "p2"]),
);

The stub also calls onSave with those same mapped values, making the contractUpdate payload assertions end-to-end meaningful.


src/lib/ui/modals/OrganizationDetailsComponents/ContractsSection.test.jsx — 15 tests

TestKey assertion
fetch on mountcontractPartnerGet called with { partnerId: organizationId }
render fetched contractsone ContractCard per DB contract
contract count in headingheading shows "contracts (N)"
empty fetchheading shows "contracts (0)"
pending new contractspendingNewContracts prop renders ContractCard with data-isnew="true"
mixed fetched + pendingboth rendered together; fetched cards have data-isnew="false"
markedForDeletion pass-throughcard has data-marked="true" for ids in deletedContractIds
pendingEdit pass-throughcard has data-haspendingledit="true" for ids in pendingContractEdits
add button opens modalclicking the add button renders ContractEdit
submit calls onAddNewContractonAddNewContract called with { contractName, partnerIds: [organizationId], activityIds }
modal closes after submitContractEdit unmounted after save
close buttonContractEdit unmounted on close
toggle delete (existing)onToggleDeleteContract("c1") called on ContractCard click
remove pending newonRemovePendingNewContract("temp-1") called on pending ContractCard click
fetch errorlogger.error called when contractPartnerGet rejects

Mocks: next-intl (makeTFn pattern), @/lib/mongoose (contractPartnerGet), @/lib/utils (logger), @/lib/ui/icons (ShowTooltipIcon), @/lib/ui/cards (ContractCard stub with data-testid, data-contractid, data-marked, data-haspendingledit, data-isnew), @/lib/ui/modals/ContractEdit (stub with save/close buttons that invoke onSave/onClose).

Key pattern — <TableRow> components need a table wrapper:

ContractsSection renders <TableRow>/<TableCell> elements directly. Rendering them without a surrounding table causes React DOM invalid-HTML warnings and potential rendering anomalies in jsdom. Wrap with a helper:

const renderSection = (props = {}) =>
render(
<table>
<tbody>
<ContractsSection organizationId={1} bgColor="#3366cc" {...props} />
</tbody>
</table>,
);

Use await waitFor(() => expect(contractPartnerGet).toHaveBeenCalled()) before making assertions, since the fetch is asynchronous.



src/lib/ui/cards/ContractCaption.test.jsx — 7 tests

TestKey assertion
renders without crashing with minimal propscontractName is in the document
renders the contract descriptiondescription text visible
shows partner avatars for matching partnerIdsOrganizationAvatar rendered for a known partner id
shows activity chips for matching activityIdsActivityChip rendered for a known activity id
renders without crashing when partnerIds reference unknown idsunknown ids filtered out silently, card still renders
shows expiration date row when contractExpirationDate is setExpirationChip is in the document
calls onEdit with the contract when clickedcallback wiring present

Mocks: next-intl (useTranslations, useLocale), @/lib/hooks (useOrganization returning { partners: [...] }, useRopa returning { ous: [...] }), @/lib/text (getTextColor), @/lib/ui/icons (ExpirationChip, ActivityChip, OrganizationAvatar).

Why this test exists: ContractCaption destructures { partners } from useOrganization(). When the data-model field was renamed organizations → partners, this component crashed at runtime because no test exercised it with a real context value. This test catches that class of rename-induced crash.


src/lib/ui/cards/ContractCard.test.jsx — 9 tests

TestKey assertion
renders without crashing with minimal propscontractName is in the document
renders partner chips for matching partnerIdsActivityChip rendered
renders without crashing when partnerIds reference unknown idsunknown ids filtered silently
shows expiration chipExpirationChip present when contractExpirationDate set
shows toBeDeleted watermarkpresent when markedForDeletion=true
shows toBeCreated watermarkpresent when isNew=true
opens ContractDetails when edit button clickeddialog mounted on click
merges pendingEdit over contract dataupdated name displayed; toBeUpdated watermark shown
renders inside sortable wrapper when sortableId provideddrag handle icon present

Mocks: next-intl, @dnd-kit/sortable (useSortable), @dnd-kit/utilities (CSS), @/lib/hooks (useOrganization returning { partners: [...] }), @/lib/text (Watermark), @/lib/ui/icons, @/lib/ui/buttongroups (HoldCancelButtons), @/lib/ui/modals (ContractDetails).

Why this test exists: Same root cause as ContractCaptionContractCard also destructures { partners } from useOrganization() and had the stale organizations reference.


src/lib/ui/cards/ActivityCaption.test.jsx — 4 tests

TestKey assertion
renders without crashing with minimal propsactivity name visible
renders the OU nameOU name visible in caption
renders purposeShort and purposeLongboth attribute values displayed
renders without crashing when optional fields are missingno crash when attributes are undefined

Mocks: next-intl, @/lib/text (getTextColor, HighlightedTypography), @/lib/utils (getRoleFromControllers), @/lib/url (urlParamsClientRead, urlParamsClientBuild), @/constants (orgRoles), @/lib/ui/icons (ActiveChip, ShowTooltipIcon).


src/lib/ui/cards/OrganizationCaption.test.jsx — 7 tests

TestKey assertion
renders without crashing with minimal propsheading with org name present
renders the long nameorganizationNameLong visible
shows ownOrganization label when organizationId === 0i18n key rendered
renders website when providedwebsite URL visible
renders formatted address"City, Country" visible
renders activity chips when usageData providedActivityChip rendered
wraps in a link when clickable is true<a> href points to partners detail route

Mocks: next-intl, @/lib/i18n (Link), @/lib/text (getTextColor), @/lib/ui/icons (ActivityChip, OrganizationAvatar).


src/lib/ui/cards/OrgSettingsCaption.test.jsx — 4 tests

TestKey assertion
renders without crashing with standard typetitle, description, and Button present
renders ButtonHold for alert typeButtonHold stub rendered instead of Button
navigates to href when button clickedrouter.push called with href
calls custom onClick instead of navigation when providedonClick called; router.push not called

Mocks: @/lib/i18n (useRouter), @/lib/ui/buttons (Button, ButtonHold stubs).


src/lib/ui/cards/TemplateCard.test.jsx — 7 tests

TestKey assertion
renders for activityDeclaration typeactivity name visible
renders for activityInformation typeno crash
renders for ropaFrontPage type (org-level)fullOrganization title shown
renders for ropaToc type (org-level)fullOrganization title shown
shows fullOrganization title when activityId === 0org-level heading applied
calls onClick with template when clicked and clickablecallback fired with full template object
does not call onClick when clickable=falsecallback not fired

Mocks: next-intl, @/lib/text (getTextColor), @/constants (TEMPLATE_TYPES).


src/lib/ui/cards/ContactCard.test.jsx — 10 tests

TestKey assertion
renders without crashing with minimal propscontact full name visible
renders primary emailemail link present
renders primary phoneformatted phone string present
renders contact notes when providednotes text visible
shows toBeDeleted watermarkpresent when markedForDeletion=true
shows toBeCreated watermarkpresent when isNew=true
shows toBeUpdated watermarkpresent when hasPendingChanges=true
calls onToggleDelete when delete icon cell clickedcallback fired on cell click
renders inside sortable wrapper when sortableId provideddrag indicator icon present
renders without crashing when contact has no emails or phonesempty arrays handled safely

Mocks: next-intl, @dnd-kit/sortable (useSortable), @dnd-kit/utilities (CSS), @/lib/ui/icons (ShowTooltipIcon), @/lib/forms/organizationContactForm (OrganizationContactForm), @/lib/text (Watermark).


src/lib/ui/pickers/DragAndDrop.test.jsx — 14 tests

TestKey assertion
renders items by their description textCorporation A/B/C all in the document
displays container labelsControllers, Processors, Available headings rendered
Apply button is disabled when no changesapply-btn is disabled before any drag
calls onApply with items after a simulated drag moveonApply receives array with moved item having updated status
calls onCancel with false when no changesonCancel(false) called
calls onCancel with true after items are movedonCancel(true) called after a drag
Apply button is disabled again after canceling pending changesapply-btn disabled after cancel resets state
shows toast and resets when a movement restriction is violated on drag endtoastError called with restriction errorMessage; Apply still disabled
shows toast when moving below the minimum status constrainttoastError called with minStatusConstraint.errorMessage
does not render left button when leftButtonText is not providedno left-btn-* element in document
renders left button when leftButtonText and handler are providedleft-btn-editPartners in document
left button is disabled when there are pending changesleft-btn-editPartners disabled after a drag
renders item chip label when no imagePath is providedCorp A chip label visible
renders image when item has an imagePath<img alt="En"> with /flags/en.png src

Mocks: @dnd-kit/core (full mock — see DnD handlers pattern), @dnd-kit/sortable (SortableContext, useSortable, arrayMove), @dnd-kit/utilities (CSS), @dnd-kit/modifiers (restrictToHorizontalAxis), next-intl, @/lib/ui/incidents (toastError), @/lib/ui/buttongroups (ApplyCancelButtons stub with apply-btn/cancel-btn), @/lib/ui/buttons (Button stub with left-btn-{text} testid), next/image.

Key pattern — exposing DnD callbacks via globalThis: see the Pitfalls/Patterns section below.


src/lib/ui/pickers/PartnerDnDPicker.test.jsx — 11 tests

TestKey assertion
renders a dialogrole="dialog" in the document
renders the DragAndDrop component inside the dialogdata-testid="drag-and-drop" present
maps organizations to DragAndDrop items with correct status from roleitems[0] matches { id: "0", status: "controller", label: "ACME Corp", description: "ACME Corporation" }
uses organizationColor for chipColor when provideditems[0].chipColor === "#ff0000" (org color takes precedence)
falls back to chipColor prop when organizationColor is absentitems[0].chipColor === "#abcdef" (prop used)
calls onClose when DragAndDrop cancels with no changesonClose called
does not call onClose when DragAndDrop cancels with pending changesonClose not called
passes draggable=false when loading is truedndProps.draggable === false
passes draggable=true when not loadingdndProps.draggable === true
applies a minStatusConstraint requiring at least one controllerdndProps.minStatusConstraint.status === "controller" and minCount === 1
renders title and subtitle from translations"title" and "subtitle" keys rendered

Mocks: ./DragAndDrop (stub that writes props to globalThis.__dndProps and exposes dnd-apply, dnd-cancel-no-changes, dnd-cancel-has-changes buttons), next-intl, @/lib/i18n (useRouter), @/lib/org (createOrganizationMovementRestrictions), @/lib/text (getTextColor), @/lib/ui/incidents (toastError).

Key pattern — capturing child props via globalThis:

globalThis.__dndProps = {};

vi.mock("./DragAndDrop", () => ({
default: (props) => {
globalThis.__dndProps = props;
return <div data-testid="drag-and-drop">...</div>;
},
}));

// In tests:
const { items } = globalThis.__dndProps;
expect(items[0]).toMatchObject({ id: "0", status: "controller" });

Reset globalThis.__dndProps = {} in beforeEach to prevent cross-test contamination.


src/lib/ui/pickers/LanguagePicker.test.jsx — 10 tests

TestKey assertion
renders the flag image for the current locale<img alt="en flag"> with src="/flags/en.png"
shows available locales excluding the current localeFR and DE visible; EN absent
calls router.replace with the selected locale when clickedrouter.replace called with { pathname: "/test" } and { locale: "fr" }
shows the edit icon for admin usersEditIcon test id in document
does not show the edit icon for non-admin usersEditIcon absent
shows a Divider when admin and available locales existrole="separator" in document
does not show a Divider for non-admin usersrole="separator" absent
fetches languages and opens OrgLanguages modal when edit icon is clickedorganizationLanguagesGet called; org-languages-modal appears
renders no locale items when org has only the current localeFR and DE absent
renders no locale items when organization is nullFR absent

Mocks: material-ui-popup-state/hooks (usePopupState, bindTrigger, bindMenu), @/lib/i18n (routing, usePathname, useRouter), next-intl (useLocale, useTranslations), @/lib/hooks (useUrlParams, useUserRole), @/lib/hooks/OrganizationContext (useOrganizationOptional), ../modals (OrgLanguages stub), @/lib/mongoose (organizationLanguagesGet), next/image.

Key pattern — globalThis for router mock shared across factory and tests:

vi.mock factories are hoisted before imports, so a const mockReplace = vi.fn() defined after would be undefined inside the factory. Use globalThis:

globalThis.__mockReplace = vi.fn();

vi.mock("@/lib/i18n", () => ({
routing: { locales: ["en", "fr", "de"] },
usePathname: () => "/test",
useRouter: () => ({ replace: globalThis.__mockReplace }),
}));

// Clear in beforeEach:
globalThis.__mockReplace.mockClear();

src/lib/hooks/OrganizationContext.test.jsx — 4 tests

describe blockTestsKey assertions
useOrganization2Provider delivers organization value to consumers; throws "useOrganization must be used within an OrganizationProvider" outside the tree
useRopa2Provider delivers ropa value to consumers; throws "useRopa must be used within a RopaProvider" outside the tree

Mocks: none — context is pure React.


src/lib/hooks/useUrlParams.test.js — 14 tests

describe blockTestsKey assertions
base behaviour5Empty object when nothing selected; query included when searchTerm set; query omitted when empty; ou formatted as "3,7"; showInactive / role included or omitted based on value
noOu option1ou excluded even when selectedOus is non-empty
noSearchTerm option1query excluded even when searchTerm is set
forceActive option1showInactive excluded even when the URL flag is true
forceInactive option1showInactive: "true" included even when the URL flag is false
forceActive + forceInactive conflict1forceActive wins silently — showInactive is excluded

Mocks: nuqs (full module stub with useQueryState: vi.fn()).


src/lib/forms/organizationAddressForm/OrganizationAddressForm.test.jsx — 6 tests

TestKey assertion
Renders required fieldsaddressLine1, city, postalCode inputs are in the document
Apply disabled when no changesapply-btn is disabled and onSave is not called
Valid change calls onSaveAfter editing addressLine1, onSave receives the full updated address object
Required field cleared blocks saveonSave not called when addressLine1 is cleared (validation error)
Cancel calls onCancelonCancel called exactly once
Pre-fills from address propInputs have the values from the initial address object

Mocks: next-intl, @/lib/text (geocodeAutocomplete), @/lib/text/getLocalizedCountries (getCountryOptions), @/lib/ui/buttongroups (ApplyCancelButtons → simple stub with data-testid).


src/lib/forms/organizationTextFieldForm/OrganizationTextFieldForm.test.jsx — 6 tests

TestKey assertion
Renders the edit icon buttonrole="button" and data-testid="show-tooltip-icon" present
Opens popup with pre-filled fieldAfter clicking the button, a textbox with the initial value appears
Apply disabled when value unchangedapply-btn is disabled before any edits
No onSave when value unchangedClicking disabled apply does not call onSave
Valid change calls onSaveonSave("organizationName", "New Corp Name") called with field name and new value
Validation blocks saveA value exceeding organizationName max length (25 chars) → onSave not called

Mocks: next-intl, @/lib/i18n (with routing), @/lib/ui/icons (ShowTooltipIcon → simple span), @/lib/ui/buttongroups (ApplyCancelButtons → simple stub).


src/lib/forms/ouForm/OuForm.test.jsx — 9 tests

OuForm — new OU

TestKey assertion
Renders the name text fielddata-testid="name-input-en" is in the document
Empty name does not call ouAddouAdd not called on save
Name too short (< 2 chars) does not call ouAddZod min(2) blocks the call
Valid form calls ouAdd with correct payloadouAdd({ ouColor: "#ff0000", ouName: { en: "HR Dept" } })
Successful save navigatesrouter.push("/e") called after ouAdd resolves
Error response does not navigatemockPush not called when ouAdd returns { isError: true }

OuForm — edit OU

TestKey assertion
Pre-fills name from ropasInput has value "HR"
Valid change calls ouUpdateouUpdate({ ouId: 5, ouColor: "#ff0000", ouName: { en: "Finance" } })
Cleared name does not call ouUpdateZod nonempty blocks the call

Mocks: next-intl, @/lib/i18n (with routing), react-hot-toast, @/lib/mongoose (ouAdd, ouUpdate), @/lib/ui/AttributeRow (full barrel — see Pitfalls), @/lib/ui/pickers (ColorPicker → simple div), @/lib/ui/buttongroups (OuFormButtons → simple button), @/lib/hooks (useUrlParams), @/lib/url (toQueryString), @/lib/text (getTextColor, textTranslate).


Pitfalls

Pitfall: routing must be in the @/lib/i18n mock

src/lib/schemas/newActSchema.js does this at module load time:

import { routing } from "@/lib/i18n";
const locales = routing.locales;

Vitest processes vi.mock factories before any imports run, but it still evaluates module-level code in imported files. If @/lib/i18n is mocked without exporting routing, accessing routing.locales throws at import time and the entire test suite fails to load.

Rule: every vi.mock("@/lib/i18n", ...) factory must include routing: { locales: [...] }.


Pitfall: @mui/lab Masonry uses ResizeObserver (unavailable in jsdom)

Masonry from @mui/lab observes element dimensions internally via ResizeObserver. jsdom does not implement ResizeObserver, so any test that renders a component containing <Masonry> will throw:

ReferenceError: ResizeObserver is not defined

Fix: mock @mui/lab with a plain wrapper div:

vi.mock("@mui/lab", () => ({
Masonry: ({ children }) => <div data-testid="masonry">{children}</div>,
}));

Apply this mock in any test file whose component under test (or any un-mocked child) renders <Masonry>.


Pitfall: renderAttributeRow.js is a .js file with JSX

src/lib/ui/AttributeRow/renderAttributeRow.js contains JSX but has a .js extension. In the default Vitest pipeline the @vitejs/plugin-react transform runs on the file, but Rollup's internal AST parser (parseAst) is invoked for module graph resolution before the plugin gets a chance. This causes a parse error:

RollupError: Parse failure: Expression expected
At file: /src/lib/ui/AttributeRow/renderAttributeRow.js:47:8

Fix: mock the @/lib/ui/AttributeRow barrel in any test that imports a component whose render tree reaches renderAttributeRow.js. Replace with lightweight stubs that expose the interaction surface under test:

vi.mock("@/lib/ui/AttributeRow", () => ({
ActiveLanguageRow: ({ loc, ouName, handleTextFieldChange }) => (
<input
data-testid={`name-input-${loc}`}
value={ouName}
onChange={(e) => handleTextFieldChange(loc, e)}
/>
),
OtherLanguageRow: ({ loc, ouName, handleTextFieldChange }) => (
<input
data-testid={`name-input-${loc}`}
value={ouName}
onChange={(e) => handleTextFieldChange(loc, e)}
/>
),
}));

Pitfall: MUI Tooltip aria-label collides with TextField label

When a MUI <Tooltip title={t("fieldName")}> wraps an icon in the same row as a <TextField label={t("fieldName")} />, Testing Library's getByLabelText(/fieldName/i) matches both the input and the SVG element (which inherits an aria-label from the tooltip title).

Fix: use getByRole("textbox", { name: /fieldName/i }) to target only the actual input:

// ❌ matches input AND tooltip SVG
screen.getByLabelText(/addressLine1/i);

// ✅ matches only the textbox
screen.getByRole("textbox", { name: /addressLine1/i });

Pitfall: t.rich is undefined when using a simple translation stub

Components that call t.rich("key", { br: () => <br /> }) (e.g. those using Watermark) will throw TypeError: t.rich is not a function if useTranslations is mocked as () => (key) => key.

Fix: use the makeTFn helper — see Mock setup → makeTFn.


Pitfall: async useEffect in a sub-component causes act() warnings in parent tests

When a parent component test renders a sub-component that performs async state updates (useEffect → fetch → setState), the promise resolution happens after the test's synchronous assertions and triggers:

Warning: An update to SubComponent inside a test was not wrapped in act(...)

The tests still pass, but the noise is a signal that the async flow is not being accounted for. Do not add act() wrappers everywhere in the parent tests — that couples the parent tests to the sub-component's internals.

Fix: in the parent test file, replace only the async sub-component with a null stub while keeping the rest of the barrel real:

vi.mock("./SomeComponents", async (importOriginal) => {
const actual = await importOriginal();
return { ...actual, AsyncSubComponent: () => null };
});

Write a dedicated test file for the sub-component (e.g. ContractsSection.test.jsx) where the async behaviour is tested properly with waitFor.


Pattern: exposing DnD callbacks via globalThis

@dnd-kit/core's DndContext passes onDragStart, onDragOver, and onDragEnd as props. In a jsdom environment there is no real pointer/drag API to trigger these, so tests must call them programmatically. Because vi.mock factories are hoisted above imports, a closure reference to a variable declared in the test file would be undefined inside the factory. The solution is to write the callbacks into globalThis from within the mock, then call them from tests using act():

globalThis.__dndHandlers = {};

vi.mock("@dnd-kit/core", () => ({
DndContext: ({ children, onDragStart, onDragOver, onDragEnd }) => {
globalThis.__dndHandlers.onDragStart = onDragStart;
globalThis.__dndHandlers.onDragOver = onDragOver;
globalThis.__dndHandlers.onDragEnd = onDragEnd;
return children;
},
// ... other exports
}));

// In a test:
act(() => {
globalThis.__dndHandlers.onDragOver({
active: { id: "3" },
over: { id: "controllers" },
});
});

Reset globalThis.__dndHandlers = {} in beforeEach so stale references from a previous render do not bleed into the next test.


Non-fatal: MUI anchorEl warning in popup tests

When testing components that use material-ui-popup-state (such as OrganizationTextFieldForm), MUI logs the following warning to stderr during interaction:

MUI: The `anchorEl` prop provided to the component is invalid.
The anchor element should be part of the document layout.

This is a known jsdom limitation — popup trigger elements rendered outside a real browser layout are not considered "in the document layout" by MUI's internal check. The warning is non-fatal: the popup still opens, its children are queryable via screen, and assertions pass correctly. No action needed.


Files not covered at Tier 4

FileReason
TextAttributeForm.jsxRequires both material-ui-popup-state (popup interaction) and useOrganization context — adding a context wrapper and mocking the popup trigger is feasible but the core logic (validation, updateFunc callback) is analogous to OrganizationTextFieldForm, which is already covered
ContractEdit.jsxDepends on @mui/x-date-pickers (date picker rendering in jsdom is unreliable) and zod-form-data FormData parsing — better covered at Tier 5
OrganizationContactForm.jsxForm structure mirrors OrganizationAddressForm; validation is covered by validateContact in organizationSchema.test.js (Tier 1)
OrganizationLogoForm.jsxUses react-dropzone file input and image processing — requires a real browser File API
ContactsSection.jsxNo branching logic — delegates entirely to ContactCard (now has its own test) and VerticalDnD (drag-and-drop, requires pointer events). Covered transitively.
ActivitiesGrid.jsxThree layout branches (0/1/multiple) but no state, no server actions, and no callbacks on the grid itself — all interactivity lives inside ActivityCaption and ActivitiesGridButtons. Signal-to-effort ratio too low.
OrganizationSettingsGrid.jsxAlways renders the same fixed set of cards — no conditional rendering, no server action calls, and onRestoreOrgData is passed straight through to OrgSettingsCaption without transformation. Purely presentational.
Purely presentational UI componentsComponents with no conditional rendering, no server action calls, and no state — testing them provides no signal beyond what the tests already exercise transitively

Adding a new Tier 4 test

  1. Create the test file co-located with its source file, using the .test.jsx extension for components and .test.js for plain hooks.
  2. Do not add // @vitest-environment — the default jsdom from vitest.config.mjs applies.
  3. Mock @/lib/mongoose explicitly: list only the server action functions the component under test actually imports.
  4. Mock next-intl with useTranslations: () => (key) => key and useLocale: () => "en".
  5. Mock @/lib/i18n and always include routing: { locales: ["en"] } — see the pitfall above.
  6. Stub heavy UI sub-components (buttongroups, complex pickers) with minimal stubs exposing data-testid attributes for targeting in tests.
  7. If a component imports anything from @/lib/ui/AttributeRow, mock the barrel — see renderAttributeRow pitfall.
  8. Use getByRole("textbox", { name: /label/i }) rather than getByLabelText when MUI icons share the same text as field labels.
  9. Run npm run test:components to confirm.