MainSDK
Search⌘K
Interfaces

Developer experience

Testing kit

testing

Test against in-memory fakes of every interface, assert what your code sent, control time and inject provider outages, with no provider account.

Building for 0.4ships as a stable APIPhase 1 · Core, public betaSpec testing-kitFree in your app, Apache-2.0

Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.

Stability The promise an interface ships with.

  • Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
  • Beta Usable in production; may change before it becomes stable.
  • Experimental May change or go away; needs an explicit opt-in in main.yaml.
Stability levels
Updated 2026-10-28

@mainsdk/testing lets you test code that uses MainSDK with no provider account or network. createTestApp() builds your app from main.yaml on in-memory drivers and a clock you control.

It makes that app the one behind import { files } from "mainsdk", so your route code runs unchanged, and a network never leaves the process. Reach for it in every unit and integration test, and in CI.

At a glance

Calls

  • createTestApp({ main })
  • createSim({ apps, start, seed })
  • sim.run(duration, { step })
  • sim.inject.cpu(app, curve)
  • sim.expect.signal(app, name)
  • sim.report({ golden })
  • app.fixtures(opts?)
  • mailbox(app).toHaveSent()
  • clock.advance()
  • chaos.on()
  • expect(app.pay).toHaveCharged()
  • assertFakesCover(ids)
  • runDriverKit(driver)
  • policyTest()
  • createApp(options)
  • configure(app)
  • app()
  • fakeAiDriver()
  • testCards()
  • new App(opts?)

Guarantees

Whatever the driver.

  • A fake for every interface, generated from its contract

Drivers

1 drivers. Select one to see its line in main.yaml.

main.yamlThe local drivers with recording and chaos (default)
testing:
  driver: local

Change this one line to swap providers; your calls stay the same.Built-in and local drivers integration

Codes

No check or runtime codes are assigned to this interface yet.

Features

Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.

FeaturePhaseMilestoneSpec ticket
In-memory fakes for every interfacePhase 10.4testing-kit#fakes-core
Assertions and a controllable clockPhase 10.4testing-kit#assertions-time
Policy test helpersPhase 10.4testing-kit#policy-helpers
Chaos drillsPhase 10.4testing-kit#chaos-drills
Conformance suitePhase 10.4testing-kit#conformance-suite
Parity vectors for the other language SDKsPhase 10.4testing-kit#parity-vectors
Driver kitPhase 10.4testing-kit#driver-kit

Quick start

The kit runs from the repository; it is not published to npm yet. Import it from your workspace.

app/rates.test.ts
import { http } from "mainsdk";
import { createTestApp } from "@mainsdk/testing";

const app = createTestApp({
  yaml: "schema: 1\nnetwork:\n  allow: [api.partner.com]",
  respond: () => Response.json({ rate: 1.1 }),   // answers every call the allow list lets through
});

const res = await http.fetch("https://api.partner.com/v1/rates");
console.log(await res.json(), app.sent.length);   // { rate: 1.1 } 1

An undeclared host is refused with MAIN-NET-0002, exactly as in production.

Configuration

The kit has no main.yaml block. It reads your config and puts every interface on a fake.

OptionWhat it does
mainPath to main.yaml. Overlays are not applied, so tests see the base config
yaml or configThe config as text or an object, instead of a file. With none, you get schema: 1
respondAnswers outbound calls that network.allow lets through; the default is 204
dnsFake addresses by host, for the network layer's DNS checks
secretsSecret values by name
clockYour own createClock()
resources, metrics, signals, exitFake sources for health and resources, a metrics sink, shutdown signals and what ends the process
authWhat sign-in calls out to, such as fakeWhatsApp()
dir, configDriver, broadcastBus, fsWatchFor live-config tests that link several apps
realTimersUse real timers for tests that talk to a real socket

What you get

  • createTestApp(): your app built from main.yaml with every interface on an in-memory driver, behind the module-level mainsdk imports.
  • A test clock (clock.advance) and a faked network that refuses undeclared hosts as production does.
  • Tenant helpers (asTenant, twoTenants), a mailbox and notify channels, and fakes for AI, payments, sign-in, resources and metrics.
  • Assertions for what your code sent, such as app.sent, mailbox(app).toHaveSent() and expect(app.pay).toHaveCharged().
  • chaos.on() drills, policyTest(), createSim for a simulated afternoon, app.fixtures(), the driver kit and the parity vectors.

