MainSDK
Search⌘K
How-to

Test your app

Test Pinecart with createTestApp from @mainsdk/testing - in-memory fakes, assertions, time control, chaos drills and policy tests, with no provider keys.

Building for 0.4

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

Write tests for code that uses MainSDK and run them with no network and no provider keys. createTestApp from @mainsdk/testing puts every interface on an in-memory fake generated from the same contracts as the SDK, so a fake can't drift from production. The full API is on Testing kit; this guide walks through the common tasks.

Before you start: a MainSDK app with a main.yaml, and a test runner. Node's built-in runner (node:test) is the example; any runner works.

1. Install the kit

npm install --save-dev @mainsdk/testing

2. Write a test with fakes

createTestApp({ main: "./main.yaml" }) reads your main.yaml and puts every interface on its fake. Fakes enforce the same guarantees as production: email.marketing() without an unsubscribe link fails in a test too.

tests/checkout.test.ts
import { describe, it } from "node:test";
import { createTestApp, expect, mailbox, type TestApp } from "@mainsdk/testing";

// Your own code, from app/lib/orders.ts: it takes the app so a test can pass the fake one.
declare function placeOrder(app: TestApp, order: { email: string; amount: number; currency: string }): Promise<void>;

describe("checkout", () => {
  it("charges once and sends a receipt", async () => {
    const app = createTestApp({ main: "./main.yaml" });

    await placeOrder(app, { email: "a@pinecart.dev", amount: 4900, currency: "INR" });

    expect(app.pay).toHaveCharged({ amount: 4900, currency: "INR", times: 1 });
    mailbox(app).toHaveSent({ to: "a@pinecart.dev", template: "receipt" });
  });
});

3. Run it and verify

node --test tests/checkout.test.ts

The test passes with no provider account. To see a useful failure, change times: 1 to times: 2: the assertion fails and lists the one charge that did happen.

Control time

Expiring links, TTLs and retries run without real waiting:

tests/share.test.ts
import { it } from "node:test";
import { createTestApp, clock } from "@mainsdk/testing";
import { share } from "mainsdk";

it("share links expire after 24 hours", async () => {
  const app = createTestApp({ main: "./main.yaml" });
  const { url } = await share({ url: "https://pinecart.dev/orders/o_1" }, { expiresIn: "24h" });
  clock.advance("25h");
  // resolving url now reports it expired
});

Test policies

tests/policy.test.ts
import { it } from "node:test";
import { policyTest } from "@mainsdk/testing";

it("only owners update orders", () => {
  policyTest("policies/")
    .expect({
      subject: { type: "user", id: "u_2" },
      action: "orders.update",
      resource: { type: "order", id: "o_1", owner: "u_1" },
    })
    .toDecide("deny")
    .byRule("orders.owner_only");
});

From the terminal, mainsdk test policy runs your policy tests and replays recorded decisions, listing every decision a change would flip. See Policy as code.

Prove your fallbacks with chaos drills

Inject an outage, added latency or a malformed response into one driver and check that the fallback in main.yaml takes over.

main.yaml
fallback:
  email: [resend, ses]
tests/fallback.test.ts
import { it } from "node:test";
import { createTestApp, chaos, mailbox, type TestApp } from "@mainsdk/testing";

// Your own code, from app/lib/receipts.ts.
declare function sendReceipt(app: TestApp, orderId: string): Promise<void>;

it("fails over to ses when resend is down", async () => {
  const app = createTestApp({ main: "./main.yaml" });
  chaos.on("email:resend", { outage: true });

  await sendReceipt(app, "o_1");

  mailbox(app).toHaveSent({ template: "receipt", times: 1 });
});

With { latency: "3s" } the call times out per contract and fails over; with { respond: "malformed" } it raises a typed error.

Build and test your own driver

The driver kit lets anyone build a driver for an interface. mainsdk driver test runs exactly the conformance suite first-party drivers pass, against a local harness with fake traffic.

relaymail/index.ts
import { defineDriver, type EmailMessage } from "mainsdk";

export default defineDriver({
  implements: "email@1",
  id: "relaymail",
  manifest: {
    regions: ["us", "eu", "in"],
    data: ["email-address", "message-body"],
    subprocessors: ["Relaymail Inc."],
  },
  async send(msg: EmailMessage, ctx: { http: { post(path: string, body: unknown): Promise<{ id: string }> } }) {
    const res = await ctx.http.post("/v1/send", msg);
    return { id: res.id };
  },
});
bash
npx mainsdk driver test ./relaymail --interface email@1.4
text
email@1.4 conformance: 86 passed, 0 failed · capability profile: attachments=no · idempotency=sdk

A driver that calls a host it didn't declare fails with MAIN-NET-0002. defineDriver comes from mainsdk today. Whether the driver kit becomes its own package or part of @mainsdk/testing is still open.

Run another implementation against the golden vectors

The golden test vectors are plain data, so a port to another language can prove it behaves the same:

bash
npx mainsdk conformance export --out vectors/
npx mainsdk conformance run --impl "./my-port --vectors" --only config --json

export writes the vectors, and with --check it fails when the committed copy is stale. run feeds them to the command you name and exits 1 when a vector fails.

Next

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet