Files
Raunak Bhagat 31461f9c8c feat(admin): reorganise chat preferences and language models settings (#16435)
## Description

Reorganise the Chat Preferences and Language Models admin pages, tighten
their copy, and give their model selects one loading and failure
pattern. This PR also carries the former #3 (model load states and
hidden models), which was folded in. Each UI change is one bullet.

### Chat Preferences
- Split the settings into two cards: a **Chat** card and a **Search**
card.
- Moved **Default Language** to the top of the Chat card, as a
horizontal row.
- Chat card order: Default Language, Chat Naming Model, Multi-Model
Generation, Deep Research, Chat Auto-Scroll.
- Search card order: Search Selection Model, Search Mode, Auto-Detect
Search Filters.
- Removed the "Model selection" heading; the two task-model rows now sit
in the cards.
- Renamed "Chat naming model" to **Chat Naming Model**, and "Search
selection model" to **Search Selection Model**, with new copy.
- Removed the **Reset** buttons and the **(Optional)** suffix from both
task-model rows.
- Added an unset row at the top of both task-model selects, above a
divider; picking it clears the setting:
- **Chat Naming Model:** "Chat Default". Naming only runs in chats, on
the chat's own model.
- **Search Selection Model:** "Global Default", described as "Each
chat's model". Selection also runs in the Search UI, where the unset
fallback is the workspace default model.
- Clicking a task-model row's title opens its select.
- Removed the **beta** tag from Multi-Model Generation.
- Renamed "Disable Default Chat" to **Always Start with an Agent**, with
new copy and a link to featured agents.
- Made "without logging in" bold in the Allow Anonymous Users
description.
- Moved **Temperature Control** and **Reasoning Control** to the
Language Models page.
- New copy for Default Language and Search Selection Model.

### Language Models
- Card order: Default Model, Auto Mode, Router Model, a divider,
Reasoning Control, Temperature Control, Hide Provider Grouping.
- New copy for Default Model, Reasoning Control and Temperature Control.
- **Auto Mode** stays locked until the router list has loaded with at
least one router, with a "No router models found" tooltip when there are
none. Turning it off is always possible.
- The **Router Model** row shows only when a router model exists, and
its select can be cleared.
- With Auto Mode on and no usable router (none chosen, cleared, or
deleted since), an inline error under the Router Model row reads "Choose
a router model to enable users to use the auto routing mode." With no
router models at all, the error sits under Auto Mode instead.

### Settings (personal)
- **Display Language** now shares one component with the workspace
Default Language. It starts with a **Global Default** row (the workspace
language as its description) above a divider; picking it clears the
override.

### Model selects (task models and Router Model)
- **Loading:** a `DropdownLoader` stands in for the select.
- **Load failure:** the select stays, disabled with the error border,
and a persistent error toast offers a Retry button. The two task-model
rows share one toast.
- **Hidden models** are never offered, except a hidden model already in
use.

### Across these pages
- Removed **success toasts** from Chat Preferences, Language Models
(including cost overrides), Settings and Decision Models' provider
delete. Failures still show an error toast. e2e helpers wait for the
save response instead of the toast.

### Code changes behind the UI
- `LanguageSelect` (`lib/app/components.tsx`) serves both language
pickers.
- `filterModelConfigurations` loses its `visibleOnly` option; every
caller now lists visible models only (plus `keep`).
- `SimpleModelSelector`: its Global Default row takes a `label`, always
shows a divider (also in an ungrouped list), and the select takes
`isError`.
- Opal `InputHorizontal` and `InputVertical` take `error` and `warning`
props for inputs outside Formik.

## Changes

### Language Models

No "router model" configured, "Auto Mode" disabled:

<img width="872" height="452" alt="image"
src="https://github.com/user-attachments/assets/3894cb0f-9805-4909-99e9-f2bfe3ed92ab"
/>

"Auto Mode" on and no "Router model" configured:

<img width="877" height="526" alt="image"
src="https://github.com/user-attachments/assets/2ba22737-ddc7-4836-9ed8-83d2106aa54f"
/>

"Auto Mode" on and "Router model" configured:

<img width="863" height="506" alt="image"
src="https://github.com/user-attachments/assets/748db170-376b-4955-8d73-0c5a064d8a2b"
/>

