Files
onyx/web/tests
roshan bf62f4bf65 feat(admin): add opt-in Decision Models configuration (#16276)
## Summary

Add Admin > Decision Models for TypeSafe AI, OpenAI, and OpenRouter.
Decision-model selection starts unset and never falls back to the chat
default.

Decision providers have dedicated save and credential-test endpoints.
Their request accepts connection fields and explicit
`selected_model_names`. It rejects chat settings. The database operation
changes decision selections while preserving shared chat settings,
visibility, defaults, and access.

The dedicated decision form separates catalog capability from saved
selection. Catalog refresh and auto-mode synchronization do not select
additional decision models. Clear the selected workspace decision model
before removing its model or provider.

Remove decision-scope flags and branches from the shared chat provider
forms and updates. Shared model settings remain on Language Models. No
classification or review workflow ships in this stack.

## Validation

- 247 related backend unit tests passed, including stored-capability
preservation.
- 15 tests against isolated PostgreSQL passed.
- 24 frontend component tests and full web type checks passed.
- Live requests through the frontend proxy verified selection changes,
chat preservation, and rejection of chat settings.
- All applicable pre-commit hooks passed, including Python and
TypeScript types, Ruff, oxlint, oxfmt, secret scanning, and feature-map
integrity.
- The standard local Playwright setup requires paid group APIs
unavailable on this local deployment. The browser test did not reach its
assertions.

## Stack testing (`29eec61fb4`)

Tested the full stack at the top commit on a local dev stack, desktop
and 390×844 mobile. OpenAI decisions and chat used a real key. TypeSafe,
Zoom, OpenRouter, and Vercel AI Gateway used local mock upstreams; Onyx
auth, validation, persistence, encryption, and UI were real.

- Passed: decision selection starts **Not configured**, independent of
the chat default.
- Passed: live OpenAI decision validation saved `gpt-6-luna`. OpenRouter
discovery and validation worked; bad keys fail visibly.
- Passed: decision saves keep shared chat settings, visibility,
defaults, and access (DB before/after compared).
- Passed: save/test endpoints reject chat settings (422). Catalog
refresh and chat auto-mode do not add decision selections.
- Passed: model and provider removal are blocked until the workspace
selection is cleared.
- Passed (regression): live GPT-5 Mini chat works; decision-only models
are absent from the chat picker.
- **Fixed (`37c248ec04`):** two UI bugs found here. See *Fix retest*
below.

![Decision Models desktop and
mobile](https://app.devin.ai/api/presigned_proxy?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjpudWxsLCJidWNrZXRfbmFtZSI6ImRldmluYXR0YWNobWVudHMiLCJidWNrZXRfa2V5IjoiYXR0YWNobWVudHNfcHJpdmF0ZS9vcmdfT2NlNnlnR2RrdGJQekZsaS82ZTdiZjg3My1kNWYwLTQyOTMtYjk0ZS03NWM1OTFiMDdhMTYiLCJpYXQiOjE3OTI2MTIwNTgsIm9yZ19pZCI6Im9yZ19PY2U2eWdHZGt0YlB6RmxpIiwiZmlsZW5hbWUiOiJkZWNpc2lvbi1tb2RlbHMtZGVza3RvcC1tb2JpbGUucG5nIn0.y0LxVR9yzp-2FviQg6sUUSc79oyekW7PhO9jd1sA2U0)

![OpenAI decision
provider](https://app.devin.ai/api/presigned_proxy?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjpudWxsLCJidWNrZXRfbmFtZSI6ImRldmluYXR0YWNobWVudHMiLCJidWNrZXRfa2V5IjoiYXR0YWNobWVudHNfcHJpdmF0ZS9vcmdfT2NlNnlnR2RrdGJQekZsaS9lYmNlMWNiMC1lZmVkLTQyNDQtYWU5Zi1hNjQ0NWM4MDAzYjciLCJpYXQiOjE3OTI2MTIwNTgsIm9yZ19pZCI6Im9yZ19PY2U2eWdHZGt0YlB6RmxpIiwiZmlsZW5hbWUiOiJvcGVuYWktZGVza3RvcC1tb2JpbGUucG5nIn0.viITouz1WwL-KPGjLAOOxhO2O8OH78a0-f9Ww6IhJHg)

![OpenRouter decision
provider](https://app.devin.ai/api/presigned_proxy?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjpudWxsLCJidWNrZXRfbmFtZSI6ImRldmluYXR0YWNobWVudHMiLCJidWNrZXRfa2V5IjoiYXR0YWNobWVudHNfcHJpdmF0ZS9vcmdfT2NlNnlnR2RrdGJQekZsaS8wMTQzMzRlYS1iNjE0LTQ1ZjktOGM3MC1hMWE5ZDAyMWU2NWUiLCJpYXQiOjE3OTI2MTIwNTgsIm9yZ19pZCI6Im9yZ19PY2U2eWdHZGt0YlB6RmxpIiwiZmlsZW5hbWUiOiJvcGVucm91dGVyLWRlc2t0b3AtbW9iaWxlLnBuZyJ9.-k_vhxArLC0sn-efnND-4PrdBaCXCbvXIyODhgYM2O0)

![Live OpenAI decision
flow](https://app.devin.ai/api/presigned_proxy?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjpudWxsLCJidWNrZXRfbmFtZSI6ImRldmluYXR0YWNobWVudHMiLCJidWNrZXRfa2V5IjoiYXR0YWNobWVudHNfcHJpdmF0ZS9vcmdfT2NlNnlnR2RrdGJQekZsaS81NjEyY2VlZC0zMDEyLTRhNDUtYjhiZi1jMWUwNTVjN2EwNDgiLCJpYXQiOjE3OTI2MTIwNTgsIm9yZ19pZCI6Im9yZ19PY2U2eWdHZGt0YlB6RmxpIiwiZmlsZW5hbWUiOiJvcGVuYWktZGVjaXNpb24tZmxvdy53ZWJwIn0.wnYGxw94Kj9NClKTgrANKSLib0ToOs1eROCqesZAdfs)

![Removal
guard](https://app.devin.ai/api/presigned_proxy?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjpudWxsLCJidWNrZXRfbmFtZSI6ImRldmluYXR0YWNobWVudHMiLCJidWNrZXRfa2V5IjoiYXR0YWNobWVudHNfcHJpdmF0ZS9vcmdfT2NlNnlnR2RrdGJQekZsaS82NGUxM2JjOC04YjMwLTRlNmEtYjNhYy00ZDcxOWIxZDMyYTIiLCJpYXQiOjE3OTI2MTIwNTgsIm9yZ19pZCI6Im9yZ19PY2U2eWdHZGt0YlB6RmxpIiwiZmlsZW5hbWUiOiJkZXNrdG9wLWRlbGV0ZS1ndWFyZC5wbmcifQ.QD54xAub9H33NzJCgbJhnJkKI36XdfjvRPFRyu4VUqo)

![Live chat
regression](https://app.devin.ai/api/presigned_proxy?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjpudWxsLCJidWNrZXRfbmFtZSI6ImRldmluYXR0YWNobWVudHMiLCJidWNrZXRfa2V5IjoiYXR0YWNobWVudHNfcHJpdmF0ZS9vcmdfT2NlNnlnR2RrdGJQekZsaS9lODJlNGM5Zi0xZjBiLTQxYzAtYTNjZS00M2E0YTFhNmU4ZjMiLCJpYXQiOjE3OTI2MTIwNTgsIm9yZ19pZCI6Im9yZ19PY2U2eWdHZGt0YlB6RmxpIiwiZmlsZW5hbWUiOiJyZWdyZXNzaW9uLWxpdmUtb3BlbmFpLWNoYXQucG5nIn0._YvK1bOI35c1BUkJ8Ubdlzxyx2K7FQs_CfPhqWs44Vo)

## Fix retest (`78cf4ccb12`)

`37c248ec04` fixes two bugs found by the stack test:
- Decision Models showed a deleted provider (e.g. Zoom removed on Voice)
as Configure/Delete until a full reload. `useAdminLanguageModels` now
takes `{ revalidateIfStale }`; Decision Models passes `true`, so the
cached provider list refetches on mount.
- Configure headings rendered literal asterisks (`Configure *Vercel AI
Gateway*`). `DecisionProviderModal` now wraps the title in `markdown()`,
like the shared LLM modal.

Retested in the UI, no page reloads:
- Passed: Zoom connected on Voice and Decision Models, selection
cleared, Zoom disconnected on Voice. Sidebar navigation to Decision
Models showed **Connect**.
- Passed: a Gateway connection made on Language Models showed
**Configure** on Decision Models.
- Passed: Gateway and OpenAI Configure headings show no asterisks.
- Passed: both pages load, and display-name edits save and persist.
- Jest: new regression test for a stale cached provider list, and a
heading assertion.

![Zoom card before and after Voice
disconnect](https://app.devin.ai/api/presigned_proxy?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjpudWxsLCJidWNrZXRfbmFtZSI6ImRldmluYXR0YWNobWVudHMiLCJidWNrZXRfa2V5IjoiYXR0YWNobWVudHNfcHJpdmF0ZS9vcmdfT2NlNnlnR2RrdGJQekZsaS82MzUxZWYwMi0zN2Q3LTRiYWItOWE5My01NWM1NTcyMDQ2ZDYiLCJpYXQiOjE3OTI2MTIwNTgsIm9yZ19pZCI6Im9yZ19PY2U2eWdHZGt0YlB6RmxpIiwiZmlsZW5hbWUiOiJ6b29tLWNhY2hlLWJlZm9yZS1hZnRlci5wbmcifQ.WFkOy2ZxZz4NccAYlxpAcloWWl2DQx-GtsyP2by5PFw)

![Cache navigation
recording](https://app.devin.ai/api/presigned_proxy?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjpudWxsLCJidWNrZXRfbmFtZSI6ImRldmluYXR0YWNobWVudHMiLCJidWNrZXRfa2V5IjoiYXR0YWNobWVudHNfcHJpdmF0ZS9vcmdfT2NlNnlnR2RrdGJQekZsaS9hN2QwMzhjMS04YzQwLTQ1ZWQtYjZhOC1mYzViNjM1ODc1NjQiLCJpYXQiOjE3OTI2MTIwNTgsIm9yZ19pZCI6Im9yZ19PY2U2eWdHZGt0YlB6RmxpIiwiZmlsZW5hbWUiOiJjYWNoZS1uYXZpZ2F0aW9uLndlYnAifQ.PKq-o2BIeHa7Y9wjSDZ3BkNdMKHR0kK_EtNztu_8s0M)

![Configure
headings](https://app.devin.ai/api/presigned_proxy?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjpudWxsLCJidWNrZXRfbmFtZSI6ImRldmluYXR0YWNobWVudHMiLCJidWNrZXRfa2V5IjoiYXR0YWNobWVudHNfcHJpdmF0ZS9vcmdfT2NlNnlnR2RrdGJQekZsaS81NTU2NzMzZC02NjBiLTQ4YjItOTdmOC05MzFmYWFiMWU0ODMiLCJpYXQiOjE3OTI2MTIwNTgsIm9yZ19pZCI6Im9yZ19PY2U2eWdHZGt0YlB6RmxpIiwiZmlsZW5hbWUiOiJjb25maWd1cmUtaGVhZGluZ3MucG5nIn0.8UqmY8ZjvoxXFml-rLNrfj1lCrqb_1MpRql8Zvyb2WQ)

## Stack

PR 2 of 4: #16262 → **#16276** → #16280 → #16284.

Link to Devin session:
https://app.devin.ai/sessions/39d77bde91c34b75922bb05c02b93b90
Open in Devin Desktop:
https://app.devin.ai/desktop/session/39d77bde91c34b75922bb05c02b93b90?variant=devin
Requested by: @rohoswagger
2026-10-21 23:51:27 +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.