Testing actions
Apps/Build/Testing actions

Testing actions

Test an app's actions on your own computer: they run against in-memory records that follow the same rules as the real ones, with Shopify's Admin API mocked. Nothing is sent to Inference Bureau or Shopify.

Running tests

Tests live in tests/ and import @inferencebureau/apps-sdk/test. ib-apps test runs them with the app's own Vitest, which loads TypeScript, so tests import action files as they are. It needs no sign-in. New apps start with a test of their install or sign-up action, and a vitest.config.ts that has Vitest run the SDK itself (server.deps.inline), which is how it loads the app's TypeScript files. An older app needs the same file:

import { defineConfig } from "vitest/config";

export default defineConfig({ test: { server: { deps: { inline: ["@inferencebureau/apps-sdk"] } } } });

Then:

npm install                       # once: new apps list vitest as a dev dependency
ib-apps test                      # every test in the folder
ib-apps test tests/approve --watch  # anything after test goes to Vitest

Keep tests out of api/: a file in api/models/<model>/actions/ is an action. tests/ syncs like any other folder and is never part of a build, and neither is Vitest.

A first test

loadApp() reads the folder the tests run in. Each test gets a fresh start from app.reset(), which empties the records, jobs, logs and recorded Shopify calls, and keeps the mocks.

// tests/approve.test.ts
import { beforeEach, expect, it } from "vitest";
import { TEST_SHOP, loadApp } from "@inferencebureau/apps-sdk/test";

// Every schema.ib.ts, action file and settings.ib.ts in the app's folder.
const app = await loadApp();
beforeEach(() => app.reset());

it("approves a review and publishes it to the product", async () => {
  app.shopify.on("PublishReviews", { metafieldsSet: { userErrors: [] } });
  const review = await app.seed("review", { shop: TEST_SHOP, productGid: "gid://shopify/Product/1", rating: 5, body: "Fits well.", authorName: "Sam" });

  const run = await app.run("review.approve", { id: review.id, shop: TEST_SHOP });

  expect(run.record).toMatchObject({ status: "approved" });
  expect(run.onSuccessError).toBeNull();
  expect(app.shopify.calls).toHaveLength(1);
});

Running actions

app.run("<model>.<action>", { id, params, shop }) runs an action as a call through the API does, and a global action by its name. It goes through the same runner as a deployment: params and the record arrive as JSON, the save or delete run asks for is applied with the model's validations when it returns, then onSuccess runs with the saved record.

What happenedIn a test
It workedResolves with record (null after a delete), result when the action returns one, and logs.
run threwRejects with ActionFailedError, an ApiError with status 422 and the message <model>.<action> failed: …. Nothing is written.
The write was refusedRejects with an ApiError, status 400, with each field's problem in problems.
onSuccess threwResolves, with the write standing and the error in onSuccessError, as a partial run does.
it("refuses a review that's too short", async () => {
  await expect(app.run("review.create", { params: { body: "meh", rating: 9 } })).rejects.toMatchObject({
    status: 400,
    problems: expect.arrayContaining([
      { field: "body", message: "must have a length of at least 10" },
      { field: "rating", message: "must be at most 5" },
    ]),
  });
});

it("reports an action that throws", async () => {
  const review = await app.seed("review", { /* … */ status: "approved" });
  await expect(app.run("review.edit", { id: review.id })).rejects.toThrow("review.edit failed: An approved review can't be edited.");
});

run and onSuccess share the action's timeoutMS: 30 seconds unless it says, or five minutes for a job, a webhook's run or a schedule's, and at most 15 minutes. A run that takes longer rejects with an ApiError, status 503, The action took longer than N seconds., and writes nothing; an onSuccess that does is reported in onSuccessError.

app.logs holds every line the actions logged, with the action's name. Pass { echo: true } to loadApp to print them as well.

Records

app.api is the client an action gets, over the in-memory records: filters, sorting and pages, create, update, delete, checkPassword, run, $global and enqueue. As through the API, create, update and delete run the model's action of that name when it has one. Field types, defaults, required, unique, ranges, lengths, patterns, enums and belongsTo parents are checked as they are for real.

  • app.seed(model, values) writes a record directly, skipping its actions (still validated): the state a test starts from.
  • app.records(model) lists a model's records, oldest first.
  • Vector search works with vectors. For text search and fields with a source, pass an embedder: loadApp(".", { embed: async (model, texts) => texts.map(toVector) }).

Permissions

app.run and app.api run as the system, as the app's own code does. To test what a role in accessControl/permissions.ib.ts may do, call as it. loadApp reads the file, and calls are held to it as the API holds them.

HelperWhat it does
app.runAs({ role, shop, userId, customerId, customerRecordId }, action, { id, params })Runs an action as a call for that role and store. The action sees caller. Without role, the call is held to the store only.
app.api.forShop(shop).asRole(role, session?)The API a Shopify session's or a proxy request's api is. Reads see what the role's read grants, through its filter, and new records are the store's. storefront-customers also sees only that customer's records.
What happenedIn a test
The role isn't granted itRejects with an ApiError, status 403, code policy_denied, naming the role and what it can't do.
The store or the grant's filter doesn't reach the recordRejects with status 404, as a missing record does, so ids can't be probed. Reads leave the record out.
A filter on { $session: "userId" } the caller doesn't haveMatches nothing, not everything.
it("lets shoppers send reviews but not read them, and staff moderate their own store's", async () => {
  // A storefront page's api: the store's, held to the role permissions.ib.ts gives shoppers.
  const shopper = app.api.forShop(TEST_SHOP).asRole("storefront-shoppers");
  const review = await shopper.review.create({ productGid, rating: 5, body: "Fits well, washes well." });
  expect(review.shop).toBe(TEST_SHOP);
  await expect(shopper.review.findMany()).rejects.toMatchObject({ status: 403, code: "policy_denied" });

  const staff = { role: "shopify-app-users", shop: TEST_SHOP };
  expect((await app.runAs(staff, "review.approve", { id: review.id })).record).toMatchObject({ status: "approved" });
  await expect(app.runAs({ ...staff, shop: "other.myshopify.com" }, "review.approve", { id: review.id })).rejects.toMatchObject({ status: 404 });
});