### Chat Preferences

A few things have just been moved around here. The "beta" tag was also
removed from "Multi-Model Generation".

<img width="877" height="688" alt="image"
src="https://github.com/user-attachments/assets/e21336a8-4667-4110-a11e-e98f0488e0ad"
/>


<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Reorganizes the admin Chat Preferences and Language Models pages and
tightens their copy. Chat Preferences now splits into Chat and Search
cards, Temperature Control and Reasoning Control move to Language
Models, and the task-model rows are renamed to Chat Naming Model and
Search Selection Model.

**Behavior changes**
- Task-model selectors start with a Global Default row that clears the
designation; chat naming reads Chat Default and search selection keys
off the workspace default. Reset buttons and the Optional suffix are
gone.
- Auto Mode stays locked until router models load and can only be
enabled when a router exists; it can always be turned off. The Router
Model row only shows when a router exists, and its select is clearable
with an inline error when Auto Mode needs a router.
- Failed model loads keep the select, disabled with an error border, and
raise a persistent error toast with a retry button; loading shows a
dropdown-loader shimmer.
- "Disable Default Chat" is renamed to "Always Start with an Agent" with
new copy and a link to featured agents.
- Success toasts are removed from these pages, including cost overrides
and Settings, and Decision Models no longer toasts provider deletion;
failures still show an error toast. e2e helpers wait for the save
response instead.
- `LanguageSelect` is shared between the workspace Default Language and
the personal Display Language; the personal picker offers a Global
Default row.
- Model pickers no longer list hidden models; they show visible models
only, plus the currently selected value.

<sup>Written for commit d523995b8d.
Summary will update on new commits.</sup>

<a
href="https://cubic.dev/pr/onyx-dot-app/onyx/pull/16435?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="View guided diff"
src="https://www.cubic.dev/buttons/review-in-cubic-light.svg"></picture></a>
<a
href="https://www.cubic.dev/action/auto-fix/pr/onyx-dot-app/onyx/16435?returnTo=https%3A%2F%2Fgithub.com%2Fonyx-dot-app%2Fonyx%2Fpull%2F16435&source=description"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/turn-on-auto-fix-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/turn-on-auto-fix-light.svg"><img
alt="Turn on auto-fix"
src="https://www.cubic.dev/buttons/turn-on-auto-fix-light.svg"></picture></a>

<!-- End of auto-generated description by cubic. -->



<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

- **New Features**
- Chat Preferences separates chat and search settings into dedicated
sections.
- Chat-naming and search-selection models can be configured
independently, with a Global Default option that clears each
task-specific selection.
- Language-model settings include per-chat reasoning and temperature
controls, plus guidance for unavailable router models.
- Workspace and user language selection supports a Global Default
option.

- **Improvements**
- Model selectors exclude hidden models except when a hidden model is
already selected.
- Settings provide inline loading and error feedback; success
notifications are no longer shown for many actions.
- Updated terminology and translations, including the setting for
starting chats with an agent.
  - Input layouts support explicit error and warning messages.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-10-22 01:47:24 +00:00
..

React Integration Testing Guide

Comprehensive guide for writing integration tests in the Onyx web application using Jest and React Testing Library.

Playwright end-to-end tests live under web/tests/e2e/. See web/tests/e2e/README.md for the rules that apply there (Page Object Model, auto-retrying matchers).

Table of Contents

Running Tests

# Run all tests
bun run test

# Run specific test file
bun run test -- EmailPasswordForm.test

# Run tests matching pattern
bun run test -- --testPathPattern="auth"

# Run without coverage
bun run test -- --no-coverage

# Run in watch mode
bun run test -- --watch

# Run with verbose output
bun run test -- --verbose

Core Concepts

Test Structure

Tests are co-located with source files for easy discovery and maintenance:

src/app/auth/login/
├── EmailPasswordForm.tsx
└── EmailPasswordForm.test.tsx

Test Anatomy

Every test follows this structure:

import { render, screen, setupUser, waitFor } from "@tests/setup/test-utils";
import MyComponent from "./MyComponent";

