npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

feather-testing-convex

v0.5.7

Published

React provider that adapts convex-test's one-shot client for use with ConvexProvider, so useQuery/useMutation work in tests.

Readme

feather-testing-convex

How do you test a React component that fetches data from Convex?

| Approach | Tests backend logic | Tests component rendering | Tests the integration | Fast | Code coverage | |----------|:---:|:---:|:---:|:---:|:---:| | Backend-only (convex-test) | ✅ | ❌ | ❌ | ✅ | Partial | | Component with mocks (vi.mock) | ❌ | ✅ | ❌ | ✅ | Partial | | E2E (Playwright) | ✅ | ✅ | ✅ | ❌ | None | | This library | | | | | Full |

Say you have a <TodoList> component that calls useQuery(api.todos.list) to fetch and display todos.

If you test the backend alone (convex-test), you can prove the query returns the right data — but you have no idea if your React component actually calls it correctly or renders the result. You've tested half the picture.

If you test the component alone (mocking useQuery), you can prove it renders whatever data you hand it — but the "data" is a mock you wrote. It can drift from what the backend actually returns. Your test passes, your app is broken.

If you write a Playwright E2E test, you get real integration — but it's slow, needs a running backend, and gives you no code coverage.

There's a gap in the middle: no fast, in-process way to test a React component against a real Convex backend.

This library fills that gap

feather-testing-convex wires convex-test's in-memory backend into React's provider tree. Your component calls useQuery → hits a real Convex function → gets real data → renders it. All in Vitest. No mocks, no server, full coverage.

test("shows todos", async ({ client, seed }) => {
  await seed("todos", { text: "Buy milk", completed: false });
  renderWithConvex(<TodoList />, client);
  expect(await screen.findByText("Buy milk")).toBeInTheDocument();
});

The MECE Testing Principle

MECE (Mutually Exclusive, Collectively Exhaustive) is a decomposition principle from McKinsey consulting: break a problem into buckets where nothing overlaps and nothing is missed. Applied to testing: decompose your component into visual states, then write one test per state. Within each test, assert as many aspects of that state as needed.

function TodoList() {
  const todos = useQuery(api.todos.list);
  if (todos === undefined) return <div>Loading...</div>;       // State 1: Mock
  if (todos.length === 0) return <div>No todos yet</div>;      // State 2: Integration
  return <ul>{todos.map(t => <li key={t._id}>{t.text}</li>)}</ul>;  // State 3: Integration
}

3 states → 3 tests → 100% coverage → zero overlap:

| State (bucket) | Approach | Why | |----------------|----------|-----| | Loading spinner | Mock | Transient — query resolves too fast to observe | | Empty list | Integration | Real query returns [] naturally | | With data | Integration | Real query returns real data |

Integration is the default. Mocks are the exception — only for transient states (loading spinners) and error states that can't be produced from a real backend. Never mock useQuery to return data you could seed instead.

MECE governs the Integration and Mock layers. E2E (Playwright) is a deliberate exception — it intentionally overlaps integration tests for critical user journeys (sign up, checkout, onboarding) to provide real-browser confidence. Keep E2E to ~10 smoke tests covering the paths that, if broken, mean the product is broken.

📖 Full guide: TESTING-PHILOSOPHY.md — the MECE framework, decision tree, test matrix workflow, naming convention, anti-pattern examples, 12-point review checklist, and coverage rules.


Quick Start

Three files to set up, then write your first test.

1. Install Dependencies

npm install -D convex-test feather-testing-convex @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom @vitejs/plugin-react

2. Create vitest.config.ts

import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  test: {
    environment: "jsdom",
    environmentMatchGlobs: [["convex/**", "edge-runtime"]],
    server: { deps: { inline: ["convex-test"] } },
    globals: true,
    setupFiles: ["./src/test-setup.ts"],
  },
});

3. Create convex/test.setup.ts

/// <reference types="vite/client" />
import { createConvexTest, renderWithConvex } from "feather-testing-convex";
import schema from "./schema";

export const modules = import.meta.glob("./**/!(*.*.*)*.*s");
export const test = createConvexTest(schema, modules);
export { renderWithConvex };

4. Create src/test-setup.ts

import "@testing-library/jest-dom/vitest";

5. Write Your First Test

