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';
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',
}),
};
const client = MiniAppClient.withBridge(bridge);
// External data is plain fetch() — stub global.fetch for the test.
vi.stubGlobal(
'fetch',
vi.fn(async () =>
new Response(JSON.stringify({ stations: [{ name: 'Aramco A', km: 0.4 }] })),
),
);
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. (Add import { vi } from 'vitest'
for the fetch stub.)
Asserting external API calls
For "did the page actually hit this endpoint?" tests, spy on
global.fetch — the external call doesn't go through the bridge:
import { vi } from 'vitest';
const fetchMock = vi.fn(async () => new Response('{"stations":[]}'));
vi.stubGlobal('fetch', fetchMock);
await render(MiniAppClient.withBridge({ getContext: async () => ctx }));
expect(fetchMock).toHaveBeenCalledWith(
'https://api.fuel.example.com/v1/stations?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. vi.stubGlobal is reset
by vi.unstubAllGlobals() in an afterEach.
Covering error paths
Each SDKError subclass is constructible — throw it from your
fake bridge to drive the error branch:
import { BridgeTransportError, BridgeTimeoutError } from 'i99dash';
const bridge: Bridge = {
getContext: async () => {
throw new BridgeTransportError('host crashed', new Error('underlying'));
},
};
await expect(client.getContext()).rejects.toBeInstanceOf(BridgeTransportError);For external API failures, drive them through the fetch stub — a
non-2xx Response (new Response('...', { status: 503 })) for a
server error, or fetchMock.mockRejectedValue(new TypeError('blocked'))
to simulate an offline / undeclared-origin (CSP) failure. Those are
ordinary web errors, not SDKErrors — assert on your component's
rendered fallback.
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';
let pushStatus: ((raw: unknown) => void) | undefined;
const bridge: CarStatusBridge = {
// Base Bridge:
getContext: async () => ctx,
// Streaming surface:
async getCarStatus() {
return {
deviceId: 'byd:BYDMCKLE0PARD8801', brand: 'byd',
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?.({
deviceId: 'byd:BYDMCKLE0PARD8801', brand: 'byd',
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)
Use FakeAdminBridge — same idea, but for the admin surface:
import { AdminClient, FakeAdminBridge, snapshotFromList } from 'i99dash';
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', deviceId: 'byd:BYDMCKLE0PARD8801', brand: 'byd' },
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 device ID; 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.