test("descriptive test name explaining user behavior", async () => {
  // 1. Setup - Create user, mock APIs
  const user = setupUser();
  const fetchSpy = jest.spyOn(global, "fetch");

  fetchSpy.mockResolvedValueOnce({
    ok: true,
    json: async () => ({ data: "value" }),
  } as Response);

  // 2. Render - Display the component
  render(<MyComponent />);

  // 3. Act - Simulate user interactions
  await user.type(screen.getByRole("textbox"), "test input");
  await user.click(screen.getByRole("button", { name: /submit/i }));

  // 4. Assert - Verify expected outcomes
  await waitFor(() => {
    expect(screen.getByText(/success/i)).toBeInTheDocument();
  });

  // 5. Cleanup - Restore mocks
  fetchSpy.mockRestore();
});

setupUser() - Automatic act() Wrapping

ALWAYS use setupUser() instead of userEvent.setup()

// ✅ Correct - Automatic act() wrapping
const user = setupUser();
await user.click(button);
await user.type(input, "text");

// ❌ Wrong - Manual act() required, verbose
const user = userEvent.setup();
await act(async () => {
  await user.click(button);
});

The setupUser() helper automatically wraps all user interactions in React's act() to prevent warnings and ensure proper state updates.

Writing Tests

Query Selectors

Use queries in this priority order (most accessible first):

1. Role Queries (Preferred)

// Buttons
screen.getByRole("button", { name: /submit/i });
screen.getByRole("button", { name: /cancel/i });

// Text inputs
screen.getByRole("textbox", { name: /email/i });

// Checkboxes
screen.getByRole("checkbox", { name: /remember me/i });

// Links
screen.getByRole("link", { name: /learn more/i });

// Headings
screen.getByRole("heading", { name: /welcome/i });

2. Label Queries

// For form inputs with labels
screen.getByLabelText(/password/i);
screen.getByLabelText(/email address/i);

3. Placeholder Queries

// When no label exists
screen.getByPlaceholderText(/enter email/i);

4. Text Queries

// For non-interactive text
screen.getByText(/welcome back/i);
screen.getByText(/error occurred/i);

Query Variants

// getBy - Throws error if not found (immediate)
screen.getByRole("button");

// queryBy - Returns null if not found (checking absence)
expect(screen.queryByText(/error/i)).not.toBeInTheDocument();

// findBy - Returns promise, waits for element (async)
expect(await screen.findByText(/success/i)).toBeInTheDocument();

// getAllBy - Returns array of all matches
const inputs = screen.getAllByRole("textbox");

Query Selectors: The Wrong Way

❌ Avoid these anti-patterns:

// DON'T query by test IDs
screen.getByTestId("submit-button");

// DON'T query by class names
container.querySelector(".submit-btn");

// DON'T query by element types
container.querySelector("button");

User Interactions

Basic Interactions

const user = setupUser();

// Click
await user.click(screen.getByRole("button", { name: /submit/i }));

// Type text
await user.type(screen.getByRole("textbox"), "test input");

// Clear and type
await user.clear(input);
await user.type(input, "new value");

// Check/uncheck checkbox
await user.click(screen.getByRole("checkbox"));

// Select from dropdown
await user.selectOptions(screen.getByRole("combobox"), "option-value");

// Upload file
const file = new File(["content"], "test.txt", { type: "text/plain" });
const input = screen.getByLabelText(/upload/i);
await user.upload(input, file);

Form Interactions

test("user can fill and submit form", async () => {
  const user = setupUser();

  render(<ContactForm />);

  await user.type(screen.getByLabelText(/name/i), "John Doe");
  await user.type(screen.getByLabelText(/email/i), "john@example.com");
  await user.type(screen.getByLabelText(/message/i), "Hello!");
  await user.click(screen.getByRole("button", { name: /send/i }));

  await waitFor(() => {
    expect(screen.getByText(/message sent/i)).toBeInTheDocument();
  });
});

Async Operations

Handling Async State Updates

Rule: After triggering state changes, always wait for UI updates before asserting.

Pattern 1: findBy Queries (Simplest)

// Element appears after async operation
await user.click(createButton);
expect(await screen.findByRole("textbox")).toBeInTheDocument();

Pattern 2: waitFor (Complex Assertions)

await user.click(submitButton);

await waitFor(() => {
  expect(screen.getByText("Success")).toBeInTheDocument();
  expect(screen.getByText("Count: 5")).toBeInTheDocument();
});