// src/components/TodoList.test.tsx
import { describe, expect } from "vitest";
import { screen } from "@testing-library/react";
import { test, renderWithConvex } from "../../convex/test.setup";
import { TodoList } from "./TodoList";

describe("TodoList", () => {
  test("shows seeded data", async ({ client, seed }) => {
    await seed("todos", { text: "Buy milk", completed: false });
    renderWithConvex(<TodoList />, client);
    expect(await screen.findByText("Buy milk")).toBeInTheDocument();
  });
});

6. Run

npx vitest run

Features with Before/After Examples

1. Authenticated Backend Queries

Test backend functions with an auto-created authenticated user.

Before (raw convex-test — ~15 lines of boilerplate):

import { convexTest } from "convex-test";

it("returns user's todos", async () => {
  const testClient = convexTest(schema, modules);
  const userId = await testClient.run(async (ctx) => {
    return await ctx.db.insert("users", {});
  });
  const client = testClient.withIdentity({ subject: userId });
  await testClient.run(async (ctx) => {
    await ctx.db.insert("todos", { text: "Buy milk", completed: false, userId });
  });

  const todos = await client.query(api.todos.list, {});
  expect(todos).toHaveLength(1);
  expect(todos[0].text).toBe("Buy milk");
});

After (with createConvexTest fixtures — 3 lines):

test("returns user's todos", async ({ client, seed }) => {
  await seed("todos", { text: "Buy milk", completed: false });

  const todos = await client.query(api.todos.list, {});
  expect(todos).toHaveLength(1);
  expect(todos[0].text).toBe("Buy milk");
});

The test function provides client (authenticated) and seed (auto-fills userId) as fixtures.


2. Integration Tests (React Component + Real Backend)

Render a React component that calls useQuery against a real in-memory backend.

Before (manual provider wrapping):

import { convexTest } from "convex-test";
import { ConvexTestProvider } from "feather-testing-convex";

it("shows todos", async () => {
  const testClient = convexTest(schema, modules);
  const userId = await testClient.run(async (ctx) => ctx.db.insert("users", {}));
  const client = testClient.withIdentity({ subject: userId });
  await testClient.run(async (ctx) => {
    await ctx.db.insert("todos", { text: "Buy milk", completed: false, userId });
  });

  render(
    <ConvexTestProvider client={client}>
      <TodoList />
    </ConvexTestProvider>
  );

  expect(await screen.findByText("Buy milk")).toBeInTheDocument();
});

After (renderWithConvex — 3 lines):

test("shows todos", async ({ client, seed }) => {
  await seed("todos", { text: "Buy milk", completed: false });
  renderWithConvex(<TodoList />, client);
  expect(await screen.findByText("Buy milk")).toBeInTheDocument();
});

3. Data Seeding

seed(table, data) inserts a document and auto-fills userId from the default test user.

Before:

await testClient.run(async (ctx) => {
  await ctx.db.insert("todos", {
    text: "Buy milk",
    completed: false,
    userId,  // Must manually track and pass userId
  });
});

After:

await seed("todos", { text: "Buy milk", completed: false });
// userId is automatically filled from the test user

If your data includes an explicit userId, the explicit value wins:

const bob = await createUser();
await seed("todos", { text: "Bob's todo", completed: false, userId: bob.userId });

4. Multi-User Testing

Test data isolation between users with createUser().

Before:

it("users only see their own todos", async () => {
  const testClient = convexTest(schema, modules);

  // Create Alice
  const aliceId = await testClient.run(async (ctx) => ctx.db.insert("users", {}));
  const alice = testClient.withIdentity({ subject: aliceId });

  // Create Bob
  const bobId = await testClient.run(async (ctx) => ctx.db.insert("users", {}));
  const bob = testClient.withIdentity({ subject: bobId });

  // Seed data
  await alice.mutation(api.todos.create, { text: "Alice's todo" });
  await testClient.run(async (ctx) => {
    await ctx.db.insert("todos", { text: "Bob's todo", completed: false, userId: bobId });
  });

  const aliceTodos = await alice.query(api.todos.list, {});
  expect(aliceTodos).toHaveLength(1);

  const bobTodos = await bob.query(api.todos.list, {});
  expect(bobTodos).toHaveLength(1);
  expect(bobTodos[0].text).toBe("Bob's todo");
});

After:

