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
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.
routingis required in the@/lib/i18nmock.src/lib/schemas/newActSchema.jsimportsroutingfrom@/lib/i18nat 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:
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
| Concern | Assertion |
|---|---|
| Required fields are rendered | expect(screen.getByRole("textbox", { name: /label/i })).toBeInTheDocument() |
| Invalid form does not call server action | expect(serverAction).not.toHaveBeenCalled() |
| Valid form calls server action with correct payload | expect(serverAction).toHaveBeenCalledWith({ field: "value", ... }) |
| Error path does not navigate | expect(mockPush).not.toHaveBeenCalled() |
| Success path navigates | await waitFor(() => expect(mockPush).toHaveBeenCalledWith(...)) |
| Pre-populated fields have correct values | expect(input).toHaveValue("expected") |
| Disabled button stays disabled | expect(btn).toBeDisabled() |
For hooks
| Concern | Assertion |
|---|---|
| Correct value derived from inputs | expect(result.current.field).toBe("expected") |
| Field absent when condition not met | expect(result.current).not.toHaveProperty("field") |
| Options filter or override correctly | expect(result.current).toEqual({ ... }) |
| Throws when used outside its required provider | expect(() => render(<Consumer />)).toThrow("...") |
Test files
src/lib/ui/modals/OrganizationDetails.test.jsx — 11 tests
describe block | Tests | Key assertions |
|---|---|---|
edit mode (isNew=false) | 7 | OrganizationDetailsButtons 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) | 4 | data-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 block | Tests | Key assertions |
|---|---|---|
| conditional rendering | 5 | Empty state shows noOrganizationsConfigured text; single org renders one OrganizationCaption without Masonry; multiple orgs render inside Masonry |
| sort order | 2 | Self organization (organizationId === 0) is always first; remaining orgs sorted alphabetically by organizationName |
| usageData | 2 | Correct usageData from usageDataMap passed to each caption by organizationId; caption receives undefined when id is absent from the map |
| add organization | 1 | Add 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 block | Tests | Key assertions |
|---|---|---|
| conditional rendering | 5 | Empty state shows noContracts text; single contract renders one ContractCaption (no Masonry); multiple contracts render inside Masonry; all contracts sorted alphabetically by name |
| edit dialog | 3 | Clicking edit opens ContractEdit; partnerIds→contractPartners and activityIds→contractActivities mapped correctly; close button unmounts ContractEdit |
| save | 4 | contractUpdate called with correct shape (contractId + updates with partnerIds/activityIds); toastSuccess + dialog closes + router.refresh() on success; toastError + dialog stays open on error |
| add contract | 1 | Add 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 (partnerIds → contractPartners, activityIds → contractActivities). 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
| Test | Key assertion |
|---|---|
| fetch on mount | contractPartnerGet called with { partnerId: organizationId } |
| render fetched contracts | one ContractCard per DB contract |
| contract count in heading | heading shows "contracts (N)" |
| empty fetch | heading shows "contracts (0)" |
| pending new contracts | pendingNewContracts prop renders ContractCard with data-isnew="true" |
| mixed fetched + pending | both rendered together; fetched cards have data-isnew="false" |
markedForDeletion pass-through | card has data-marked="true" for ids in deletedContractIds |
pendingEdit pass-through | card has data-haspendingledit="true" for ids in pendingContractEdits |
| add button opens modal | clicking the add button renders ContractEdit |
submit calls onAddNewContract | onAddNewContract called with { contractName, partnerIds: [organizationId], activityIds } |
| modal closes after submit | ContractEdit unmounted after save |
| close button | ContractEdit unmounted on close |
| toggle delete (existing) | onToggleDeleteContract("c1") called on ContractCard click |
| remove pending new | onRemovePendingNewContract("temp-1") called on pending ContractCard click |
| fetch error | logger.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
| Test | Key assertion |
|---|---|
| renders without crashing with minimal props | contractName is in the document |
| renders the contract description | description text visible |
shows partner avatars for matching partnerIds | OrganizationAvatar rendered for a known partner id |
shows activity chips for matching activityIds | ActivityChip rendered for a known activity id |
renders without crashing when partnerIds reference unknown ids | unknown ids filtered out silently, card still renders |
shows expiration date row when contractExpirationDate is set | ExpirationChip is in the document |
calls onEdit with the contract when clicked | callback 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
| Test | Key assertion |
|---|---|
| renders without crashing with minimal props | contractName is in the document |
renders partner chips for matching partnerIds | ActivityChip rendered |
renders without crashing when partnerIds reference unknown ids | unknown ids filtered silently |
| shows expiration chip | ExpirationChip present when contractExpirationDate set |
shows toBeDeleted watermark | present when markedForDeletion=true |
shows toBeCreated watermark | present when isNew=true |
opens ContractDetails when edit button clicked | dialog mounted on click |
merges pendingEdit over contract data | updated name displayed; toBeUpdated watermark shown |
renders inside sortable wrapper when sortableId provided | drag 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 ContractCaption — ContractCard also destructures { partners } from useOrganization() and had the stale organizations reference.
src/lib/ui/cards/ActivityCaption.test.jsx — 4 tests
| Test | Key assertion |
|---|---|
| renders without crashing with minimal props | activity name visible |
| renders the OU name | OU name visible in caption |
renders purposeShort and purposeLong | both attribute values displayed |
| renders without crashing when optional fields are missing | no 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
| Test | Key assertion |
|---|---|
| renders without crashing with minimal props | heading with org name present |
| renders the long name | organizationNameLong visible |
shows ownOrganization label when organizationId === 0 | i18n key rendered |
| renders website when provided | website URL visible |
| renders formatted address | "City, Country" visible |
renders activity chips when usageData provided | ActivityChip 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
| Test | Key assertion |
|---|---|
| renders without crashing with standard type | title, description, and Button present |
renders ButtonHold for alert type | ButtonHold stub rendered instead of Button |
navigates to href when button clicked | router.push called with href |
calls custom onClick instead of navigation when provided | onClick called; router.push not called |
Mocks: @/lib/i18n (useRouter), @/lib/ui/buttons (Button, ButtonHold stubs).
src/lib/ui/cards/TemplateCard.test.jsx — 7 tests
| Test | Key assertion |
|---|---|
renders for activityDeclaration type | activity name visible |
renders for activityInformation type | no crash |
renders for ropaFrontPage type (org-level) | fullOrganization title shown |
renders for ropaToc type (org-level) | fullOrganization title shown |
shows fullOrganization title when activityId === 0 | org-level heading applied |
calls onClick with template when clicked and clickable | callback fired with full template object |
does not call onClick when clickable=false | callback not fired |
Mocks: next-intl, @/lib/text (getTextColor), @/constants (TEMPLATE_TYPES).
src/lib/ui/cards/ContactCard.test.jsx — 10 tests
| Test | Key assertion |
|---|---|
| renders without crashing with minimal props | contact full name visible |
| renders primary email | email link present |
| renders primary phone | formatted phone string present |
| renders contact notes when provided | notes text visible |
shows toBeDeleted watermark | present when markedForDeletion=true |
shows toBeCreated watermark | present when isNew=true |
shows toBeUpdated watermark | present when hasPendingChanges=true |
calls onToggleDelete when delete icon cell clicked | callback fired on cell click |
renders inside sortable wrapper when sortableId provided | drag indicator icon present |
| renders without crashing when contact has no emails or phones | empty 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
| Test | Key assertion |
|---|---|
| renders items by their description text | Corporation A/B/C all in the document |
| displays container labels | Controllers, Processors, Available headings rendered |
| Apply button is disabled when no changes | apply-btn is disabled before any drag |
| calls onApply with items after a simulated drag move | onApply receives array with moved item having updated status |
| calls onCancel with false when no changes | onCancel(false) called |
| calls onCancel with true after items are moved | onCancel(true) called after a drag |
| Apply button is disabled again after canceling pending changes | apply-btn disabled after cancel resets state |
| shows toast and resets when a movement restriction is violated on drag end | toastError called with restriction errorMessage; Apply still disabled |
| shows toast when moving below the minimum status constraint | toastError called with minStatusConstraint.errorMessage |
| does not render left button when leftButtonText is not provided | no left-btn-* element in document |
| renders left button when leftButtonText and handler are provided | left-btn-editPartners in document |
| left button is disabled when there are pending changes | left-btn-editPartners disabled after a drag |
| renders item chip label when no imagePath is provided | Corp 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
| Test | Key assertion |
|---|---|
| renders a dialog | role="dialog" in the document |
| renders the DragAndDrop component inside the dialog | data-testid="drag-and-drop" present |
| maps organizations to DragAndDrop items with correct status from role | items[0] matches { id: "0", status: "controller", label: "ACME Corp", description: "ACME Corporation" } |
| uses organizationColor for chipColor when provided | items[0].chipColor === "#ff0000" (org color takes precedence) |
| falls back to chipColor prop when organizationColor is absent | items[0].chipColor === "#abcdef" (prop used) |
| calls onClose when DragAndDrop cancels with no changes | onClose called |
| does not call onClose when DragAndDrop cancels with pending changes | onClose not called |
| passes draggable=false when loading is true | dndProps.draggable === false |
| passes draggable=true when not loading | dndProps.draggable === true |
| applies a minStatusConstraint requiring at least one controller | dndProps.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
| Test | Key assertion |
|---|---|
| renders the flag image for the current locale | <img alt="en flag"> with src="/flags/en.png" |
| shows available locales excluding the current locale | FR and DE visible; EN absent |
| calls router.replace with the selected locale when clicked | router.replace called with { pathname: "/test" } and { locale: "fr" } |
| shows the edit icon for admin users | EditIcon test id in document |
| does not show the edit icon for non-admin users | EditIcon absent |
| shows a Divider when admin and available locales exist | role="separator" in document |
| does not show a Divider for non-admin users | role="separator" absent |
| fetches languages and opens OrgLanguages modal when edit icon is clicked | organizationLanguagesGet called; org-languages-modal appears |
| renders no locale items when org has only the current locale | FR and DE absent |
| renders no locale items when organization is null | FR 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 block | Tests | Key assertions |
|---|---|---|
useOrganization | 2 | Provider delivers organization value to consumers; throws "useOrganization must be used within an OrganizationProvider" outside the tree |
useRopa | 2 | Provider 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 block | Tests | Key assertions |
|---|---|---|
| base behaviour | 5 | Empty 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 option | 1 | ou excluded even when selectedOus is non-empty |
noSearchTerm option | 1 | query excluded even when searchTerm is set |
forceActive option | 1 | showInactive excluded even when the URL flag is true |
forceInactive option | 1 | showInactive: "true" included even when the URL flag is false |
forceActive + forceInactive conflict | 1 | forceActive wins silently — showInactive is excluded |
Mocks: nuqs (full module stub with useQueryState: vi.fn()).
src/lib/forms/organizationAddressForm/OrganizationAddressForm.test.jsx — 6 tests
| Test | Key assertion |
|---|---|
| Renders required fields | addressLine1, city, postalCode inputs are in the document |
| Apply disabled when no changes | apply-btn is disabled and onSave is not called |
Valid change calls onSave | After editing addressLine1, onSave receives the full updated address object |
| Required field cleared blocks save | onSave not called when addressLine1 is cleared (validation error) |
Cancel calls onCancel | onCancel called exactly once |
Pre-fills from address prop | Inputs 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
| Test | Key assertion |
|---|---|
| Renders the edit icon button | role="button" and data-testid="show-tooltip-icon" present |
| Opens popup with pre-filled field | After clicking the button, a textbox with the initial value appears |
| Apply disabled when value unchanged | apply-btn is disabled before any edits |
No onSave when value unchanged | Clicking disabled apply does not call onSave |
Valid change calls onSave | onSave("organizationName", "New Corp Name") called with field name and new value |
| Validation blocks save | A 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
| Test | Key assertion |
|---|---|
| Renders the name text field | data-testid="name-input-en" is in the document |
Empty name does not call ouAdd | ouAdd not called on save |
Name too short (< 2 chars) does not call ouAdd | Zod min(2) blocks the call |
Valid form calls ouAdd with correct payload | ouAdd({ ouColor: "#ff0000", ouName: { en: "HR Dept" } }) |
| Successful save navigates | router.push("/e") called after ouAdd resolves |
| Error response does not navigate | mockPush not called when ouAdd returns { isError: true } |
OuForm — edit OU
| Test | Key assertion |
|---|---|
Pre-fills name from ropas | Input has value "HR" |
Valid change calls ouUpdate | ouUpdate({ ouId: 5, ouColor: "#ff0000", ouName: { en: "Finance" } }) |
Cleared name does not call ouUpdate | Zod 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
| File | Reason |
|---|---|
TextAttributeForm.jsx | Requires 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.jsx | Depends on @mui/x-date-pickers (date picker rendering in jsdom is unreliable) and zod-form-data FormData parsing — better covered at Tier 5 |
OrganizationContactForm.jsx | Form structure mirrors OrganizationAddressForm; validation is covered by validateContact in organizationSchema.test.js (Tier 1) |
OrganizationLogoForm.jsx | Uses react-dropzone file input and image processing — requires a real browser File API |
ContactsSection.jsx | No branching logic — delegates entirely to ContactCard (now has its own test) and VerticalDnD (drag-and-drop, requires pointer events). Covered transitively. |
ActivitiesGrid.jsx | Three 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.jsx | Always 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 components | Components 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
- Create the test file co-located with its source file, using the
.test.jsxextension for components and.test.jsfor plain hooks. - Do not add
// @vitest-environment— the defaultjsdomfromvitest.config.mjsapplies. - Mock
@/lib/mongooseexplicitly: list only the server action functions the component under test actually imports. - Mock
next-intlwithuseTranslations: () => (key) => keyanduseLocale: () => "en". - Mock
@/lib/i18nand always includerouting: { locales: ["en"] }— see the pitfall above. - Stub heavy UI sub-components (
buttongroups, complex pickers) with minimal stubs exposingdata-testidattributes for targeting in tests. - If a component imports anything from
@/lib/ui/AttributeRow, mock the barrel — see renderAttributeRow pitfall. - Use
getByRole("textbox", { name: /label/i })rather thangetByLabelTextwhen MUI icons share the same text as field labels. - Run
npm run test:componentsto confirm.