Pattern 3: waitForElementToBeRemoved

await user.click(deleteButton);

await waitForElementToBeRemoved(() => screen.queryByText(/item name/i));

Common Async Mistakes

// ❌ Wrong - getBy immediately after state change
await user.click(button);
expect(screen.getByText("Updated")).toBeInTheDocument(); // May fail!

// ✅ Correct - Wait for state update
await user.click(button);
expect(await screen.findByText("Updated")).toBeInTheDocument();

// ❌ Wrong - Multiple getBy calls without waiting
await user.click(button);
expect(screen.getByText("Success")).toBeInTheDocument();
expect(screen.getByText("Data loaded")).toBeInTheDocument();

// ✅ Correct - Single waitFor with multiple assertions
await user.click(button);
await waitFor(() => {
  expect(screen.getByText("Success")).toBeInTheDocument();
  expect(screen.getByText("Data loaded")).toBeInTheDocument();
});

Mocking

Mocking fetch API

IMPORTANT: Always document which endpoint each mock corresponds to using comments.

let fetchSpy: jest.SpyInstance;

beforeEach(() => {
  fetchSpy = jest.spyOn(global, "fetch");
});

afterEach(() => {
  fetchSpy.mockRestore();
});

test("fetches data successfully", async () => {
  // Mock GET /api/data
  fetchSpy.mockResolvedValueOnce({
    ok: true,
    json: async () => ({ data: [1, 2, 3] }),
  } as Response);

  render(<MyComponent />);

  await waitFor(() => {
    expect(fetchSpy).toHaveBeenCalledWith("/api/data");
  });
});

Why comment the endpoint? Sequential mocks can be confusing. Comments make it clear which API call each mock corresponds to, making tests easier to understand and maintain.

Multiple API Calls

Pattern: Document each endpoint with a comment, then verify it was called correctly.

test("handles multiple API calls", async () => {
  const user = setupUser();

  // Mock GET /api/items
  fetchSpy.mockResolvedValueOnce({
    ok: true,
    json: async () => ({ items: [] }),
  } as Response);

  // Mock POST /api/items
  fetchSpy.mockResolvedValueOnce({
    ok: true,
    json: async () => ({ id: 1, name: "New Item" }),
  } as Response);

  render(<MyComponent />);

  // Verify GET was called
  await waitFor(() => {
    expect(fetchSpy).toHaveBeenCalledWith("/api/items");
  });

  await user.click(screen.getByRole("button", { name: /create/i }));

  // Verify POST was called
  await waitFor(() => {
    expect(fetchSpy).toHaveBeenCalledWith(
      "/api/items",
      expect.objectContaining({ method: "POST" })
    );
  });
});

Three API calls example:

test("test, create, and set as default", async () => {
  const user = setupUser();

  // Mock POST /api/llm/test
  fetchSpy.mockResolvedValueOnce({
    ok: true,
    json: async () => ({}),
  } as Response);

  // Mock PUT /api/llm/provider?is_creation=true
  fetchSpy.mockResolvedValueOnce({
    ok: true,
    json: async () => ({ id: 5, name: "New Provider" }),
  } as Response);

  // Mock POST /api/llm/provider/5/default
  fetchSpy.mockResolvedValueOnce({
    ok: true,
    json: async () => ({}),
  } as Response);

  render(<MyForm />);

  await user.type(screen.getByLabelText(/name/i), "New Provider");
  await user.click(screen.getByRole("button", { name: /create/i }));

  // Verify all three endpoints were called
  await waitFor(() => {
    expect(fetchSpy).toHaveBeenCalledWith(
      "/api/llm/test",
      expect.objectContaining({ method: "POST" })
    );
    expect(fetchSpy).toHaveBeenCalledWith(
      "/api/llm/provider",
      expect.objectContaining({ method: "PUT" })
    );
    expect(fetchSpy).toHaveBeenCalledWith(
      "/api/llm/provider/5/default",
      expect.objectContaining({ method: "POST" })
    );
  });
});

Verifying Request Body