test("users only see their own todos", async ({ client, seed, createUser }) => {
  await client.mutation(api.todos.create, { text: "Alice's todo" });

  const bob = await createUser();
  await seed("todos", { text: "Bob's todo", completed: false, userId: bob.userId });

  const aliceTodos = await client.query(api.todos.list, {});
  expect(aliceTodos).toHaveLength(1);

  const bobTodos = await bob.query(api.todos.list, {});
  expect(bobTodos).toHaveLength(1);
  expect(bobTodos[0].text).toBe("Bob's todo");
});

5. Auth State Testing

Test components that use <Authenticated>, <Unauthenticated>, useConvexAuth(), and useAuthActions().

Before (mocking auth hooks):

vi.mock("convex/react", () => ({
  useConvexAuth: vi.fn(),
  Authenticated: ({ children }) => children,
  Unauthenticated: () => null,
}));

it("shows welcome when authenticated", () => {
  vi.mocked(useConvexAuth).mockReturnValue({ isLoading: false, isAuthenticated: true });
  render(<AuthGate />);
  expect(screen.getByText("Welcome back")).toBeInTheDocument();
});

it("shows sign-in when unauthenticated", () => {
  vi.mocked(useConvexAuth).mockReturnValue({ isLoading: false, isAuthenticated: false });
  render(<AuthGate />);
  expect(screen.getByText("Please sign in")).toBeInTheDocument();
});

After (real auth state, no mocking):

test("shows welcome when authenticated", async ({ client }) => {
  renderWithConvexAuth(<AuthGate />, client);
  expect(await screen.findByText("Welcome back")).toBeInTheDocument();
});

test("shows sign-in when unauthenticated", async ({ client }) => {
  renderWithConvexAuth(<AuthGate />, client, { authenticated: false });
  expect(await screen.findByText("Please sign in")).toBeInTheDocument();
});

Prerequisites for Auth Testing

npm install -D @convex-dev/auth

Add the vitest plugin to resolve an internal @convex-dev/auth import:

// vitest.config.ts
import { convexTestProviderPlugin } from "feather-testing-convex/vitest-plugin";

export default defineConfig({
  plugins: [
    react(),
    convexTestProviderPlugin(),
  ],
  // ... rest of config unchanged
});

Update your test setup to export renderWithConvexAuth:

// convex/test.setup.ts
import { createConvexTest, renderWithConvex, renderWithConvexAuth } from "feather-testing-convex";
import schema from "./schema";

export const modules = import.meta.glob("./**/!(*.*.*)*.*s");
export const test = createConvexTest(schema, modules);
export { renderWithConvex, renderWithConvexAuth };

6. Sign In / Sign Out Flows

Test interactive auth flows — signIn() and signOut() toggle real React state.

Before (mocking useAuthActions):

const mockSignOut = vi.fn();
vi.mock("@convex-dev/auth/react", () => ({
  useAuthActions: () => ({ signIn: vi.fn(), signOut: mockSignOut }),
}));

it("sign out works", async () => {
  const user = userEvent.setup();
  render(<App />);
  await user.click(screen.getByRole("button", { name: /sign out/i }));
  expect(mockSignOut).toHaveBeenCalled();
  // But does the UI actually change? This test doesn't verify that!
});

After (real state toggle, UI verification):

test("sign out toggles the view", async ({ client }) => {
  const user = userEvent.setup();
  renderWithConvexAuth(<App />, client);

  expect(await screen.findByText("Welcome back")).toBeInTheDocument();

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

  expect(await screen.findByText("Please sign in")).toBeInTheDocument();
  expect(screen.queryByText("Welcome back")).not.toBeInTheDocument();
});

7. Sign-In Error Simulation

Test how your component handles authentication errors.

Before:

const mockSignIn = vi.fn().mockRejectedValue(new Error("Invalid credentials"));
// ... complex mock setup

After:

test("shows error on failed sign-in", async ({ client }) => {
  const user = userEvent.setup();
  renderWithConvexAuth(<App />, client, {
    authenticated: false,
    signInError: new Error("Invalid credentials"),
  });

  await user.click(screen.getByRole("button", { name: /sign in/i }));
  expect(await screen.findByText("Invalid credentials")).toBeInTheDocument();
});

8. Fluent Session DSL

Write readable, chainable test interactions using the Session DSL from feather-testing-convex/rtl. One fluent chain replaces multiple userEvent + screen calls.

Before (verbose Testing Library calls):