// A grant's filter on { $session: "userId" } sees the session passed here.
const mine = app.api.asRole("signed-in", { userId: "user_1" });
// A signed-in storefront customer sees only their own records, and makes new ones as theirs.
const customer = app.api.forShop(TEST_SHOP).asRole("storefront-customers", { customerId: "42", customerRecordId: me.id });

Shopify

An action's shopify client answers from mocks. A call nothing answers fails with the query in its message, so a test says which call it needs.

// Answer the Admin API: by text in the query (an operation name, a field) or a RegExp, latest first.
app.shopify.on("currentAppInstallation", { currentAppInstallation: { id: "gid://shopify/AppInstallation/1" } });
app.shopify.on("DefineReviews", (variables) => ({ metafieldDefinitionCreate: { userErrors: variables.definition ? [] : [{ message: "No definition" }] } }));
app.shopify.on("PublishReviews", () => { throw new ShopifyError("Access denied", []); });

await app.install();                                          // shopifyShop's install (or reinstall)
await app.webhook("products/update", fixtures.product({ title: "Tee" }));   // synced models, then their actions
await app.webhook("orders/create", fixtures.order({ total_price: "120.00" }), { shop: "other.myshopify.com" });
await app.webhook("customers/redact", fixtures.customersRedact({ customer: { id: 42 } }));

expect(app.shopify.calls.map((c) => c.shop)).toContain(TEST_SHOP);
HelperWhat it does
app.shopify.on(match, answer)Answers queries containing match with answer, or with what (variables, call) => data returns. Throw ShopifyError to fail the call. app.shopify.calls lists every call with its store, query and variables.
app.install(shop?), app.uninstall(shop?)Run shopifyShop's install, reinstall or uninstall action with what a real install sends. TEST_SHOP unless a store is named.
app.webhook(topic, payload, { shop })Writes synced models (products and their variants, collections, metaobjects, shop/update) through their actions, runs the privacy actions for customers/redact, customers/data_request and shop/redact, and runs the webhooks settings.ib.ts names when the payload matches their filter, with includeFields applied.
fixturesshop, product, variant, collection, customer, order, customersRedact, customersDataRequest and shopRedact: payloads as Shopify sends them, with believable values. Pass what the test cares about.

A webhook entry's filter is applied as Shopify applies it, in its search syntax: field:value (case-sensitive), field:>=10, field:* for any value, field:Prefix*, dot paths into nested fields (any element of a list matches), AND or a space, OR, NOT or a leading -, and parentheses. Tags match one at a time. A filter Shopify would refuse, or one naming a field its includeFields leaves out, throws.

// settings.ib.ts: webhooks: [{ topic: "orders/create", action: "bigOrder", filter: "total_price:>100" }]
expect(await app.webhook("orders/create", fixtures.order({ total_price: "19.00" }))).toEqual([]);   // not delivered
expect(await app.webhook("orders/create", fixtures.order({ total_price: "120.00" }))).toHaveLength(1);

matchesWebhookFilter("line_items.sku:TEE* AND -cancelled_at:*", order);   // one filter on its own

Jobs and schedules

api.enqueue adds to app.jobs instead of running. await app.runJobs() runs every queued job once, high priority first and whatever its delay, along with the jobs those queue. A job that fails stays queued with its last_error while it has tries left, so a second runJobs() retries it.

app.queueSchedules({ from, to }) queues what settings.ib.ts's schedules owe in that window: a job each time a cron matched, in its timezone, with params.scheduledAt. Each time is queued once, however often it's asked. Only schedules that run in the test app's environment count, and that is development unless loadApp is told otherwise. nextRuns(schedule, { after, count }) lists when a schedule or a cron fires next.

import { loadApp, nextRuns } from "@inferencebureau/apps-sdk/test";

// Schedules run in production unless they name development.
const app = await loadApp(".", { environment: "production" });

it("sends the digest at 03:00 New York time, DST and all", async () => {
  const queued = await app.queueSchedules({ from: "2026-03-06T00:00:00Z", to: "2026-03-09T00:00:00Z" });
  expect(queued.map((j) => j.params)).toEqual([
    { scheduledAt: "2026-03-06T08:00:00.000Z" },
    { scheduledAt: "2026-03-07T08:00:00.000Z" },
    { scheduledAt: "2026-03-08T07:00:00.000Z" },
  ]);
  await app.runJobs();
});

nextRuns("0 3 * * *", { timezone: "America/New_York", after: "2026-03-07T12:00:00Z", count: 2 });
// ["2026-03-08T07:00:00.000Z", "2026-03-09T07:00:00.000Z"]

What isn't covered

  • Pages and routes in web/: test the actions they call, as the role the route's api has, and check the pages in Preview and Logs.
  • Computed fields, and relationships other than belongsTo.