Examples

Control time

app.clock.advance() moves time with no real waiting, for expiring links, TTLs and retries. clock.tick() also lets the work each timer starts finish.

app/cache.test.ts
import { kv } from "mainsdk";
import { createTestApp } from "@mainsdk/testing";

const app = createTestApp();
await kv.set("k", 1, { ttl: "1h" });
app.clock.advance("2h");
console.log(await kv.get("k"));   // null

Upload without a browser

app/avatar.test.ts
import { files } from "mainsdk";
import { createTestApp } from "@mainsdk/testing";

const app = createTestApp();
const up = await files.uploadUrl({ owner: "u1", types: ["image/png"] });
await app.files.put(up, new Uint8Array([0x89, 0x50, 0x4e, 0x47]), { type: "image/png" });
const ref = await files.complete(up.ref.id, { owner: "u1" });   // ref.status is "ready"

Run as a tenant, and prove two tenants stay apart

app.asTenant({ id, plan?, roles?, user? }) runs code as a member of a tenant, making it on first use (ids look like tnt_acme). twoTenants(app, { seed, routes }) seeds a unique marker as one tenant, reads your routes as another and fails naming the route and interface that leaked it.

app/tenancy.test.ts
import { createTestApp, twoTenants } from "@mainsdk/testing";

const app = createTestApp({ yaml: "schema: 1\ntenancy:\n  resolve: [session]\n" });
const notes = new Map<string, string>();

await twoTenants(app, {
  seed: async (marker) => { notes.set(app.tenancy.prefix("note"), marker); },
  routes: {
    "GET /note": { interface: "kv", read: async () => notes.get(app.tenancy.prefix("note")) ?? null },
  },
});

Other helpers

HelperWhat it does
app.apikeys.issue({ tenant, scopes }), withApiKey(req, key)Issue a key without a caller, and send it as a bearer token (needs an apikeys section)
app.entitlements.setPlan(), setUsage()Put a tenant on a plan and set the usage a limit reads (needs plans)
app.limits.exhaust(policy, key), storeDown(true)Use a policy up, or make the shared store fail
app.protect.signals(), party(), chain()Control the signals and parties of the local protect driver
expectQueries(handler, { repeat? })Fail on an N+1 query pattern with MAIN-DATA-0054
fakeResources(), fakeSignals(), fakeMetrics()Fake sources for status and resources tests
createFakeOidcProvider(), createFakeAuthenticator(), fakeWhatsApp()Fake sign-in providers
fakeAiDriver(), testCards() (from mainsdk)A model that answers from a script, and a payment driver with test card numbers; neither can run in production (MAIN-CHECK-0026)

Assertions and policy tests

mailbox(app).toHaveSent({ to, template, times }) fails with the messages that were sent when none match. watchCharges(app.pay) records the checkouts made through the fake, and expect(app.pay).toHaveCharged({ amount: 4900, currency: "INR", times: 1 }) asserts on them in minor units.

policyTest(source) asks a policy set for a decision and checks the answer and the rule behind it. The source is a directory of Cedar files, or an engine with a decide, such as app.access.

ts
import { createTestApp, policyTest } from "@mainsdk/testing";

const app = createTestApp();

policyTest(app.access)
  .expect({ subject: { id: "u_2", roles: ["member"] }, action: "orders.update", resource: { type: "order", owner: "u_1" } })
  .toDecide("deny")
  .byRule("orders.owner_only");

assertFakesCover(ids) fails with MAIN-TESTING-0001 when an interface in the catalog has no fake in the kit, and fakeCoverage(ids) lists the resolved, planned and missing ones without failing.

Chaos drills

A drill sits on the fake network your app calls out through, so the real drivers run behind it and the contract's own timeouts, retries and fallbacks decide what happens. The test clock runs the waits, so a 3 second delay takes no real time.

ts
import { chaos, createTestApp } from "@mainsdk/testing";

const app = createTestApp({ main: "./main.yaml" });   // email: { driver: resend, fallback: [ses] }
chaos.on("email:resend", { outage: true });            // every call fails: the send goes through ses
chaos.on("email:resend", { latency: "3s" });           // times out per network.http.timeout, then fails over
chaos.on("email:resend", { respond: "malformed" });    // raises a typed error: MAIN-EMAIL-0004, not retryable
chaos.off();                                           // end every drill

