@douglance/stdb-test-utils
v1.0.0
Published
Enable fast, isolated unit testing of SpacetimeDB reducer and system logic without live database
Maintainers
Readme
@spacetimedb/test-utils
Enable fast, isolated unit testing of SpacetimeDB reducer and system logic without requiring a live database.
Installation
npm install --save-dev @spacetimedb/test-utils
# or
bun add --dev @spacetimedb/test-utilsProblem Solved
Testing SpacetimeDB reducers traditionally requires:
- Deploying to a live SpacetimeDB instance
- Complex setup and teardown
- Slow feedback loops
@spacetimedb/test-utils provides a lightweight mock environment for testing reducer logic in isolation with fast, synchronous tests.
Features
- Mock Database: In-memory table operations without SpacetimeDB runtime
- Mock Context: Simulated
ReducerContextwith sender, timestamp, and database access - Type-Safe: Full TypeScript support with generics
- Fast: Synchronous, in-process testing
- Bun Native: Uses Bun's built-in mock functions for assertions
Usage
Basic Example
import { describe, test, expect } from "bun:test";
import { createTestContext } from "@spacetimedb/test-utils";
import type { GameSchema } from "./generated/schema";
import { handleInput } from "./reducers/input";
describe("handleInput reducer", () => {
test("updates player input correctly", () => {
const { callReducer, db, setSender } = createTestContext(GameSchema);
// Setup: Create test data
const playerId = Identity.fromString("0x01");
db.playerInput.data.set(playerId.toHexString(), {
entity_id: playerId.toHexString(),
up: false,
down: false,
left: false,
right: false,
});
// Set the sender identity
setSender(playerId);
// Execute: Call the reducer
callReducer(handleInput, {
entity_id: playerId.toHexString(),
up: true,
down: false,
left: true,
right: false,
});
// Assert: Verify state changes
const updatedPlayer = db.playerInput.data.get(playerId.toHexString());
expect(updatedPlayer?.up).toBe(true);
expect(updatedPlayer?.left).toBe(true);
expect(updatedPlayer?.down).toBe(false);
expect(updatedPlayer?.right).toBe(false);
// Assert: Verify mock was called
expect(db.playerInput.update).toHaveBeenCalledTimes(1);
});
});Testing Insert Operations
test("join_game creates player entity", () => {
const { callReducer, db } = createTestContext(GameSchema);
callReducer(joinGame, { name: "Alice" });
expect(db.Entity.data.size).toBe(1);
expect(db.Position.data.size).toBe(1);
expect(db.PlayerTag.data.size).toBe(1);
const [entity] = Array.from(db.Entity.data.values());
expect(entity).toBeDefined();
expect(db.Entity.insert).toHaveBeenCalledTimes(1);
expect(db.Position.insert).toHaveBeenCalledTimes(1);
expect(db.PlayerTag.insert).toHaveBeenCalledTimes(1);
});Testing with Custom Sender
test("only owner can call admin reducer", () => {
const { callReducer, setSender, ctx } = createTestContext(GameSchema);
const adminId = Identity.fromString("0xADMIN");
const userId = Identity.fromString("0xUSER");
// Setup: Add admin to owner table
db.Owner.data.set(adminId.toHexString(), { id: adminId.toHexString() });
// Test 1: Admin can call
setSender(adminId);
expect(() => callReducer(resetGame, {})).not.toThrow();
// Test 2: Regular user cannot call
setSender(userId);
expect(() => callReducer(resetGame, {})).toThrow("Not authorized");
});Testing Physics Systems
test("physics system updates positions from velocity", () => {
const { callReducer, db } = createTestContext(GameSchema);
// Setup: Create entity with position and velocity
const entityId = Identity.fromString("0x01");
db.Position.data.set(entityId.toHexString(), {
entity_id: entityId.toHexString(),
x: 100,
y: 100,
});
db.Velocity.data.set(entityId.toHexString(), {
entity_id: entityId.toHexString(),
dx: 10,
dy: 20,
});
// Execute: Run physics tick
callReducer(physicsTick, { dt: 1.0 });
// Assert: Position updated by velocity
const pos = db.Position.data.get(entityId.toHexString());
expect(pos?.x).toBe(110);
expect(pos?.y).toBe(120);
});API Reference
createTestContext<S>(schema: S): TestContext<S>
Creates a test context with mock database and reducer context.
Parameters:
schema: SpacetimeDB schema definition (usually exported from generated code)
Returns: TestContext<S> with:
ctx: MockReducerContextwith sender, timestamp, db accessdb: Mock database with all tables from schemasetSender(identity: Identity): Change the sender identitycallReducer(fn, args): Execute a reducer function with args
MockTableOperations<TRow>
Each table in db provides:
data:Map<any, TRow>- Direct access to table rowsinsert(row: TRow): Mock function to insert rowupdate(row: TRow): Mock function to update rowdelete(primaryKey: any): Mock function to delete rowfind(primaryKey: any): Mock function to find row by keyiter(): Mock function returning iterator over all rows
All mock functions support Bun's assertion matchers:
expect(mockFn).toHaveBeenCalledWith(...args)expect(mockFn).toHaveBeenCalledTimes(n)
Testing Best Practices
1. Test Reducer Logic in Isolation
Focus on testing the business logic without SpacetimeDB runtime overhead:
test("damage reduces health", () => {
const { callReducer, db } = createTestContext(GameSchema);
db.Health.data.set("player1", { entity_id: "player1", hp: 100 });
callReducer(applyDamage, { entity_id: "player1", damage: 30 });
expect(db.Health.data.get("player1")?.hp).toBe(70);
});2. Verify Table Operations
Assert that reducers call the correct database operations:
test("leave_game removes all player components", () => {
const { callReducer, db } = createTestContext(GameSchema);
// Setup state
const playerId = "player1";
db.Entity.data.set(playerId, { id: playerId });
db.Position.data.set(playerId, { entity_id: playerId, x: 0, y: 0 });
db.Velocity.data.set(playerId, { entity_id: playerId, dx: 0, dy: 0 });
callReducer(leaveGame, { entity_id: playerId });
// Verify deletes were called
expect(db.Entity.delete).toHaveBeenCalledWith(playerId);
expect(db.Position.delete).toHaveBeenCalledWith(playerId);
expect(db.Velocity.delete).toHaveBeenCalledWith(playerId);
// Verify state is cleaned up
expect(db.Entity.data.has(playerId)).toBe(false);
});3. Test Edge Cases
Cover error conditions and boundary cases:
test("handleInput ignores input for non-existent player", () => {
const { callReducer, db } = createTestContext(GameSchema);
callReducer(handleInput, {
entity_id: "nonexistent",
up: true,
down: false,
left: false,
right: false,
});
expect(db.PlayerInput.update).not.toHaveBeenCalled();
});4. Use beforeEach for Setup
Keep tests isolated with fresh state:
describe("combat system", () => {
let testCtx: TestContext<typeof GameSchema>;
beforeEach(() => {
testCtx = createTestContext(GameSchema);
// Common setup
testCtx.db.Entity.data.set("player1", { id: "player1" });
testCtx.db.Health.data.set("player1", { entity_id: "player1", hp: 100 });
});
test("attack reduces target health", () => {
testCtx.callReducer(attack, { attacker: "player1", target: "enemy1", damage: 25 });
// ...
});
});Limitations
- Does not test SpacetimeDB-specific features (indexes, scheduled reducers, client subscriptions)
- Does not test network behavior or client-server interactions
- Does not validate schema constraints (use SpacetimeDB's schema validation for that)
For integration testing with real SpacetimeDB runtime, use spacetime publish with test database.
License
MIT
Contributing
Issues and PRs welcome at the project repository.