test("user creates a todo", async ({ client }) => {
  const user = userEvent.setup();
  renderWithConvexAuth(<App />, client);

  await user.type(screen.getByLabelText("Task"), "Buy groceries");
  await user.click(screen.getByRole("button", { name: "Add Todo" }));

  expect(await screen.findByText("Buy groceries")).toBeInTheDocument();
});

After (fluent Session DSL):

import { renderWithSession } from "feather-testing-convex/rtl";

test("user creates a todo", async ({ client }) => {
  const session = renderWithSession(<App />, client);

  await session
    .fillIn("Task", "Buy groceries")
    .clickButton("Add Todo")
    .assertText("Buy groceries");
});

renderWithSession combines renderWithConvexAuth + createSession() in one call. It returns a Session object with a fluent API.

Setup for Session DSL

// convex/test.setup.ts
import { createConvexTest, renderWithConvex, renderWithConvexAuth } from "feather-testing-convex";
import { renderWithSession } from "feather-testing-convex/rtl";
import schema from "./schema";

export const modules = import.meta.glob("./**/!(*.*.*)*.*s");
export const test = createConvexTest(schema, modules);
export { renderWithConvex, renderWithConvexAuth, renderWithSession };

9. Scoped Interactions with within()

Test interactions scoped to a specific part of the page — for example, clicking a link inside a sidebar, or asserting text within a specific card.

Before (Testing Library within):

import { within } from "@testing-library/react";

test("sidebar has navigation", async ({ client }) => {
  const user = userEvent.setup();
  renderWithConvexAuth(<App />, client);

  const sidebar = await screen.findByTestId("sidebar");
  expect(within(sidebar).getByText("Home")).toBeInTheDocument();
  expect(within(sidebar).getByText("Settings")).toBeInTheDocument();

  await user.click(within(sidebar).getByRole("link", { name: "Settings" }));
  expect(await screen.findByText("Settings Page")).toBeInTheDocument();
});

After (session.within):

test("sidebar has navigation", async ({ client }) => {
  const session = renderWithSession(<App />, client);

  await session
    .within("[data-testid='sidebar']", (s) =>
      s.assertText("Home")
       .assertText("Settings")
       .clickLink("Settings")
    )
    .assertText("Settings Page");
});

within(selector, fn) creates a scoped session. Actions inside the callback only interact with elements inside the matched selector. After the callback, the chain returns to the full page scope.


10. Verifying Mutations via Backend

Since queries are one-shot (run once at mount), verify mutation results by querying the backend directly.

Before (no library — manual setup + verification):

it("adds an item", async () => {
  const testClient = convexTest(schema, modules);
  const user = userEvent.setup();

  render(
    <ConvexTestProvider client={testClient}>
      <AddButton />
    </ConvexTestProvider>
  );

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

  const items = await testClient.query(api.items.list, {});
  expect(items).toHaveLength(1);
  expect(items[0].text).toBe("From test");
});

After (with fixtures):

test("adds an item", async ({ client }) => {
  const user = userEvent.setup();
  renderWithConvex(<AddButton />, client);

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

  // Query backend directly — UI doesn't re-render after mutation (one-shot)
  const items = await client.query(api.items.list, {});
  expect(items).toHaveLength(1);
  expect(items[0].text).toBe("From test");
});

11. TanStack React Query Components

Test components that use @tanstack/react-query with @convex-dev/react-query bridge — i.e., useQuery(convexQuery(...)) instead of Convex's useQuery.

The Problem: Components using convexQuery() get undefined data with the basic ConvexTestProvider because TanStack Query needs its own QueryClientProvider and queryFn.

The Solution: Use feather-testing-convex/tanstack-query — it provides a custom queryFn that routes Convex query keys to the in-memory test backend.

Setup

npm install -D @tanstack/react-query @convex-dev/react-query
// convex/test.setup.ts
import { createConvexTest } from "feather-testing-convex";
import { renderWithConvexQuery, renderWithConvexQueryAuth } from "feather-testing-convex/tanstack-query";
import schema from "./schema";

export const modules = import.meta.glob("./**/!(*.*.*)*.*s");
export const test = createConvexTest(schema, modules);
export { renderWithConvexQuery, renderWithConvexQueryAuth };

Basic Query Test

import { useQuery } from "@tanstack/react-query";
import { convexQuery } from "@convex-dev/react-query";

function UserProfile() {
  const { data: user } = useQuery(convexQuery(api.app.getCurrentUser, {}));
  if (!user) return <div>Loading...</div>;
  return <h1>Welcome, {user.username}</h1>;
}

test("shows user profile", async ({ client, testClient, userId }) => {
  await testClient.run(async (ctx) => ctx.db.patch(userId, { username: "alice" }));
  renderWithConvexQueryAuth(<UserProfile />, client);
  expect(await screen.findByText("Welcome, alice")).toBeInTheDocument();
});

Mutation with Auto-Refresh (Key Advantage!)

Unlike the base ConvexTestProvider (one-shot), the TanStack Query provider auto-invalidates queries after mutations. The UI updates without re-mounting:

function TodoApp() {
  const { data: todos } = useQuery(convexQuery(api.todos.list, {}));
  const addTodo = useMutation(api.todos.create);  // convex/react useMutation
  if (!todos) return <div>Loading...</div>;
  return (
    <div>
      <ul>{todos.map(t => <li key={t._id}>{t.text}</li>)}</ul>
      <button onClick={() => addTodo({ text: "New todo" })}>Add</button>
    </div>
  );
}

test("adds todo and UI updates automatically", async ({ client }) => {
  const user = userEvent.setup();
  renderWithConvexQueryAuth(<TodoApp />, client);

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

  // ✅ UI auto-updates — no re-mount needed!
  expect(await screen.findByText("New todo")).toBeInTheDocument();
});

Auth-Aware Tests

test("authenticated user sees their data", async ({ client }) => {
  renderWithConvexQueryAuth(<UserProfile />, client);
  expect(await screen.findByText(/Welcome/)).toBeInTheDocument();
});

test("unauthenticated user sees nothing", async ({ testClient }) => {
  renderWithConvexQuery(<UserProfile />, testClient);
  expect(await screen.findByText("Loading...")).toBeInTheDocument();
});

Fixtures Reference

createConvexTest(schema, modules, options?) returns a custom Vitest test function with these fixtures:

| Fixture | Type | Description | |---------|------|-------------| | testClient | convex-test client | Raw unauthenticated client. Use for edge cases or direct DB access. | | userId | string | ID of an auto-created user in the users table. | | client | convex-test client | Authenticated client for the auto-created user. Use for most tests. | | seed(table, data) | (string, object) => Promise<string> | Insert a document. Auto-fills userId unless data includes an explicit userId. Returns the document ID. | | createUser() | () => Promise<client & { userId }> | Create another user. Returns an authenticated client with a .userId property. |

Configuration

// Default: uses "users" table
export const test = createConvexTest(schema, modules);

// Custom users table name
export const test = createConvexTest(schema, modules, { usersTable: "profiles" });

API Reference

Main Export (feather-testing-convex)

| Export | Description | |--------|-------------| | createConvexTest(schema, modules, options?) | Create a Vitest test function with authentication, seeding, and multi-user fixtures. | | renderWithConvex(ui, client) | Render a React element with ConvexTestProvider. Returns Testing Library render result. | | renderWithConvexAuth(ui, client, options?) | Render with ConvexTestAuthProvider. Supports authenticated and signInError options. | | wrapWithConvex(children, client) | JSX wrapper — returns <ConvexTestProvider> element for custom rendering setups. | | ConvexTestProvider | React component. Wraps children with a fake Convex client. Props: client, children, authenticated?. | | ConvexTestAuthProvider | React component. Wraps with auth state + auth actions context. Props: client, children, authenticated?, signInError?. |

TanStack Query (feather-testing-convex/tanstack-query)

| Export | Description | |--------|-------------| | renderWithConvexQuery(ui, client) | Render with QueryClientProvider + ConvexProvider. For components using useQuery(convexQuery(...)). | | renderWithConvexQueryAuth(ui, client, options?) | Auth-aware version. Supports authenticated and signInError options. | | ConvexTestQueryProvider | React component. Wraps with QueryClientProvider + ConvexProvider with auto query invalidation. Props: client, children, authenticated?. | | ConvexTestQueryAuthProvider | React component. Auth-aware version with signIn/signOut context. Props: client, children, authenticated?, signInError?. | | createTestQueryFn(client) | Custom queryFn for advanced QueryClient setup. Routes ["convexQuery", ...] keys to the test backend. | | createTestQueryClient(client) | Pre-configured QueryClient factory (retry: false, gcTime: Infinity, custom queryFn). |

Vitest Plugin (feather-testing-convex/vitest-plugin)

| Export | Description | |--------|-------------| | convexTestProviderPlugin() | Vite plugin that resolves the internal @convex-dev/auth import. Required for auth testing. |

RTL Session DSL (feather-testing-convex/rtl)

| Export | Description | |--------|-------------| | renderWithSession(ui, client, options?) | Combines renderWithConvexAuth + createSession(). Returns a fluent Session object. |

Playwright (feather-testing-convex/playwright)

| Export | Description | |--------|-------------| | createConvexTest({ convexUrl, clearAll }) | Returns a Playwright test object extended with session fixture + auto-cleanup after each test. |


Session DSL Reference

The Session DSL (from feather-testing-core) provides a fluent, chainable API for test interactions. Methods queue up and execute sequentially when awaited.

const session = renderWithSession(<App />, client);

await session
  .fillIn("Email", "[email protected]")
  .fillIn("Password", "secret123")
  .clickButton("Sign Up")
  .assertText("Welcome, [email protected]!");

Interaction Methods

| Method | Description | Example | |--------|-------------|---------| | click(text) | Click any element matching text | session.click("Menu") | | clickLink(text) | Click a link (<a>) by text | session.clickLink("Home") | | clickButton(text) | Click a button by text | session.clickButton("Submit") | | fillIn(label, value) | Type into an input by its label or placeholder | session.fillIn("Email", "[email protected]") | | selectOption(label, option) | Select a dropdown option | session.selectOption("Country", "USA") | | check(label) | Check a checkbox | session.check("Accept Terms") | | uncheck(label) | Uncheck a checkbox | session.uncheck("Accept Terms") | | choose(label) | Select a radio button | session.choose("Express Shipping") | | submit() | Submit the most recently interacted form | session.submit() |

Assertion Methods

| Method | Description | Example | |--------|-------------|---------| | assertText(text) | Assert text is visible on the page | session.assertText("Welcome") | | refuteText(text) | Assert text is NOT visible | session.refuteText("Error") |

Scoping Methods

| Method | Description | Example | |--------|-------------|---------| | within(selector, fn) | Run interactions scoped to a DOM element | See within() examples |

Debugging

| Method | Description | |--------|-------------| | debug() | Log the current DOM to console (screen.debug()) |

How Chaining Works

The Session uses a thenable action-queue pattern:

  1. Each method pushes an async action onto a queue and returns this
  2. await triggers sequential execution of the entire queue
  3. The queue resets after execution, so you can use the same session for multiple chains
const session = renderWithSession(<App />, client);

// Chain 1: Fill in form and submit
await session
  .fillIn("Name", "Alice")
  .clickButton("Save");

// Chain 2: Verify result (same session, fresh queue)
await session
  .assertText("Saved successfully");

Error Messages

On failure, the Session provides a detailed chain trace showing exactly which step failed:

feather-testing-core: Step 3 of 5 failed

Failed at: clickButton('Submit')
Cause: Could not find button with name 'Submit'

Chain:
    [ok] fillIn('Email', '[email protected]')
    [ok] fillIn('Password', 'secret123')
>>> [FAILED] clickButton('Submit')
    [skipped] assertText('Welcome')
    [skipped] refuteText('Error')

Vitest Configuration Reference

Minimal Config (no auth, no session DSL)

import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  test: {
    environment: "jsdom",
    environmentMatchGlobs: [["convex/**", "edge-runtime"]],
    server: { deps: { inline: ["convex-test"] } },
    globals: true,
    setupFiles: ["./src/test-setup.ts"],
  },
});

Full Config (auth + session DSL)

import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import { convexTestProviderPlugin } from "feather-testing-convex/vitest-plugin";

export default defineConfig({
  plugins: [react(), convexTestProviderPlugin()],
  test: {
    environment: "jsdom",
    environmentMatchGlobs: [["convex/**", "edge-runtime"]],
    server: { deps: { inline: ["convex-test"] } },
    globals: true,
    setupFiles: ["./src/test-setup.ts"],
  },
});

Config Options Explained

