Skip to main content

Test signed-in screens

Test Better Convex components with the real composables against an in-memory Convex connection.

setupBetterConvexTest() creates a test runtime for component tests. Your component runs the real composables: queries, pagination, mutations, actions, forms, uploads, and operations. Only the Convex deployment is replaced. The test answers each request by its generated function reference.

The runtime does not run your Convex functions. Test validators, authorization, and indexes with convex-test.

Nuxt

Install the runtime's plugin in mountSuspended. Bind only useConvexAuth with mockNuxtImport. mockNuxtImport is a compile-time macro, so keep the runtime in a hoisted holder and create a new one before each test:

tests/component/client-screen.test.ts
import { mockNuxtImport, mountSuspended } from '@nuxt/test-utils/runtime'
import {
  setupBetterConvexTest,
  type BetterConvexTestRuntime,
} from '@lupinum/better-convex-nuxt/test'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'

import { api } from '#convex/api'
import ClientScreen from '~/features/clients/ClientScreen.vue'

const testState = vi.hoisted(() => ({ convex: null as BetterConvexTestRuntime | null }))

mockNuxtImport('useConvexAuth', () => () => testState.convex!.auth)

beforeEach(() => {
  testState.convex = setupBetterConvexTest({ auth: 'authenticated' })
})

afterEach(async () => {
  await testState.convex?.dispose()
})

it('renders a realtime update and creates a client', async () => {
  const convex = testState.convex!
  convex.query(api.clients.list).resolve([{ id: 'one', name: 'First' }])
  convex.mutation(api.clients.create).resolve('client-two')

  const wrapper = await mountSuspended(ClientScreen, {
    global: { plugins: [convex.plugin] },
  })
  await convex.flush()
  expect(wrapper.text()).toContain('First')

  convex.query(api.clients.list).push([
    { id: 'one', name: 'First' },
    { id: 'two', name: 'Second' },
  ])
  await convex.flush()
  expect(wrapper.text()).toContain('Second')

  await wrapper.get('form').trigger('submit')
  await convex.flush()
  expect(convex.mutation(api.clients.create).calls[0]?.args).toEqual({ name: 'Second' })
})

convex.auth is a useConvexAuth() double: status, pending, user, error, ready(), and client.signIn.email(), client.signUp.email(), and client.signOut(). It moves the same identity as convex.auth.signIn().

Use await convex.flush() after you change the identity or answer a request. It lets promises and Vue watchers finish without timers, so it also works with fake timers.

Plain Vue

Import the runtime from @lupinum/better-convex-vue/test and install its plugin instead of createBetterConvex():

src/components/ClientScreen.test.ts
import { setupBetterConvexTest } from '@lupinum/better-convex-vue/test'
import { mount } from '@vue/test-utils'

const convex = setupBetterConvexTest({ auth: { subject: 'alice' } })
const wrapper = mount(ClientScreen, { global: { plugins: [convex.plugin] } })

For an embedded application, pass the runtime's attachment: createBetterConvex({ attachment: convex.attachment }).

Answer requests

Every request stays pending until the test answers it. Use this to assert loading states.

ControlAnswers
convex.query(ref, args?)resolve(value), push(value) for a live update, reject(error), reset(), calls, activeSubscriptions(), nextCall()
convex.paginatedQuery(ref, listArgs?)page(cursor) returns a query control for one page; the first page has cursor null
convex.mutation(ref), convex.action(ref)resolve(value), reject(error), or respond((args) => value) for every pending and later request; calls, nextCall(), reset()
convex.upload(ref, { prepared? })progress(loaded, total?), resolve(storageId), fail(status), reject(error) for the upload-URL mutation, calls, nextCall()
convex.storageurl() returns a test upload URL; answer its POSTs with resolve, fail, and progress
convex.authsignIn(user) in Nuxt or signIn(subject) in Vue, signOut(), renewSession(), setLoading(), fail(error)
convex.setConnectionState(update)Changes what useConvexConnectionState reports

A query control without args answers every argument variant of that function. A control with args answers only that variant and wins over the default.

Each entry of calls for a mutation or action has args, the identity of the user who sent it, its state, and its own resolve(value) and reject(error). nextCall() waits for the next request:

ts
const create = convex.mutation(api.clients.create)
await wrapper.get('form').trigger('submit')
const request = await create.nextCall()
expect(wrapper.get('button').attributes('disabled')).toBeDefined()

request.reject(new ConvexError({ code: 'CLIENT_EXISTS', message: 'This client already exists' }))
await convex.flush()
expect(wrapper.get('[role="alert"]').text()).toContain('already exists')

For an upload-URL mutation that returns an object, build the result around a test URL:

ts
convex.upload(api.assets.createUploadSession, {
  prepared: (url) => ({ uploadUrl: url, sessionId: 'session-1', token: 'token-1' }),
})

A complete step of useConvexFileUpload is an ordinary mutation or action. Answer it with convex.mutation(ref) or convex.action(ref).

Uploads run the real storage request code. The runtime replaces XMLHttpRequest only for its own test URLs. dispose() restores it.

Test hard sequences

Switch the user while the upload URL is on its way. The upload stops, and no file is sent:

ts
const prepare = convex.mutation(api.files.generateUploadUrl)
const pending = upload(file)

const request = await prepare.nextCall()
convex.auth.signIn({ id: 'bob', name: 'Bob' })
request.resolve(convex.storage.url())

await expect(pending).rejects.toMatchObject({ code: 'IDENTITY_CHANGED' })
await convex.flush()
expect(convex.storage.calls).toEqual([])

Reject a later page with an invalid cursor. The list starts again from the first page:

ts
import { invalidCursorError } from '@lupinum/better-convex-nuxt/test'

const list = convex.paginatedQuery(api.clients.listPaginated)
list.page().resolve({ page: firstPage, isDone: false, continueCursor: 'c1' })
// Mount the screen, then press "Load more".
list.page('c1').reject(invalidCursorError())
await convex.flush()
expect(list.page().activeSubscriptions()).toBe(1)

Reset a mutation while it runs, then answer it. Only the original promise receives the result, and the state stays idle:

ts
const create = convex.mutation(api.clients.create)
const pending = mutate({ name: 'First' })
const request = await create.nextCall()
reset()
request.resolve('client-one')

await expect(pending).resolves.toBe('client-one')
expect(status.value).toBe('idle')

What this proves

Use the test runtime for what the screen does:

  • loading, success, error, and realtime states;
  • typed mutation and action arguments and results;
  • upload progress, cancellation, and failures;
  • signed-in, anonymous, loading, and changing users;
  • subscriptions that stop when a component or user changes.

Test other layers separately:

  • convex-test for validators, authorization, indexes, and transactions;
  • a Nuxt server-rendering fixture for server fetch, payload serialization, and hydration;
  • a real browser for routing, focus, forms, and the deployed auth origin.

The runtime does not emulate optimistic updates or server-rendered payloads.