Skip to content

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.

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().

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);

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.

category-todos.spec.ts
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();
}));
});

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.

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.

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 / isAuthenticated signals and decide what fetchAccessToken returns.
  • convex.lastAuthRegistration() models Convex answering back. Every client.setAuth call lands on convex.authRegistrations as a MockAuthRegistration.

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.

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

account-badge.spec.ts
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.

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.

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);
todo-counter.spec.ts
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);
});
});

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.

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.refusedSubscriptions gets a { kind, query, args } entry for every subscription that was asked for and skipped, so a test can name it.
  • convex.connectionStateReads and convex.connectionStateSubscriptions count the attempt before the disabled branch, so a zero there is a positive claim: the code never even reached for the connection state.
  • convex.localQueryResultCalls stays empty, because a disabled client’s warm cache is never consulted.
todo-shell.spec.ts
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.