A target is a driver (email:resend), a whole interface (email) or a host (api.resend.com). respond also takes "error" (a 500) or a status number. A drill that fails a call before it leaves is not in app.sent. An unknown target throws MAIN-TESTING-0002.

Test a driver

mainsdk driver test <path> [--interface email@1.4] [--json] runs the same suite that runDriverKit(driver) from @mainsdk/testing runs in code, and returns a report of passed, failed and skipped cases. The suite is generated for the interface version a driver claims and runs against a local harness that answers fake provider traffic. It is the suite the first-party drivers pass, so there is one bar.

A run prints a report like this:

text
email@1.4 conformance: 8 passed, 0 failed, 2 skipped · capability profile: attachments=no · idempotency=sdk

A driver is made with defineDriver and carries a manifest of its regions, data labels and hosts. A case the capability profile switches off is reported as skipped with the profile key, never as passed. A call to a host the manifest does not declare fails the run with MAIN-NET-0002, even when the driver caught the error. A driver with no manifest, or one that is not a defineDriver module, fails with MAIN-TESTING-0003. The suite for each interface version is generated from the contract, so it covers every operation and guarantee. mainsdk driver matrix [--out file] prints the compatibility matrix of the first-party drivers: each driver, interface version and pass or fail.

Parity vectors

mainsdk conformance export [--out conformance/] [--check] writes language-neutral JSON from the TypeScript reference: contract/main-yaml (every top-level key, set right and wrong, with the nested keys it covers), contract/errors (every registry code with its problem body and status) and contract/wire (licence tokens, build hashes, telemetry records), and a manifest.json with a digest. The same export twice gives the same files. --check fails and names every file that differs from a fresh export.

mainsdk conformance run --impl "<command>" [--only contract] [--json] plays the vectors to a runner in another language. The runner reads one JSON case per line on stdin and prints one {"id","result"} line per case it answers on stdout. A case it does not answer, or answers with {"id","skipped":true}, is reported as skipped, never as failed. A line that is not JSON raises MAIN-TESTING-0004.

Fixtures, seed data and previews

await app.fixtures({ seed: 7, tenants: 2, models: { orders: 3 } }) fills the test app's in-memory stores with rows that pass your schemas and returns { tenants, users, rows, digest, as }. The same seed number and counts give the same rows and the same digest as mainsdk seed. Pass data: { orders: [{ status: "refunded" }] } for rows you want by hand; every field you leave out is generated, and a row that fails its schema raises MAIN-SCHEMA-0001 with the path (["orders", 0, "status"]) and no value. await fx.as(fx.users[0], fn) runs fn in that user's tenant with a session for that user.

Generated emails use example.com, example.org, example.net or .test, phone numbers come from reserved ranges, and names, addresses and currencies follow the tenant's region. Fields labelled child, phi or sensitive get obviously synthetic values.

mainsdk seed [--number n] [--tenants n] [--reset] [--no-ai] [--init-local] [--env name] [--json] writes the same rows to your local database. List the modules that export your models under seed.schemas. It refuses production before it opens a connection (MAIN-SEED-0001) and any database that is not local, a preview or marked seedable (MAIN-SEED-0002). --reset deletes only the rows of tenants the seed made. A field it cannot generate raises MAIN-SEED-0003 and names the seed.models key to set. Set seed.ai to have free-text fields written by your ai driver, cached under .mainsdk/seed-cache/ and held to seed.ai.limit; past it, or when the driver fails, built-in text is used and MAIN-SEED-0004 is logged once.

mainsdk preview up|down|list|prune gives each git branch its own database: a copy of the sqlite file for preview.driver: local, a branch from preview.from for neon or supabase. up seeds it, keeps the connection as the secret PREVIEW_DB_<SLUG> and prints only that name, and returns the existing preview when you run it again. down deletes only a database up made (MAIN-PREVIEW-0003). prune deletes previews whose branch is gone or whose preview.ttl has passed. Every create and delete is written to the audit log. A db driver that cannot branch raises MAIN-PREVIEW-0001, and a provider failure deletes whatever it made and raises MAIN-PREVIEW-0002.

mainsdk check reports seed or preview set for production, and app code that imports mainsdk/seed outside tests and scripts (MAIN-SEED-0010), and a literal under seed.models that looks like real personal data (MAIN-SEED-0011).

Reference

