Unit testing with sdk-test-utils
MockBridge — drive the SDK from a test without a real platform.
@gamee/sdk-test-utils is a sister package that ships a MockBridge. Inject it
in place of the real bridge and your tests can record outbound traffic, dictate
platform responses, and emit platform → game events on demand. Works with Vitest, Jest,
Mocha — anything that runs JS.
Install#
@gamee/sdk-test-utils ships from the same monorepo as @gamee/sdk and is
not on npm yet. Build it locally and link it the same way you linked the
SDK — see Install → option 1.
# Build alongside the SDK:
cd gamee-sdk
npm install
npm run build --workspace=@gamee/sdk
npm run build --workspace=@gamee/sdk-test-utils
// your-game/package.json
{
"devDependencies": {
"@gamee/sdk": "file:../gamee-sdk/packages/sdk",
"@gamee/sdk-test-utils": "file:../gamee-sdk/packages/sdk-test-utils"
}
}
The package has @gamee/sdk as a peer dependency — keep both at the same
version. Once published, the install will be npm install --save-dev @gamee/sdk-test-utils.
Anatomy of a test#
import { describe, expect, test, beforeEach, afterEach } from 'vitest';
import { createSdk, type GameeSDK } from '@gamee/sdk';
import { MockBridge } from '@gamee/sdk-test-utils';
let mock: MockBridge;
let sdk: GameeSDK;
beforeEach(() => {
// 1. New bridge + new SDK for every test — no shared state.
mock = new MockBridge();
sdk = createSdk({ bridge: mock });
});
afterEach(() => {
sdk.dispose();
mock.dispose();
});
test('ships score and ends the run', async () => {
// 2. By default, every request is auto-acked with `null` data — perfect
// for fire-and-forget signals like updateScore / gameOver.
await sdk.init({ capabilities: [] });
sdk.updateScore({ score: 420, playTime: 12.3, checksum: 'sum' });
sdk.gameOver({ saveStateData: { highScore: 420 } });
// 3. Inspect the recorded traffic.
const methods = mock.requests.map((r) => r.method);
expect(methods).toEqual(['init', 'updateScore', 'gameOver']);
const score = mock.requests.find((r) => r.method === 'updateScore');
expect(score?.data).toMatchObject({ score: 420, gameChecksum: 'sum' });
});
Setting expectations#
Use expect(method).respondWith(...) to dictate the platform’s reply for a single
RPC. Most useful for data-returning methods.
test('grants a reward when the ad plays', async () => {
mock.expect('showRewardedVideo').respondWith({ videoPlayed: true });
await sdk.init({ capabilities: ['rewardedAds'] });
const result = await sdk.showRewardedVideo();
expect(result.videoPlayed).toBe(true);
});
Other forms on the same builder:
mock.expect('purchaseItemWithGems').rejectWith({
code: 'BRIDGE_ERROR',
message: 'insufficient gems',
});
mock.expect('showRewardedVideo').leavePending(); // resolve later via respondTo()
Expectations are matched in FIFO order per method. They consume on first
match; anything unmatched falls back to defaultResolution.
defaultResolution#
What to do with a request that has no matching expectation:
| Mode | Effect |
|---|---|
'ack-null' (default) | Auto-ack with data: null. Good for signals. |
'leave-pending' | Don’t respond at all. The Promise stays pending until you call respondTo() or hit the timeout. |
'error' | Respond with { code: 'NOT_EXPECTED', ... }. Use to enforce that every RPC has an expectation. |
mock.defaultResolution = 'error'; // strict mode
Emitting platform → game events#
mock.emit(name, payload) reproduces the wire-level dispatch. The SDK auto-acks
the message and then runs every on(name, ...) handler.
test('pauses the game loop on platform pause', async () => {
await sdk.init({ capabilities: [] });
let running = true;
sdk.on('pause', () => {
running = false;
});
mock.emit('pause', undefined);
expect(running).toBe(false);
});
test('passes start payload through', async () => {
await sdk.init({ capabilities: [] });
let seed: string | undefined;
sdk.on('start', (p) => {
seed = p.gameSeed;
});
mock.emit('start', { gameSeed: 'abc-123' });
expect(seed).toBe('abc-123');
});
Asserting validation / capability errors#
These throw synchronously on the SDK boundary — wrap in expect(...).toThrow,
no await:
import { GameeError } from '@gamee/sdk';
test('rejects gameSaveState without saveState capability', async () => {
await sdk.init({ capabilities: [] });
expect(() => sdk.gameSaveState({ x: 1 })).toThrow(/CAPABILITY_MISSING|saveState/);
});
test('rejects oversized log event names', async () => {
await sdk.init({ capabilities: ['logEvents'] });
expect(() => sdk.logEvent({ name: 'x'.repeat(25), value: '' })).toThrow(GameeError);
});
For Promise-returning methods, use await expect(...).rejects.toThrow(...):
test('forwards platform errors as GameeError', async () => {
mock.expect('showRewardedVideo').rejectWith({
code: 'BRIDGE_ERROR',
message: 'no fill',
});
await sdk.init({ capabilities: ['rewardedAds'] });
await expect(sdk.showRewardedVideo()).rejects.toThrow('no fill');
});
Testing timeouts#
Combine leavePending with a small requestTimeoutMs:
test('rejects with BRIDGE_TIMEOUT when the platform is silent', async () => {
sdk.dispose(); // throw away the default sdk
sdk = createSdk({ bridge: mock, requestTimeoutMs: 50 });
mock.expect('showRewardedVideo').leavePending();
await sdk.init({ capabilities: ['rewardedAds'] });
await expect(sdk.showRewardedVideo()).rejects.toMatchObject({
code: 'BRIDGE_TIMEOUT',
});
});
Late responses with respondTo#
For ad-hoc test choreography, settle a pending request manually:
test('drives the response from the test body', async () => {
mock.expect('loadRewardedVideo').leavePending();
await sdk.init({ capabilities: ['rewardedAds'] });
const promise = sdk.loadRewardedVideo();
// ...assert intermediate UI state here...
mock.respondTo('loadRewardedVideo', { videoLoaded: true });
await expect(promise).resolves.toEqual({ videoLoaded: true });
});
Other handy properties#
| Property | What it gives you |
|---|---|
mock.requests | All RPCs the SDK has sent (including init). |
mock.acked | messageIds of platform events the SDK acknowledged. |
mock.platform | The platform the bridge advertises (default 'web'). |
mock.reset() | Clear requests and any unconsumed expectations. |
mock.dispose() | Detach from the SDK. Safe to call repeatedly. |
Constructor takes the platform string for tests that branch on it:
const iosMock = new MockBridge('ios');
expect(createSdk({ bridge: iosMock }).getPlatform()).toBe('ios');