Testing

Unit-test connector routes locally with the SDK testing runtime.

Connector routes run inside Backfill with runtime globals like api, Http, Secrets, Settings, sync, and ingest. In local Node tests, those globals do not exist unless you install a test runtime first.

Use @backfill-io/sdk/testing to run route modules against mocked provider HTTP, mocked connection settings, mocked secrets, in-memory checkpoints, and captured ingest calls.

Install

Add a test runner and run tests through TypeScript:

pnpm add -D tsx
{
  "scripts": {
    "test": "node --test --import tsx test/*.test.ts"
  }
}

Basic route test

Install the runtime before importing a route module that imports SDK globals. The safest pattern is a dynamic import inside the test after runtime.install().

import assert from "node:assert/strict";
import test from "node:test";
import { connectorTestRuntime } from "@backfill-io/sdk/testing";

test("customers sync imports one page", async () => {
  const runtime = connectorTestRuntime({
    settings: { shop_domain: "demo.myshopify.com" },
    connector: { key: "shopify", provider: "shopify" },
    connection: {
      id: "conn_123",
      connectionId: "conn_123",
      connectorKey: "shopify",
      provider: "shopify",
    },
    oauth: {
      accessToken: "test_access_token",
      scopes: ["read_customers"],
      requiredScopes: ["read_customers"],
      provider: "shopify",
    },
  });

  try {
    runtime.install();

    const { POST } = await import("../src/api/sync/customers");

    runtime.http.postJsonOnce({
      status: 200,
      body: {
        data: {
          customers: {
            nodes: [
              {
                id: "gid://shopify/Customer/123",
                displayName: "Ada Lovelace",
              },
            ],
            pageInfo: { hasNextPage: false, endCursor: "cursor_1" },
          },
        },
      },
    });

    const response = await runtime.call(POST, {
      searchParams: { limit: "50" },
    });

    assert.equal(response.status, 200);
    assert.equal(runtime.http.calls.length, 1);
    assert.equal(runtime.ingest.calls.length, 1);
    assert.equal(runtime.ingest.calls[0].documentType, "customer");
    assert.equal(
      runtime.ingest.calls[0].idempotencyKey,
      "shopify:customer:gid://shopify/Customer/123",
    );
  } finally {
    runtime.restore();
  }
});

Always call runtime.restore() in a finally block. The runtime installs globals on globalThis; restoring prevents one test from leaking SDK state into the next.

Runtime setup

const runtime = connectorTestRuntime({
  settings: { shop_domain: "demo.myshopify.com" },
  tenantId: "tenant_123",
  extensionKey: "shopify",
  connector: { key: "shopify", provider: "shopify" },
  connection: {
    id: "conn_123",
    connectionId: "conn_123",
    connectorKey: "shopify",
    provider: "shopify",
    settings: { shop_domain: "demo.myshopify.com" },
  },
  oauth: {
    accessToken: "test_access_token",
    scopes: ["read_customers"],
    requiredScopes: ["read_customers"],
    provider: "shopify",
  },
});

The runtime installs mocks for:

SDK surfaceWhat the test runtime provides
apiapi(...), api.json(...), api.badRequest(...), api.error(...), and api.providerError(...) responses.
HttpQueue-based get, post, postJson, put, patch, and delete responses plus captured calls.
SettingsValues from settings, with runtime mutation through runtime.settings.set(...).
SecretsValues from secrets, with runtime mutation through runtime.secrets.set(...).
syncIn-memory listPage(...), checkpoint(...), and setCheckpoint(...) helpers.
ingestCaptured canonical(...) and batch(...) calls with optional failure injection.
LogCaptured debug, info, warn, and error entries.
Connector / connectorCurrent connector, connection, and status metadata.
OAuthConnected, missing-token, missing-scope, expired, refreshable, and refresh-failed OAuth states.
EntityIn-memory entity reads and writes for route code that imports it.

Mock provider calls

Queue provider responses before the route makes outbound calls:

runtime.http.getOnce({
  status: 200,
  body: { data: [{ id: "cus_123", name: "Ada Lovelace" }] },
});

runtime.http.respondOnce({
  method: "POST",
  url: /graphql\.json$/,
  status: 200,
  body: { data: { shop: { id: "gid://shopify/Shop/1" } } },
});