test("sends correct data", async () => {
  const user = setupUser();

  fetchSpy.mockResolvedValueOnce({
    ok: true,
    json: async () => ({}),
  } as Response);

  render(<MyForm />);

  await user.type(screen.getByLabelText(/name/i), "Test");
  await user.click(screen.getByRole("button", { name: /submit/i }));

  await waitFor(() => {
    expect(fetchSpy).toHaveBeenCalled();
  });

  const callArgs = fetchSpy.mock.calls[0];
  const requestBody = JSON.parse(callArgs[1].body);

  expect(requestBody).toEqual({
    name: "Test",
    active: true,
  });
});

Mocking Errors

test("displays error message on failure", async () => {
  // Mock GET /api/data (network error)
  fetchSpy.mockRejectedValueOnce(new Error("Network error"));

  render(<MyComponent />);

  await waitFor(() => {
    expect(screen.getByText(/failed to load/i)).toBeInTheDocument();
  });
});

test("handles API error response", async () => {
  // Mock POST /api/items (server error)
  fetchSpy.mockResolvedValueOnce({
    ok: false,
    status: 500,
  } as Response);

  render(<MyComponent />);

  await waitFor(() => {
    expect(screen.getByText(/something went wrong/i)).toBeInTheDocument();
  });
});

Mocking Next.js Router

// At top of test file
jest.mock("next/navigation", () => ({
  useRouter: () => ({
    push: jest.fn(),
    back: jest.fn(),
    refresh: jest.fn(),
  }),
  usePathname: () => "/current-path",
}));

Common Patterns

Testing CRUD Operations

describe("User Management", () => {
  test("creates new user", async () => {
    const user = setupUser();

    fetchSpy.mockResolvedValueOnce({
      ok: true,
      json: async () => ({ id: 1, name: "New User" }),
    } as Response);

    render(<UserForm />);

    await user.type(screen.getByLabelText(/name/i), "New User");
    await user.click(screen.getByRole("button", { name: /create/i }));

    await waitFor(() => {
      expect(screen.getByText(/user created/i)).toBeInTheDocument();
    });
  });

  test("edits existing user", async () => {
    const user = setupUser();

    fetchSpy.mockResolvedValueOnce({
      ok: true,
      json: async () => ({ id: 1, name: "Updated User" }),
    } as Response);

    render(<UserForm initialData={{ id: 1, name: "Old Name" }} />);

    await user.clear(screen.getByLabelText(/name/i));
    await user.type(screen.getByLabelText(/name/i), "Updated User");
    await user.click(screen.getByRole("button", { name: /save/i }));

    await waitFor(() => {
      expect(screen.getByText(/user updated/i)).toBeInTheDocument();
    });
  });

  test("deletes user", async () => {
    const user = setupUser();

    fetchSpy.mockResolvedValueOnce({
      ok: true,
      json: async () => ({}),
    } as Response);

    render(<UserList />);

    await waitFor(() => {
      expect(screen.getByText("John Doe")).toBeInTheDocument();
    });

    await user.click(screen.getByRole("button", { name: /delete/i }));

    await waitFor(() => {
      expect(screen.queryByText("John Doe")).not.toBeInTheDocument();
    });
  });
});

Testing Conditional Rendering

test("shows edit form when edit button clicked", async () => {
  const user = setupUser();

  render(<MyComponent />);

  expect(screen.queryByRole("textbox")).not.toBeInTheDocument();

  await user.click(screen.getByRole("button", { name: /edit/i }));

  expect(await screen.findByRole("textbox")).toBeInTheDocument();
});

test("toggles between states", async () => {
  const user = setupUser();

  render(<Toggle />);

  const button = screen.getByRole("button", { name: /show details/i });

  await user.click(button);
  expect(await screen.findByText(/details content/i)).toBeInTheDocument();

  await user.click(button);
  expect(screen.queryByText(/details content/i)).not.toBeInTheDocument();
});

Testing Lists and Tables

test("displays list of items", async () => {
  fetchSpy.mockResolvedValueOnce({
    ok: true,
    json: async () => ({
      items: [
        { id: 1, name: "Item 1" },
        { id: 2, name: "Item 2" },
        { id: 3, name: "Item 3" },
      ],
    }),
  } as Response);

  render(<ItemList />);

  await waitFor(() => {
    expect(screen.getByText("Item 1")).toBeInTheDocument();
    expect(screen.getByText("Item 2")).toBeInTheDocument();
    expect(screen.getByText("Item 3")).toBeInTheDocument();
  });
});