CallWhat it does
clock.advance(by)Moves the test clock forward by a duration such as "2h" or by milliseconds, firing the timers that fall due. It does not wait for the work a timer starts; use clock.tick() for that.
new App(opts?)The class createTestApp() builds on: one running app from a main.yaml. Use createTestApp() in tests, and new App() only to wire a custom app.

Simulate an afternoon

createSim builds several apps on one fake clock and one seed, injects load, outages and key events through the SDK's own fakes, and returns a timeline that is the same on every run (spec intelligence-sim).

ts
import { createSim, ramp, step } from "@mainsdk/testing";

const sim = createSim({ apps: { web: { main: "./main.yaml" } }, start: "2026-10-27T00:00:00Z", seed: 42 });
sim.inject.loop("web", step({ from: 20, to: 300, at: "10m" }));      // event-loop p99 in ms
sim.inject.cpu("web", ramp({ from: 0.2, to: 0.95, over: "10m" }));   // share of the allowed cores
sim.inject.traffic("web", { rps: sim.curve.noise(sim.curve.constant(5), { sd: 1 }), errors: sim.curve.constant(0.02) });
sim.inject.dependency("web", "db", { down: "2m", at: "1m" });
await sim.run("20m", { step: "1s" });                                // 24 hours takes seconds
sim.expect.signal("web", "pressure.high").within("12m");
sim.report({ golden: "fixtures/sim/pressure.json" });                // UPDATE_GOLDEN=1 rewrites it
  • Curves are constant, ramp, step, spike, sine, noise and replay (a CSV of seconds and values). A curve reads milliseconds since its injection began.
  • A sim never emits a built-in signal: pressure.high in the timeline comes from the resources module. The app's main.yaml needs a signals, norms or playbooks section for the signal layer to run.
  • inject.attack and inject.key throw MAIN-SIM-0001 naming the spec that has no code yet (security-events, secret-rotation). MAIN-SIM-0002 is a bad input.
  • host: "sockets" runs the real host bus over the fake unix sockets in a temporary folder that sim.stop() removes (sim.home, sim.net). link: true on an app seals it and opens its app link to the other linked apps, so neighbours.ask works between them. connectOps is an MCP client for an app's ops socket.
  • sim.timeline(), sim.report(), sim.approve(runId) and sim.decline(runId) read and drive what the apps decided. pnpm e2e --only intelligence plays the connected-intelligence scenarios on the probe apps.

Planned

These are in spec testing-kit and not built. Until they are, assert on what the interface recorded: app.sent, app.log.auditRecords, fakeAiDriver().requests.

  • Assertions for webhooks, jobs and files beside toHaveSent and toHaveCharged.
  • The conformance suite for interfaces other than email@1 and files@1, and behaviour/, http/ and check/ parity vectors (the seal wire format too).
  • The testing kit in Rust, Python and Go: each language SDK ships its own (milestone 1.5).

Use a different app

The module-level interfaces use one app built from main.yaml on first use. app() returns it, createApp(options) builds another and configure(app) makes it current. createTestApp() does this for you.

test/setup.ts
import { app, configure, createApp } from "mainsdk";

configure(createApp({ yaml: "schema: 1" }));
const current = app();

Errors and checks

The kit has no check codes of its own; seed and preview add MAIN-SEED-0010 and 0011 to mainsdk check. It raises MAIN-TESTING-0001 to 0005 (a missing fake, a bad chaos target, a driver that cannot be tested, a broken runner protocol, a kit or schema that is not installed) and surfaces the codes of the interfaces under test, such as MAIN-NET-0002, MAIN-DATA-0054 and MAIN-TENANT-0005. See Errors.

Swapping drivers

Your tests do not change when your driver does: the fakes implement the interface contract, not a provider. Some interfaces ship a conformance helper for drivers, such as protectConformance for protect; mainsdk driver test runs the generated suite for email@1 and files@1.

Testing

The kit is what you test with, so the Quick start is the pattern: call createTestApp() with your main.yaml or an inline yaml, run your code, then assert on app.sent, app.log.auditRecords or the helper for the interface. Control time with clock.advance instead of waiting. See Test your app.

Security and privacy

  • Fakes need no network and no provider keys, so no secret enters your test environment.
  • The kit is free and open source, like every in-app capability.

FAQ

Do fakes behave like production? They run the same interface code with in-memory drivers, so the same checks apply. Provider-specific behaviour is what driver conformance tests check.

Does the app need a real database? No. createTestApp() uses in-memory stores.

Next

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet