Files
onyx/web/tests
roshan fcc0cae05a feat(llm): share Zoom setup across Voice and Decisions (#16280)
## Summary

Add Zoom Decisions and Zoom Decisions Pro to **Admin > Decision
Models**.

Configure Zoom from either Voice or Decision Models. Both pages use one
shared connection and one encrypted API key and secret. Zoom Decisions
is opt-in: a Zoom decision provider exists only after an admin connects
Zoom on Decision Models. A Voice-only setup never creates one. Decision
setup does not activate Voice defaults or select a decision model.

Read current credentials and sign a fresh JWT for each request. Keep
Zoom decision models out of chat. Removing Zoom from Decision Models
deletes only the decision provider and keeps Scribe. Disconnecting Zoom
on Voice also removes its linked decision provider, after selected-model
checks. The migration only adds the link column; it writes no rows.

Both setup links open the [Zoom AI Services
documentation](https://developers.zoom.us/docs/ai-services/).

## Validation

- 336 focused backend tests and nine frontend component tests passed.
- Type checks, lint, translations, deterministic migration checks, and
touched-file hooks passed.
- A live Zoom Decision API request returned HTTP 200 with a JWT signed
using the existing Scribe credentials.
- The current-head preview opens the credential form directly from
Decision Models. No Voice setup is required first.
- PostgreSQL tests cover opt-in linking from Decision Models only,
rotation, rollback, relink and duplicate-link rejection, and deletion.

## Screenshot

Shared Zoom setup from Decision Models at `9b7792d`. Credentials are
empty. This screenshot demonstrates the form, not a completed connection
test.

![Shared Zoom AI Services
setup](https://github.com/user-attachments/assets/418e5eca-edd4-48c2-a0d4-a7103751c0cb)

## 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.

- Fixed during testing: migration `9071f636d5f2` now revises
`4d8453213439` (#16351 on main). The old `down_revision` left two
alembic heads and failed most CI jobs on this PR and #16284.
- Passed: Voice-only Zoom setup creates no decision provider. Decision
Models opens the shared credential form directly.
- Passed: one encrypted Voice connection; the linked LLM provider stores
no duplicate key. JWT validation and the Scribe WebSocket handshake
worked against the mock.
- Passed: bad credentials fail. Both setup links open the Zoom AI
Services docs.
- Passed: selection guards block Zoom removal and Voice disconnect.
Removing Zoom from Decision Models keeps Voice/Scribe. Zoom decision
models are absent from chat.
- **Fixed in #16276 (`37c248ec04`):** after Zoom is disconnected on
Voice, sidebar navigation to Decision Models now shows **Connect**
without a reload (retested at `78cf4ccb12`). Before the fix, the card
showed **Configure/Delete** from a stale `adminLlmProvidersPaged` SWR
cache.

![Before the fix: stale Zoom card vs. after
reload](https://app.devin.ai/api/presigned_proxy?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjpudWxsLCJidWNrZXRfbmFtZSI6ImRldmluYXR0YWNobWVudHMiLCJidWNrZXRfa2V5IjoiYXR0YWNobWVudHNfcHJpdmF0ZS9vcmdfT2NlNnlnR2RrdGJQekZsaS9iMmMyNWEwZS0wNWVhLTQyYmUtODFmMy05YWFkYzQwYmYzNzciLCJpYXQiOjE3OTI2MTIwNjEsIm9yZ19pZCI6Im9yZ19PY2U2eWdHZGt0YlB6RmxpIiwiZmlsZW5hbWUiOiJ6b29tLXN0YWxlLWJlZm9yZS1hZnRlci5wbmcifQ.Y0FiVPDvizzJT-GX494OGq6lsQoIrwMd7WHrwFzBWqQ)

![Zoom on Decision Models, desktop and
mobile](https://app.devin.ai/api/presigned_proxy?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjpudWxsLCJidWNrZXRfbmFtZSI6ImRldmluYXR0YWNobWVudHMiLCJidWNrZXRfa2V5IjoiYXR0YWNobWVudHNfcHJpdmF0ZS9vcmdfT2NlNnlnR2RrdGJQekZsaS8wZWJiYmUxOC03Mjk5LTQ1ZTYtOGE2MC1iYjAzNThhZTgyMzgiLCJpYXQiOjE3OTI2MTIwNjEsIm9yZ19pZCI6Im9yZ19PY2U2eWdHZGt0YlB6RmxpIiwiZmlsZW5hbWUiOiJ6b29tLWRlc2t0b3AtbW9iaWxlLnBuZyJ9.-EcWczrz3n2IdSdZR2J8WWS3-hl0q8qxwVvoq3K6hOU)

![Zoom on Voice, desktop and
mobile](https://app.devin.ai/api/presigned_proxy?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjpudWxsLCJidWNrZXRfbmFtZSI6ImRldmluYXR0YWNobWVudHMiLCJidWNrZXRfa2V5IjoiYXR0YWNobWVudHNfcHJpdmF0ZS9vcmdfT2NlNnlnR2RrdGJQekZsaS8yMGUwMWVlNi0wYzMwLTQxOTItOWU3My01NDJlMTY0YzczM2QiLCJpYXQiOjE3OTI2MTIwNjEsIm9yZ19pZCI6Im9yZ19PY2U2eWdHZGt0YlB6RmxpIiwiZmlsZW5hbWUiOiJ6b29tLXZvaWNlLWRlc2t0b3AtbW9iaWxlLnBuZyJ9.bJbNbAOJVKDduNobJUs-67TDqIwVssOhfATOxKx8usc)

![Zoom shared flow and stale card (before the
fix)](https://app.devin.ai/api/presigned_proxy?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjpudWxsLCJidWNrZXRfbmFtZSI6ImRldmluYXR0YWNobWVudHMiLCJidWNrZXRfa2V5IjoiYXR0YWNobWVudHNfcHJpdmF0ZS9vcmdfT2NlNnlnR2RrdGJQekZsaS84NDk2ZWI1ZS1hYTVlLTQyN2EtYTllNC01YWExZjVhNTI1NWIiLCJpYXQiOjE3OTI2MTIwNjEsIm9yZ19pZCI6Im9yZ19PY2U2eWdHZGt0YlB6RmxpIiwiZmlsZW5hbWUiOiJ6b29tLXNoYXJlZC1mbG93LWFuZC1zdGFsZS1jYXJkLndlYnAifQ.BVuTJXEo4tadBG-oIc9li-9eTNJDFmEHnlXc-zWy_HM)

![After the fix: Voice disconnect then Decision
Models](https://app.devin.ai/api/presigned_proxy?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjpudWxsLCJidWNrZXRfbmFtZSI6ImRldmluYXR0YWNobWVudHMiLCJidWNrZXRfa2V5IjoiYXR0YWNobWVudHNfcHJpdmF0ZS9vcmdfT2NlNnlnR2RrdGJQekZsaS8wZDg2MzIzYS1jZmQ1LTQyYzYtYjRiNy1mMmRiYWM4NWFiNDEiLCJpYXQiOjE3OTI2MTIwNjEsIm9yZ19pZCI6Im9yZ19PY2U2eWdHZGt0YlB6RmxpIiwiZmlsZW5hbWUiOiJjYWNoZS1uYXZpZ2F0aW9uLndlYnAifQ.aElIlomXJ3AkNjokrig55oTDvAd1FONQuUsCxyqbM-U)

## Stack

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


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

* **New Features**
* Added Zoom AI Decisions with two models supporting text and image
inputs.
* Added shared Zoom AI Services setup across Voice and Decision Models,
including connection selection, credential reuse, rotation, and removal.
* Added provider discovery, model capability indicators,
decision-specific validation, and localized interface text.
  * Renamed Zoom Scribe setup labels to Zoom AI Services.

* **Bug Fixes**
  * Improved connection validation and rollback.
* Improved credential handling and protection against deleting active
shared connections.
* Preserved existing shared connection settings when updating providers.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

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-22 01:02:08 +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.