| Option | Why | |--------|-----| | react() | JSX transform for test files | | convexTestProviderPlugin() | Resolves @convex-dev/auth internal import (auth testing only) | | environment: "jsdom" | DOM APIs for React component tests | | environmentMatchGlobs | Convex functions run in edge runtime, not jsdom | | server.deps.inline: ["convex-test"] | convex-test must be inlined for Vitest to resolve it | | setupFiles | Load jest-dom matchers (toBeInTheDocument(), etc.) |


Complete Test Setup File

Here's the full convex/test.setup.ts with everything exported:

/// <reference types="vite/client" />
import { createConvexTest, renderWithConvex, renderWithConvexAuth } from "feather-testing-convex";
import { renderWithSession } from "feather-testing-convex/rtl";
import schema from "./schema";

export const modules = import.meta.glob("./**/!(*.*.*)*.*s");
export const test = createConvexTest(schema, modules);
export { renderWithConvex, renderWithConvexAuth, renderWithSession };

For TanStack Query Apps

If your app uses @tanstack/react-query + @convex-dev/react-query, add:

/// <reference types="vite/client" />
import { createConvexTest } from "feather-testing-convex";
import { renderWithConvexQuery, renderWithConvexQueryAuth } from "feather-testing-convex/tanstack-query";
import schema from "./schema";

export const modules = import.meta.glob("./**/!(*.*.*)*.*s");
export const test = createConvexTest(schema, modules);
export { renderWithConvexQuery, renderWithConvexQueryAuth };

Playwright E2E Tests

For end-to-end tests against a running Convex backend, use the Playwright integration:

// e2e/fixtures.ts
import { createConvexTest } from "feather-testing-convex/playwright";
import { api } from "../convex/_generated/api";

export const test = createConvexTest({
  convexUrl: process.env.VITE_CONVEX_URL!,
  clearAll: api.testing.clearAll,  // A mutation that clears test data
});

export { expect } from "@playwright/test";
// e2e/app.spec.ts
import { test, expect } from "./fixtures";

test("user can create a todo", async ({ session }) => {
  await session
    .visit("/")
    .fillIn("Task", "Buy groceries")
    .clickButton("Add")
    .assertText("Buy groceries");
});

The Playwright test fixture provides:

  • session — a fluent Session object (same API as RTL, plus visit(), assertPath(), assertHas())
  • Auto-cleanup — calls your clearAll mutation after each test

Limitations

One-Shot Query Execution (Non-Reactive) — ConvexTestProvider only

When using ConvexTestProvider / renderWithConvex, queries resolve once at component mount. After a mutation, the UI does not automatically re-render with updated data.

To verify backend state after a mutation:

await user.click(screen.getByRole("button", { name: "Add" }));
const items = await client.query(api.items.list, {});
expect(items).toHaveLength(1);

To see updated data in the UI, re-mount the component:

const { unmount } = renderWithConvex(<TodoList />, client);
await client.mutation(api.todos.create, { text: "New todo" });
unmount();
renderWithConvex(<TodoList />, client);
expect(await screen.findByText("New todo")).toBeInTheDocument();

Note: This limitation does not apply to the TanStack Query provider (ConvexTestQueryProvider / renderWithConvexQuery). Those automatically invalidate queries after mutations, so the UI updates without re-mounting.

Nested runQuery/runMutation Lose Auth Context

When a Convex function calls ctx.runQuery() or ctx.runMutation(), the nested call does not inherit the caller's auth identity. This is an upstream limitation in convex-test, not in this package.

Workarounds:

  1. Pass userId as an explicit argument (recommended)
  2. Use patch-package to fix convex-test directly
  3. Use actions for orchestration — actions already propagate auth correctly

Agent Skills

This package ships a review-convex-tests skill that checks test files against the 13-point checklist in TESTING-PHILOSOPHY.md.

Via TanStack Intent (recommended)

Add Intent guidance to your agent config once:

npx @tanstack/intent@latest install

After that, agents auto-discover and load the skill when working on Convex tests — no manual invocation needed.

Via slash command (optional)

If you want a /review-convex-tests slash command in Claude Code:

npx feather-install-skills

This copies the skill into .claude/skills/ so it appears in the Claude Code slash command list.


Types

The client prop accepts any object with query(ref, args) and mutation(ref, args) returning promises. The result of convexTest(schema, modules) (and .withIdentity(...)) satisfies this.

Contributing

See CONTRIBUTING.md for the development workflow.

AI agents: See CLAUDE.md for quick reference.

Versioning

Releases follow semantic versioning. See CHANGELOG.md for release history.