test("filters items", async () => {
  const user = setupUser();

  render(<FilterableList items={mockItems} />);

  await user.type(screen.getByRole("searchbox"), "specific");

  await waitFor(() => {
    expect(screen.getByText("Specific Item")).toBeInTheDocument();
    expect(screen.queryByText("Other Item")).not.toBeInTheDocument();
  });
});

Testing Validation

test("shows validation errors", async () => {
  const user = setupUser();

  render(<LoginForm />);

  await user.click(screen.getByRole("button", { name: /submit/i }));

  await waitFor(() => {
    expect(screen.getByText(/email is required/i)).toBeInTheDocument();
    expect(screen.getByText(/password is required/i)).toBeInTheDocument();
  });
});

test("clears validation on valid input", async () => {
  const user = setupUser();

  render(<LoginForm />);

  await user.click(screen.getByRole("button", { name: /submit/i }));

  await waitFor(() => {
    expect(screen.getByText(/email is required/i)).toBeInTheDocument();
  });

  await user.type(screen.getByLabelText(/email/i), "valid@email.com");

  await waitFor(() => {
    expect(screen.queryByText(/email is required/i)).not.toBeInTheDocument();
  });
});

Testing Philosophy

What to Test

✅ Test user-visible behavior:

  • Forms can be filled and submitted
  • Buttons trigger expected actions
  • Success/error messages appear
  • Navigation works correctly
  • Data is displayed after loading
  • Validation errors show and clear appropriately

✅ Test integration points:

  • API calls are made with correct parameters
  • Responses are handled properly
  • Error states are handled
  • Loading states appear

❌ Don't test implementation details:

  • Internal state values
  • Component lifecycle methods
  • CSS class names
  • Specific React hooks being used

Test Naming

Write test names that describe user behavior:

// ✅ Good - Describes what user can do
test("user can create new prompt", async () => {});
test("shows error when API call fails", async () => {});
test("filters items by search term", async () => {});

// ❌ Bad - Implementation-focused
test("handleSubmit is called", async () => {});
test("state updates correctly", async () => {});
test("renders without crashing", async () => {});

Minimal Mocking

Only mock external dependencies:

// ✅ Mock external APIs
jest.spyOn(global, "fetch");

// ✅ Mock Next.js router
jest.mock("next/navigation");

// ✅ Mock problematic packages
// (configured in tests/setup/__mocks__)

// ❌ Don't mock application code
// ❌ Don't mock component internals
// ❌ Don't mock utility functions

Troubleshooting

"Not wrapped in act()" Warning

Solution: Always use setupUser() instead of userEvent.setup()

// ✅ Correct
const user = setupUser();

// ❌ Wrong
const user = userEvent.setup();

"Unable to find element" Error

Solution: Element hasn't appeared yet, use findBy or waitFor

// ❌ Wrong - getBy doesn't wait
await user.click(button);
expect(screen.getByText("Success")).toBeInTheDocument();

// ✅ Correct - findBy waits
await user.click(button);
expect(await screen.findByText("Success")).toBeInTheDocument();

"Multiple elements found" Error

Solution: Be more specific with your query

// ❌ Too broad
screen.getByRole("button");

// ✅ Specific
screen.getByRole("button", { name: /submit/i });

Test Times Out

Causes:

  1. Async operation never completes
  2. Waiting for element that never appears
  3. Missing mock for API call

Solutions:

// Check fetch is mocked
expect(fetchSpy).toHaveBeenCalled()

// Use queryBy to check if element exists
expect(screen.queryByText("Text")).toBeInTheDocument()

// Verify mock is set up before render
fetchSpy.mockResolvedValueOnce(...)
render(<Component />)

Examples

See comprehensive test examples:

  • src/components/admin/connectors/AccessTypeForm.test.tsx - Form defaults that depend on permissions
  • src/views/SkillsPage.test.tsx - Optimistic updates, conditional rendering
  • src/sections/modals/skills/CreateSkillModal.test.tsx - Modal workflows, pending and error states

Built-in Mocks

Only essential mocks in tests/setup/__mocks__/:

  • UserProvider - Removes auth requirement for tests
  • react-markdown / remark-gfm - ESM compatibility

See tests/setup/__mocks__/README.md for details.