Driving state
MockConvexClient captures everything the helpers ask of it and gives the test the controls. This
page is the matrix of what you can drive and the call that drives it.
| What you want to drive | How |
|---|---|
| A query result | convex.lastQuerySubscription()!.emit(value) |
| A query error | convex.lastQuerySubscription()!.emitError(err) |
| A specific query, not the last | convex.querySubscriptions[i].emit(value) |
| A defensive staleness guard (nothing else) | subscription.emitAfterUnsubscribe(value) — see the caveat below |
| A paginated page | convex.lastPaginatedSubscription()!.emit({ results, status, loadMore }) |
| A mutation result | convex.mutationCalls[i].resolve(value) |
| A mutation failure | convex.mutationCalls[i].reject(err) |
| An action result or failure | convex.actionCalls[i].resolve(value) / .reject(err) |
| Convex confirming or rejecting a token | convex.lastAuthRegistration()!.setAuthenticated(bool) |
| Convex asking for a token | convex.lastAuthRegistration()!.fetchToken({ forceRefreshToken }) |
| A token refresh in progress | convex.lastAuthRegistration()!.setRefreshing(bool) |
| The token the client holds | convex.seedAuth({ token, decoded }) |
| Warm-cache data before the first emission | convex.seedQueryResult(queryName, args, result) before createComponent |
A one-shot convex.query() |
seedQueryResult for a hit; on a miss, emit() the subscription it opens |
| Connection state | convex.setConnectionState(partial) |
| Server-side rendering | new MockConvexClient({ disabled: true }) |
And what you can assert about, without driving anything:
| What you want to prove | Where to look |
|---|---|
| A subscription was released exactly once | subscription.unsubscribeCount |
| A disabled client never opened a subscription | convex.refusedSubscriptions |
| The warm cache was consulted, and with what | convex.localQueryResultCalls |
| The connection state was never read | convex.connectionStateReads / connectionStateSubscriptions |
| Auth was cleared, or looked at and left alone | convex.clearAuthCount / convex.hasAuthCount |
| The options a mutation was invoked with | convex.mutationCalls[i].options |
Every capture array is ordered oldest first, and lastQuerySubscription() /
lastPaginatedSubscription() return the newest entry or undefined.
Query results
Section titled “Query results”emit() delivers a value exactly as the live WebSocket would: it calls the helper’s update callback
synchronously, so data() and status() are current the moment it returns.
const fixture = TestBed.createComponent(TodoList);fixture.detectChanges();tick();
expect(fixture.componentInstance.todos.status()).toBe('pending');
convex.lastQuerySubscription()!.emit([{ _id: '1', title: 'Write docs' }]);
expect(fixture.componentInstance.todos.status()).toBe('success');expect(fixture.componentInstance.todos.data()).toEqual([{ _id: '1', title: 'Write docs' }]);Emit again on the same subscription to model a live update pushed by someone else’s mutation — the
subscription stays open, so a second emit() simply replaces the value.
Assert on the captured args before emitting. It is the only place a wrong argument function shows
up; a query with wrong args otherwise just sits pending forever.
expect(convex.lastQuerySubscription()!.args).toEqual({ category: 'work' });With injectQueries(), or with several query helpers in one component, index into
convex.querySubscriptions in the order the subscriptions were opened rather than using
lastQuerySubscription().
Query errors
Section titled “Query errors”emitError() routes to the helper’s error callback. The helper’s error() signal exposes the very
same object you passed, so identity assertions work and typed ConvexError payloads survive intact.
const failure = new Error('boom');convex.lastQuerySubscription()!.emitError(failure);
expect(fixture.componentInstance.todos.status()).toBe('error');expect(fixture.componentInstance.todos.error()).toBe(failure);Emitting after unsubscribe
Section titled “Emitting after unsubscribe”emit() and emitError() are gated on unsubscribe: once the helper has torn down, they do nothing.
That mirrors the real client exactly, and it is what you want in every ordinary test.
emitAfterUnsubscribe() and emitErrorAfterUnsubscribe() invoke the retired callback anyway,
straight past that gate. Be clear about what this is: the real client cannot do it.
unsubscribe() synchronously removes the listener before it returns, so no later dispatch can ever
reach a retired callback — there is no race to lose.
They exist for one narrow purpose. The query helpers carry a defensive generation guard that drops results belonging to a superseded subscription. Against a faithful client that guard is unreachable, and unreachable code is untested code. These two methods are the only way to fire it.
unsubscribeCount is the companion assertion — it proves the helper released the subscription once
and only once, which a boolean unsubscribed cannot.
import { Component, signal } from '@angular/core';import { TestBed, fakeAsync, tick } from '@angular/core/testing';import { injectQuery } from 'convex-angular';import { MockConvexClient, MockQuerySubscription, provideConvexTesting } from 'convex-angular/testing';
import { api, type Todo, type TodoId } from './convex/api';
@Component({ selector: 'app-category-todos', template: ` @for (todo of todos.data() ?? []; track todo._id) { <li>{{ todo.title }}</li> } `,})export class CategoryTodos { readonly category = signal('work'); readonly todos = injectQuery(api.todos.listByCategory, () => ({ category: this.category() }));}
/** Narrows away the `undefined` the capture arrays return when nothing matched. */function requireLastQuerySubscription(convex: MockConvexClient): MockQuerySubscription { const subscription = convex.lastQuerySubscription(); if (!subscription) { throw new Error('Expected a captured query subscription'); } return subscription;}
function todo(id: string, title: string): Todo { return { _id: id as TodoId, _creationTime: 0, title, description: '', completed: false, priority: 0 };}
const workTodo = todo('work-1', 'Write docs');const homeTodo = todo('home-1', 'Water plants');
describe('CategoryTodos staleness guards', () => { let convex: MockConvexClient;
beforeEach(() => { convex = new MockConvexClient(); TestBed.configureTestingModule({ imports: [CategoryTodos], providers: [provideConvexTesting(convex)], }); });
afterEach(() => { TestBed.resetTestingModule(); });
it('guards against a retired callback firing after a re-subscribe', fakeAsync(() => { const fixture = TestBed.createComponent(CategoryTodos); fixture.detectChanges(); tick();
const first = convex.querySubscriptions[0];
fixture.componentInstance.category.set('home'); fixture.detectChanges(); tick();
expect(convex.querySubscriptions).toHaveLength(2); expect(first.unsubscribed).toBe(true); // The helper released the old subscription exactly once. expect(first.unsubscribeCount).toBe(1);
// `emit` is gated on unsubscribe, exactly as the real client is: it cannot // reach the helper at all. This is the only realistic behaviour. first.emit([workTodo]); expect(fixture.componentInstance.todos.data()).toBeUndefined();
// `emitAfterUnsubscribe` invokes the retired callback past that gate. The // real client cannot do this, so this reaches the helper's defensive // generation guard and asserts nothing about production behaviour. first.emitAfterUnsubscribe([workTodo]); expect(fixture.componentInstance.todos.data()).toBeUndefined();
requireLastQuerySubscription(convex).emit([homeTodo]);
expect(fixture.componentInstance.todos.data()).toEqual([homeTodo]); }));
it('guards against a retired error callback firing after destroy', fakeAsync(() => { const fixture = TestBed.createComponent(CategoryTodos); fixture.detectChanges(); tick();
const subscription = convex.querySubscriptions[0]; subscription.emit([workTodo]);
fixture.destroy();
expect(subscription.unsubscribeCount).toBe(1);
subscription.emitErrorAfterUnsubscribe(new Error('never reaches a live client'));
expect(fixture.componentInstance.todos.status()).toBe('success'); expect(fixture.componentInstance.todos.error()).toBeUndefined(); }));});Paginated pages
Section titled “Paginated pages”injectPaginatedQuery() subscribes through onPaginatedUpdate_experimental, so its captures land in
paginatedSubscriptions. Each capture also records the initialNumItems the helper resolved.
const fixture = TestBed.createComponent(PaginatedTodos);fixture.detectChanges();tick();
const page = convex.lastPaginatedSubscription()!;expect(page.args).toEqual({ category: 'work' });expect(page.initialNumItems).toBe(10);
page.emit({ results: [{ _id: '1', title: 'Write docs' }], status: 'CanLoadMore', loadMore: () => true,});
expect(fixture.componentInstance.todos.status()).toBe('success');expect(fixture.componentInstance.todos.canLoadMore()).toBe(true);status is the client’s pagination status — 'LoadingFirstPage', 'CanLoadMore', 'LoadingMore',
or 'Exhausted' — and maps onto the helper’s signals:
Emitted status |
Helper state |
|---|---|
'LoadingFirstPage' |
isLoadingFirstPage() true, status() stays 'pending' |
'LoadingMore' |
isLoadingMore() true |
'CanLoadMore' |
canLoadMore() true |
'Exhausted' |
isExhausted() true |
The loadMore function you emit is the one the helper calls when the component calls loadMore(n),
so a Jest mock there lets you assert the page size requested:
const loadMore = jest.fn().mockReturnValue(true);convex.lastPaginatedSubscription()!.emit({ results: [], status: 'CanLoadMore', loadMore });
fixture.componentInstance.todos.loadMore(25);
expect(loadMore).toHaveBeenCalledWith(25);Details of the helper itself are in Pagination.
Mutations and actions
Section titled “Mutations and actions”A captured mutation’s promise never settles on its own. mutation() and action() push a
{ fn, args, options, resolve, reject } record onto mutationCalls / actionCalls and return a
promise held open until the test settles it. That is what makes the in-flight state assertable.
fixture.componentInstance.add();tick();
// In flight: nothing has settled.expect(convex.mutationCalls).toHaveLength(1);expect(convex.mutationCalls[0].args).toEqual({ title: 'New todo' });expect(fixture.componentInstance.addTodo.status()).toBe('pending');
convex.mutationCalls[0].resolve('todo-id');tick();
expect(fixture.componentInstance.addTodo.status()).toBe('success');expect(fixture.componentInstance.addTodo.data()).toBe('todo-id');Settlement is asynchronous — it goes through the promise — so tick() (or an await) is required
between resolve() and the assertion, unlike emit(). Failures work the same way:
const failure = new Error('rejected by the server');convex.mutationCalls[0].reject(failure);tick();
expect(fixture.componentInstance.addTodo.status()).toBe('error');expect(fixture.componentInstance.addTodo.error()).toBe(failure);reject() accepts any value, not just an Error, so a helper’s normalization of whatever the wire
threw is testable:
convex.mutationCalls[0].reject('a bare string');tick();
expect(fixture.componentInstance.addTodo.status()).toBe('error');Actions are identical with convex.actionCalls.
Mutations carry a third argument, recorded on options. injectMutation() always forwards
{ optimisticUpdate } there — the property is undefined when no optimistic update was configured.
Actions have no options parameter at all, matching the real client, so an action’s options is
always undefined:
expect(convex.mutationCalls[0].options).toEqual({ optimisticUpdate: expect.any(Function) });expect(convex.actionCalls[0].options).toBeUndefined();That proves the optimistic update reached the client. It does not run it: the mock has no local store for an optimistic update to write into, so the function is recorded and never called. See Optimistic updates for what it does against a real deployment.
Authentication
Section titled “Authentication”Auth is the one area where the mock is not the whole story. provideConvexTesting() registers the
CONVEX token and nothing else, so the identity side is yours: a ConvexAuthProvider behind
CONVEX_AUTH, plus provideConvexAuth(). That is what makes injectAuth() resolvable and what
calls client.setAuth in the first place — without it the mock captures nothing.
With those registered, the two halves split cleanly:
- Your fake provider models the identity system — Clerk, Auth0, Better Auth, your own session
service. Flip its
isLoading/isAuthenticatedsignals and decide whatfetchAccessTokenreturns. convex.lastAuthRegistration()models Convex answering back. Everyclient.setAuthcall lands onconvex.authRegistrationsas aMockAuthRegistration.
A registration is inert until the test drives it. The mock never calls fetchToken itself, never
validates a token, and never decides the outcome:
| Call | What it simulates |
|---|---|
registration.setAuthenticated(true) |
Convex accepted the token. injectAuth().status() becomes 'authenticated'. |
registration.setAuthenticated(false) |
Convex rejected it. The status settles on 'unauthenticated'. |
registration.setRefreshing(true) |
A token renewal started; the status becomes 'refreshing'. |
registration.fetchToken({ forceRefreshToken }) |
Convex asking your provider for a token, on connect or on expiry. |
Until setAuthenticated is called, injectAuth() stays 'loading' — a signed-in provider is not
enough on its own, which is exactly the state most auth bugs live in.
Registration is not authentication
Section titled “Registration is not authentication”Registering a token fetcher is configuration. It does not make the client authenticated, and this is
where an intuitive mental model goes wrong. The real hasAuth() reports whether the client is
holding a token, not whether a fetcher was installed, and setAuthenticated(true) is only a
notification — it hands the client no token.
convex.seedAuth({ token, decoded }) is the single source of current auth. It is what getAuth()
reports, what makes client.hasAuth() true, and what client.clearAuth() drops. Seed it before the
fixture exists to stand in for a client that was already authenticated when the code under test ran:
convex.seedAuth({ token: 'jwt-token', decoded: { sub: 'user-1' } });Without a seeded token, hasAuth() is false no matter how many registrations exist — so a test
asserting that sign-out clears auth must seed one first, or clearAuth() is never reached and
clearAuthCount stays 0.
convex.clearAuthCount and convex.hasAuthCount count those calls; hasAuthCount is what separates
“asked and correctly decided not to clear” from “never looked”.
import { Component, signal } from '@angular/core';import { TestBed, fakeAsync, tick } from '@angular/core/testing';import { CONVEX_AUTH, ConvexAuthProvider, injectAuth, provideConvexAuth } from 'convex-angular';import { MockAuthRegistration, MockConvexClient, provideConvexTesting } from 'convex-angular/testing';
@Component({ selector: 'app-account-badge', template: ` @switch (auth.status()) { @case ('loading') { <p>Checking your session…</p> } @case ('refreshing') { <p>Refreshing your session…</p> } @case ('authenticated') { <p>Signed in as {{ subject() }}</p> } @default { <p>Signed out</p> } } `,})export class AccountBadge { readonly auth = injectAuth();
subject(): string { return String(this.auth.getAuth()?.decoded['sub'] ?? 'unknown'); }}
/** * A minimal `ConvexAuthProvider` standing in for Clerk, Auth0 or Better Auth. * The test flips its signals to model the identity provider; the mock's auth * registration models Convex answering back. */class FakeAuthProvider implements ConvexAuthProvider { readonly isLoading = signal(false); readonly isAuthenticated = signal(false); readonly error = signal<Error | undefined>(undefined);
/** What the identity provider's token endpoint hands back. */ token: string | null = 'jwt-token'; /** How many times Convex asked for a bypass-the-cache token. */ forceRefreshCount = 0;
fetchAccessToken = async ({ forceRefreshToken }: { forceRefreshToken: boolean }): Promise<string | null> => { if (forceRefreshToken) { this.forceRefreshCount += 1; } return this.token; };}
describe('AccountBadge', () => { let convex: MockConvexClient; let provider: FakeAuthProvider;
beforeEach(() => { convex = new MockConvexClient(); provider = new FakeAuthProvider();
TestBed.configureTestingModule({ imports: [AccountBadge], providers: [ provideConvexTesting(convex), { provide: CONVEX_AUTH, useValue: provider }, // `provideConvexAuth()` is what calls `client.setAuth`, so the mock // captures nothing until it is registered. provideConvexAuth(), ], }); });
afterEach(() => { TestBed.resetTestingModule(); });
function createFixture() { const fixture = TestBed.createComponent(AccountBadge); fixture.detectChanges(); tick(); return fixture; }
/** `lastAuthRegistration()` is optional; narrow it once instead of at every call site. */ function lastRegistration(): MockAuthRegistration { const registration = convex.lastAuthRegistration(); if (!registration) { throw new Error('Expected the helper to have registered auth with the client'); } return registration; }
it('stays loading until Convex confirms the token', fakeAsync(() => { provider.isAuthenticated.set(true); const fixture = createFixture();
// The provider says signed in, so auth was registered — but nobody has // confirmed the token yet. expect(convex.authRegistrations).toHaveLength(1); expect(fixture.componentInstance.auth.status()).toBe('loading');
lastRegistration().setAuthenticated(true); fixture.detectChanges(); tick();
expect(fixture.componentInstance.auth.status()).toBe('authenticated'); }));
it('asks the provider for a fresh token when Convex forces a refresh', fakeAsync(() => { provider.isAuthenticated.set(true); createFixture();
let resolved: string | null | undefined; lastRegistration() .fetchToken({ forceRefreshToken: true }) .then((token) => (resolved = token)); tick();
expect(provider.forceRefreshCount).toBe(1); expect(resolved).toBe('jwt-token'); }));
it('reports the refreshing status while a token is renewed', fakeAsync(() => { provider.isAuthenticated.set(true); const fixture = createFixture();
lastRegistration().setAuthenticated(true); lastRegistration().setRefreshing(true); fixture.detectChanges(); tick();
expect(fixture.componentInstance.auth.status()).toBe('refreshing');
lastRegistration().setRefreshing(false); fixture.detectChanges(); tick();
expect(fixture.componentInstance.auth.status()).toBe('authenticated'); }));
it('renders the claims the client holds', fakeAsync(() => { // `seedAuth` is the client's token: it is what `getAuth()` reports and what // makes `client.hasAuth()` true. Registering a fetcher does neither. convex.seedAuth({ token: 'jwt-token', decoded: { sub: 'user-1' } }); provider.isAuthenticated.set(true); const fixture = createFixture();
lastRegistration().setAuthenticated(true); fixture.detectChanges(); tick();
expect(fixture.nativeElement.textContent).toContain('Signed in as user-1'); }));
it('clears the held token when the user signs out', fakeAsync(() => { convex.seedAuth({ token: 'jwt-token', decoded: { sub: 'user-1' } }); provider.isAuthenticated.set(true); const fixture = createFixture();
lastRegistration().setAuthenticated(true); fixture.detectChanges(); tick();
const registration = lastRegistration(); provider.isAuthenticated.set(false); fixture.detectChanges(); tick();
expect(convex.hasAuthCount).toBeGreaterThan(0); expect(convex.clearAuthCount).toBe(1); expect(registration.cleared).toBe(true); expect(fixture.componentInstance.auth.getAuth()).toBeUndefined(); expect(fixture.componentInstance.auth.status()).toBe('unauthenticated');
// `cleared` records that clearAuth ran; it does not gag the registration. // The real client keeps its authentication-manager config after clearAuth, // so a stale callback can still arrive — and it is the helper's own // generation guard, not the mock, that has to ignore it. registration.setAuthenticated(true); fixture.detectChanges(); tick();
expect(fixture.componentInstance.auth.status()).toBe('unauthenticated'); }));
it('leaves a client that holds no token alone', fakeAsync(() => { // No `seedAuth`, so `hasAuth()` is false: the helper must look, then // decide not to clear. `hasAuthCount` is what tells those two apart. provider.isLoading.set(true); createFixture();
expect(convex.hasAuthCount).toBeGreaterThan(0); expect(convex.clearAuthCount).toBe(0); }));
it('clears a token the client was already holding', fakeAsync(() => { // A token seeded before the fixture exists stands in for a client that was // already authenticated when this component was created. convex.seedAuth({ token: 'stale-token', decoded: { sub: 'user-1' } }); provider.isLoading.set(true); createFixture();
expect(convex.authRegistrations).toHaveLength(0); expect(convex.clearAuthCount).toBe(1); }));
it('surfaces a failure raised by the client itself', fakeAsync(() => { const fixture = createFixture();
// Fault injection: the mock cannot arm a throwing `setAuth`, so spy on its // own client object. `convex.client` is stable, which is what makes it work. jest.spyOn(convex.client, 'setAuth').mockImplementation(() => { throw new Error('sync exploded'); });
provider.isAuthenticated.set(true); fixture.detectChanges(); tick();
expect(fixture.componentInstance.auth.status()).toBe('unauthenticated'); expect(fixture.componentInstance.auth.error()?.message).toContain('sync exploded'); }));});The helper’s own contract — the statuses, the loading rules, the error prefixes — is documented in the auth overview.
Warm-cache prefill
Section titled “Warm-cache prefill”seedQueryResult() populates the local cache that injectQuery() and injectQueries() consult
before their subscription delivers, which is how you model a query whose data is already warm — for
example after injectPrewarmQuery() ran on a previous screen.
// Before the fixture is created.convex.seedQueryResult('todos:list', {}, [{ _id: 'warm', title: 'Warm' }]);
const fixture = TestBed.createComponent(TodoList);fixture.detectChanges();tick();
expect(fixture.componentInstance.todos.data()).toEqual([{ _id: 'warm', title: 'Warm' }]);Two things to get right.
The name must be the resolved function name, the "module:export" string that
getFunctionName(query) returns for the function reference the component passes — 'todos:list'
for api.todos.list, not the object itself. The args must match what the component’s argument
function produces, though the mock’s key is order-independent, so { a: 1, b: 2 } and { b: 2, a: 1 }
find the same entry.
A cache hit is a prefill, not a settlement. The helper shows the seeded data immediately but
stays 'pending' until a live emission confirms it, because the subscription has not yet reported.
expect(fixture.componentInstance.todos.data()).toEqual([{ _id: 'warm', title: 'Warm' }]);expect(fixture.componentInstance.todos.status()).toBe('pending');
convex.lastQuerySubscription()!.emit([{ _id: 'warm', title: 'Warm' }]);
expect(fixture.componentInstance.todos.status()).toBe('success');Seeding never affects the subscription itself — the subscription is still captured, and still needs
an emit().
Every lookup is recorded on convex.localQueryResultCalls, hit or miss, which is how a missed seed
stops being a silent mystery: the recorded name and args are exactly what the helper asked for, so a
mismatch is visible instead of merely absent.
expect(convex.localQueryResultCalls).toEqual([{ queryName: 'todos:list', args: { count: 20 } }]);The array also grows on every re-subscribe, so it doubles as a record of which argument combinations a component walked through.
One-shot queries
Section titled “One-shot queries”The same seeded cache backs convex.query(fn, args), the one-shot read used by code that injects the
client directly rather than through a query helper — a service method, a route resolver. It follows
the real client down both paths, and which one you land on decides how the test drives it:
| Cache | What happens |
|---|---|
| Hit | Resolves straight from the cache. No subscription is opened. |
| Miss | Opens a subscription and stays pending until it delivers, then unsubscribes. |
| Disabled | Rejects with 'ConvexClient is disabled'. |
The miss is the case you will actually hit, because it is what happens whenever you have not seeded
anything. The promise does not resolve to undefined — it does not resolve at all until you settle
the subscription that querySubscriptions now holds:
const pending = service.count();
// The miss fell back to a subscription; settle it or the promise hangs.convex.lastQuerySubscription()!.emit([{ _id: '1', title: 'Write docs' }]);
await expect(pending).resolves.toBe(1);import { Injectable } from '@angular/core';import { TestBed } from '@angular/core/testing';import { injectConvex } from 'convex-angular';import { MockConvexClient, MockQuerySubscription, provideConvexTesting } from 'convex-angular/testing';
import { api, type Todo, type TodoId } from './convex/api';
@Injectable({ providedIn: 'root' })export class TodoCounter { private readonly convex = injectConvex();
/** A one-shot read, outside any subscription. */ async count(): Promise<number> { const todos = await this.convex.query(api.todos.list, { count: 20 }); return todos.length; }}
/** Narrows away the `undefined` the capture arrays return when nothing matched. */function requireLastQuerySubscription(convex: MockConvexClient): MockQuerySubscription { const subscription = convex.lastQuerySubscription(); if (!subscription) { throw new Error('Expected a captured query subscription'); } return subscription;}
function todo(id: string, title: string): Todo { return { _id: id as TodoId, _creationTime: 0, title, description: '', completed: false, priority: 0 };}
describe('TodoCounter', () => { let convex: MockConvexClient; let service: TodoCounter;
beforeEach(() => { convex = new MockConvexClient(); TestBed.configureTestingModule({ providers: [provideConvexTesting(convex)] }); service = TestBed.inject(TodoCounter); });
afterEach(() => { TestBed.resetTestingModule(); });
it('serves a warm-cache hit without opening a subscription', async () => { convex.seedQueryResult('todos:list', { count: 20 }, [todo('1', 'Write docs')]);
await expect(service.count()).resolves.toBe(1);
// A hit settles straight from the cache — nothing was subscribed. expect(convex.querySubscriptions).toHaveLength(0); expect(convex.localQueryResultCalls).toEqual([{ queryName: 'todos:list', args: { count: 20 } }]); });
it('subscribes on a cache miss and stays pending until the first result', async () => { // No seed, so the lookup misses and the client falls back to a // subscription — exactly as the real one does. const pending = service.count();
expect(convex.querySubscriptions).toHaveLength(1); expect(requireLastQuerySubscription(convex).args).toEqual({ count: 20 });
// Nothing resolves until the subscription delivers. Forget this line and // the promise simply never settles. requireLastQuerySubscription(convex).emit([todo('1', 'Write docs'), todo('2', 'Ship it')]);
await expect(pending).resolves.toBe(2);
// A one-shot read releases its subscription once it has settled. expect(convex.querySubscriptions[0].unsubscribeCount).toBe(1); });
it('rejects when the subscription errors instead of delivering', async () => { const pending = service.count();
requireLastQuerySubscription(convex).emitError(new Error('query failed'));
await expect(pending).rejects.toThrow('query failed'); expect(convex.querySubscriptions[0].unsubscribeCount).toBe(1); });});Connection state
Section titled “Connection state”setConnectionState() shallow-merges the partial into the current state and pushes the result
synchronously to every listener registered through subscribeToConnectionState, which is what
injectConvexConnectionState() uses. Fields absent from the partial keep their previous value.
const fixture = TestBed.createComponent(OfflineBanner);fixture.detectChanges();
expect(fixture.componentInstance.connection().isWebSocketConnected).toBe(true);
convex.setConnectionState({ isWebSocketConnected: false, connectionRetries: 3 });fixture.detectChanges();
expect(fixture.componentInstance.connection().isWebSocketConnected).toBe(false);expect(fixture.componentInstance.connection().connectionRetries).toBe(3);// Untouched fields are preserved.expect(fixture.componentInstance.connection().hasEverConnected).toBe(true);A fresh mock starts connected and idle; the exact defaults are listed in the mock reference.
Simulating server-side rendering
Section titled “Simulating server-side rendering”new MockConvexClient({ disabled: true }) mirrors a disabled ConvexClient, which is the state the
client is in during a server render: subscribing becomes a no-op, the client getter throws
'ConvexClient is disabled', connectionState() throws, getAuth() reports undefined, and
mutation(), action(), and a one-shot query() all reject with 'ConvexClient is disabled'.
Nothing about a skipped subscription is an error, though. The subscribe call succeeds and hands back
a callable no-op unsubscribe, exactly as the real disabled client does; the subscription is simply
never established. That leaves an empty querySubscriptions, which is weak evidence — it looks
identical whether the code under test asked and was skipped or never asked at all. So the disabled
mock records what it passed over:
convex.refusedSubscriptionsgets a{ kind, query, args }entry for every subscription that was asked for and skipped, so a test can name it.convex.connectionStateReadsandconvex.connectionStateSubscriptionscount the attempt before the disabled branch, so a zero there is a positive claim: the code never even reached for the connection state.convex.localQueryResultCallsstays empty, because a disabled client’s warm cache is never consulted.
import { Component } from '@angular/core';import { TestBed, fakeAsync, tick } from '@angular/core/testing';import { injectConvexConnectionState, injectQuery } from 'convex-angular';import { MockConvexClient, provideConvexTesting } from 'convex-angular/testing';
import { api } from './convex/api';
@Component({ selector: 'app-todo-shell', template: ` @if (connection().isWebSocketConnected) { <p>Live</p> } @else { <p>Offline</p> }
@for (todo of todos.data() ?? []; track todo._id) { <li>{{ todo.title }}</li> } `,})export class TodoShell { readonly todos = injectQuery(api.todos.list, () => ({ count: 20 })); readonly connection = injectConvexConnectionState();}
describe('TodoShell during a server render', () => { afterEach(() => { TestBed.resetTestingModule(); });
it('never establishes a subscription and never touches the connection state', fakeAsync(() => { const convex = new MockConvexClient({ disabled: true }); TestBed.configureTestingModule({ imports: [TodoShell], providers: [provideConvexTesting(convex)], });
const fixture = TestBed.createComponent(TodoShell); fixture.detectChanges(); tick();
// The subscribe call succeeds and hands back a no-op unsubscribe — the // subscription is simply never established, exactly as with the real // disabled client. Nothing is captured, so absence alone proves nothing; // `refusedSubscriptions` records what was asked for and skipped. expect(convex.querySubscriptions).toHaveLength(0); expect(convex.refusedSubscriptions).toEqual([{ kind: 'query', query: api.todos.list, args: { count: 20 } }]);
// The warm cache is never consulted while disabled, and there is no auth. expect(convex.localQueryResultCalls).toHaveLength(0); expect(convex.getAuth()).toBeUndefined();
// The connection helper short-circuits to a static disconnected state // instead of reading through to the client. Both counters record the // attempt when one is made, so zero means "never looked". expect(convex.connectionStateReads).toBe(0); expect(convex.connectionStateSubscriptions).toBe(0); expect(fixture.componentInstance.connection().isWebSocketConnected).toBe(false);
expect(() => convex.client).toThrow('ConvexClient is disabled'); }));
it('rejects a one-shot query instead of resolving nothing', async () => { const convex = new MockConvexClient({ disabled: true });
await expect(convex.query(api.todos.list, { count: 20 })).rejects.toThrow('ConvexClient is disabled'); });
it('reads, subscribes and releases the connection state once in the browser', fakeAsync(() => { const convex = new MockConvexClient(); TestBed.configureTestingModule({ imports: [TodoShell], providers: [provideConvexTesting(convex)], });
const fixture = TestBed.createComponent(TodoShell); fixture.detectChanges(); tick();
expect(convex.refusedSubscriptions).toHaveLength(0); expect(convex.connectionStateReads).toBe(1); expect(convex.connectionStateSubscriptions).toBe(1); expect(convex.connectionStateUnsubscribes).toBe(0);
// Every warm-cache lookup the query helper made, with the args it used. expect(convex.localQueryResultCalls).toEqual([{ queryName: 'todos:list', args: { count: 20 } }]);
fixture.destroy();
expect(convex.connectionStateUnsubscribes).toBe(1); }));});This is the right tool for asserting that a component renders a sane skeleton with no client, and for
covering the convex.disabled branches of your own code. It is not a full SSR harness — the mock
provides no TransferState or hydration emulation, so it cannot exercise the transfer-and-hydrate
path. See the limitations.