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 surface | What the test runtime provides |
|---|---|
api | api(...), api.json(...), api.badRequest(...), api.error(...), and api.providerError(...) responses. |
Http | Queue-based get, post, postJson, put, patch, and delete responses plus captured calls. |
Settings | Values from settings, with runtime mutation through runtime.settings.set(...). |
Secrets | Values from secrets, with runtime mutation through runtime.secrets.set(...). |
sync | In-memory listPage(...), checkpoint(...), and setCheckpoint(...) helpers. |
ingest | Captured canonical(...) and batch(...) calls with optional failure injection. |
Log | Captured debug, info, warn, and error entries. |
Connector / connector | Current connector, connection, and status metadata. |
OAuth | Connected, missing-token, missing-scope, expired, refreshable, and refresh-failed OAuth states. |
Entity | In-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
requiredScopesto include a scope not present inscopes. - Expired and refreshable: set
expiresAtin the past and providerefresh. - 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(...)andingest.batch(...)arguments- partial failure handling
- pagination and checkpoint response shape
- log messages that operators depend on