Testing
How to test mini-app code with `MiniAppClient.withBridge()` — the one seam you need.
MiniAppClient.withBridge(...) is the one seam you need. Hand it
any object that satisfies the Bridge (or CarStatusBridge)
interface and the rest of the SDK runs unchanged. No platform
channels, no WebView, no fixtures.
TL;DR — Vitest setup
// fuel-stations.test.ts
import { describe, expect, it } from 'vitest';
import { MiniAppClient, type Bridge } from '@i99dash/sdk';
import { renderStations } from './fuel-stations';
describe('renderStations', () => {
it('shows nearest first', async () => {
const bridge: Bridge = {
getContext: async () => ({
userId: 'u1',
activeCarId: 'WDB1234567',
locale: 'en',
isDark: false,
appVersion: '1.0.0',
appId: 'fuel_prices',
}),
callApi: async (req) => {
if (req.path === '/api/v1/fuel-stations') {
return {
success: true,
data: { stations: [{ name: 'Aramco A', km: 0.4 }] },
};
}
return { success: false, error: { code: 'disallowed_path', message: req.path } };
},
};
const client = MiniAppClient.withBridge(bridge);
const html = await renderStations(client);
expect(html).toContain('Aramco A');
});
});No jsdom, no happy-dom — Bridge is a plain TypeScript
interface; the test runs in pure Node.
Asserting bridge calls
For "did the page actually call this endpoint?" tests, capture calls in a small spy:
import { vi } from 'vitest';
const callApi = vi.fn(async (req) => ({ success: true, data: null }));
const bridge: Bridge = {
getContext: async () => ctx,
callApi,
};
await render(MiniAppClient.withBridge(bridge));
expect(callApi).toHaveBeenCalledWith({
path: '/api/v1/fuel-stations',
method: 'GET',
query: { lat: 24.7, lng: 46.6 },
});Vitest's vi.fn (or Jest's jest.fn) gives you mock.calls for
ad-hoc matchers; no need for a mock library.
Covering error paths
Each SDKError subclass is constructible — throw it from your
fake bridge to drive the error branch:
import { BridgeTransportError, BridgeTimeoutError } from '@i99dash/sdk';
const bridge: Bridge = {
getContext: async () => {
throw new BridgeTransportError('host crashed', new Error('underlying'));
},
callApi: async () => ({ success: true, data: null }),
};
await expect(client.getContext()).rejects.toBeInstanceOf(BridgeTransportError);For protocol failures ({success: false, error: {...}}) — those
are first-class data, not exceptions — return the failure envelope
from callApi and assert on the value, not the throw.
Testing client.car.*
The car-status surface needs a CarStatusBridge. Capture the
notify callback so the test can synchronously inject status events:
import { describe, expect, it, vi } from 'vitest';
import { MiniAppClient, type CarStatusBridge } from '@i99dash/sdk';
let pushStatus: ((raw: unknown) => void) | undefined;
const bridge: CarStatusBridge = {
// Base Bridge:
getContext: async () => ctx,
callApi: async () => ({ success: true, data: null }),
// Streaming surface:
async getCarStatus() {
return {
vin: 'V', at: '2026-04-27T12:00:00Z', staleness: 'fresh',
doorsLocked: true,
};
},
async subscribeCarStatus(notify) {
pushStatus = notify;
return { id: 'sub-1' };
},
async unsubscribeCarStatus() { pushStatus = undefined; },
async subscribeCarConnectionState() { return { id: 'conn-1' }; },
async unsubscribeCarConnectionState() {},
};
it('updates the lock indicator on lock change', async () => {
const client = MiniAppClient.withBridge(bridge);
const cb = vi.fn();
client.car.onStatusChange(cb);
// The lazy-subscribe is async — drain microtasks once.
await Promise.resolve();
pushStatus?.({
vin: 'V', at: '2026-04-27T12:01:00Z', staleness: 'fresh',
doorsLocked: false,
});
expect(cb).toHaveBeenCalledOnce();
expect(cb.mock.calls[0][0].doorsLocked).toBe(false);
});Testing privileged ops (@i99dash/admin-sdk)
Use FakeAdminBridge — same idea, but for the admin surface:
import { AdminClient, FakeAdminBridge, snapshotFromList } from '@i99dash/admin-sdk';
const bridge = new FakeAdminBridge(async (req) => {
// Assert what your code sent the host:
expect(req.templateId).toBe('diag.tail_logs');
expect(req.idempotencyKey).toBeTruthy();
return { success: true, data: { lines: ['boot ok'] } };
});
const admin = AdminClient.withBridge({
bridge,
context: { appId: 'diagnostics-pro', vin: 'WDB1234567' },
catalog: snapshotFromList([/* CommandTemplate rows */]),
});
await admin.tailLogs({ lines: 50 });For idempotency-key-replay tests, write the handler to track call counts and assert the second call with the same key short-circuits.
What to test
The SDK is well-tested on its own — what you should test in your mini-app is your code's reaction to bridge results:
| Test case | What it catches |
|---|---|
| Happy path: real data → render | Forgot to map a field, off-by-one in pagination |
success: false envelope | Forgot the failure branch — common production crash |
BridgeTimeoutError thrown | UI doesn't show a stuck spinner forever |
NotInsideHostError (SSR) | Server-rendered page doesn't crash on first paint |
Empty activeCarId (no car bound) | UI doesn't render with empty VIN; gates correctly |
Stale car status (staleness: 'very_stale') | UI shows "no signal" instead of pretending it's live |
| Subscription cleanup on unmount | Memory leak under React useEffect strict-mode double-mount |
What NOT to test
- The SDK's schema validation — that's covered by the SDK's own
__tests__/. You don't need to assert thatgetContext()returns aMiniAppContext-typed value; the type system already promises that. - The host's behaviour — your mini-app ships a fake bridge in CI; the real host's contract is integration-tested in the host repo, not yours.
- Wire-format parsing — the SDK does the JSON / zod work.
Related
Bridge— full interface reference.- Errors reference — every error class.
- Best practices — handling these errors in production, not just tests.