Assert the provider request shape through runtime.http.calls:

assert.equal(runtime.http.calls[0].method, "POST");
assert.match(runtime.http.calls[0].url, /admin\/api\/.*\/graphql\.json$/);
assert.equal(
  runtime.http.calls[0].opts?.headers?.["X-Shopify-Access-Token"],
  "test_access_token",
);

If a route calls Http without a queued response, the runtime throws. This usually means the test missed an upstream page or the route made an unexpected provider call.

Assert ingest behavior

For single-record routes, assert runtime.ingest.calls:

assert.deepEqual(runtime.ingest.calls[0].payload, {
  customer_id: "cus_123",
  display_name: "Ada Lovelace",
});
assert.equal(runtime.ingest.calls[0].stream, "customers");
assert.equal(runtime.ingest.calls[0].sourceId, "cus_123");

For page-based sync routes that use ingest.batch(...), assert both the batch call and the expanded per-record calls:

assert.equal(runtime.ingest.batchCalls.length, 1);
assert.equal(runtime.ingest.batchCalls[0].documentType, "customer");
assert.equal(runtime.ingest.calls.length, 100);

Test partial failures

Use failure injection to verify safe-ingest behavior. With continueOnError: true, ingest.batch(...) returns imported and failed counts instead of throwing on the first bad record.

runtime.ingest.failWhere((call) => call.sourceId === "bad", {
  reason: "fixture_rejected",
  message: "Fixture rejected",
  retryable: true,
});

const response = await runtime.call(POST);

assert.equal(response.body.imported, 1);
assert.equal(response.body.failed, 1);
assert.equal(response.body.failedRecords[0].sourceId, "bad");
assert.equal(response.body.failedRecords[0].retryable, true);

Use runtime.ingest.failNext(...) when the next canonical write should fail regardless of record contents.

Test checkpoints and pagination

Pass request query params through runtime.call(...) and assert the checkpoint written by the route:

const response = await runtime.call(POST, {
  searchParams: { limit: "25", cursor: "cursor_0" },
});

assert.equal(response.body.hasMore, true);
assert.deepEqual(runtime.sync.checkpoints.customers, {
  endCursor: "cursor_1",
});

This keeps pagination tests focused on connector code: request parsing, provider query construction, checkpoint writes, and response shape.

Test OAuth routes

Seed OAuth state with the oauth option. This lets setup and sync routes call OAuth.accessToken(), OAuth.connected(), OAuth.tokenInfo(), or Http with auth: { oauth: true } without a live provider.

const runtime = connectorTestRuntime({
  settings: { shop_domain: "demo.myshopify.com" },
  connector: { key: "shopify", provider: "shopify" },
  connection: {
    connectorKey: "shopify",
    provider: "shopify",
  },
  oauth: {
    accessToken: "test_access_token",
    scopes: ["read_orders", "read_customers"],
    requiredScopes: ["read_orders"],
    provider: "shopify",
    providerAccountId: "gid://shopify/Shop/1",
    providerAccountName: "Demo Shop",
  },
});

Useful OAuth cases:

  • Missing token: oauth: { accessToken: null }.
  • Missing scope: set requiredScopes to include a scope not present in scopes.
  • Expired and refreshable: set expiresAt in the past and provide refresh.
  • Permanent refresh failure: set refreshError.
  • Explicit reauth: call runtime.oauth.needsReauth("invalid_grant").

For providers that accept bearer auth, assert the Http OAuth helper was used:

Http.get("https://api.example.com/v1/orders", {
  auth: { oauth: true },
});

assert.equal(
  runtime.http.calls[0].opts?.headers?.authorization,
  "Bearer [REDACTED_OAUTH_TOKEN]",
);

The test runtime redacts OAuth bearer auth in captured HTTP calls. If your provider needs a custom token header, assert that header directly from the connector code.

What this is not

The testing runtime is a route-level unit test harness. It does not boot Backfill, run Ash resources, verify tenant permissions, execute the canonical reactor pipeline, or validate a deployed manifest. Use platform integration tests for those behaviors.

Use connector route tests for:

  • provider request construction
  • credentials and setting lookup
  • transform output
  • ingest.canonical(...) and ingest.batch(...) arguments
  • partial failure handling
  • pagination and checkpoint response shape
  • log messages that operators depend on