Test code that reads outbound data without a vendor key
Tests for your own code that run on the in-memory adapter with a seed you write, covering unsupported calls, vendor failures, paging and recorded answers, plus what the in-memory adapter does not tell you.
Code that reads campaigns and replies should have tests that run in CI with no key, no network and no vendor account. The in-memory adapter is a complete adapter over data you hand it. Your code runs against it unchanged, because it takes an Outbound instance and does not know which adapter is behind it.
This guide tests one small function five ways: with a seed, with capabilities turned off, with injected failures, with small pages, and against recorded answers. Then it says where the in-memory adapter differs from a vendor, because a green run here proves your code and nothing about the vendor.
Before you start
- Node 22 or later.
- No key. Each sample on this page is a file in the repository that the test suite runs; the output shown is what it printed.
- The samples are plain scripts that assert with
node:assert/strictand print a line per check, because that is how this site runs them. In your project, put the same assertions insidetest()fromnode:test, orit()in Vitest or Jest. Nothing here depends on a test runner.
What you get
ok - only active campaigns are summarised
ok - counts are added up over the days seeded
ok - no results is null, not 0
ok - refs are provider:workspace:id
The code under test
One line per active campaign: sends, reply rate and how many replies the vendor labelled positive.
// The code under test: one line per active campaign.
import type { Outbound } from "outbound-sdk";
export interface CampaignLine {
readonly name: string;
readonly sent: number | null;
readonly replyRate: number | null;
/** `null` when the adapter cannot say which replies are positive. */
readonly positiveReplies: number | null;
}
export const campaignSummary = async (
outbound: Outbound
): Promise<CampaignLine[]> => {
const lines: CampaignLine[] = [];
for await (const campaign of outbound.campaigns.listAll({
status: "active",
})) {
const handle = outbound.campaign(campaign.ref);
const stats = await handle.stats();
const [total] = stats.rows;
const sent = total?.sent ?? null;
const replied = total?.replied ?? null;
const positive =
outbound.capabilities["replies.category"] === false
? null
: await handle.replies.listAll({ category: "positive" }).toArray();
lines.push({
name: campaign.name,
positiveReplies: positive === null ? null : positive.length,
replyRate:
sent === null || replied === null || sent === 0 ? null : replied / sent,
sent,
});
}
return lines;
};
It takes the instance as an argument. That is the whole trick: production passes one built on instantly(), a test passes one built on memory().
A seed of your own
Only id is required on every seed record, and every other field has a default. State what the test is about and leave the rest out.
// Your own seed: only an id is required, so state what the test is about and
// nothing else. Three campaigns: two active, one paused, one with no results.
import type { ReplyCategory } from "outbound-sdk";
import type { MemoryData, MemoryMessage } from "outbound-sdk/memory";
const reply = (
id: string,
campaignId: string,
category: ReplyCategory
): MemoryMessage => ({
campaignId,
category,
direction: "in",
id,
sentAt: "2026-11-12T10:00:00Z",
});
export const seed: MemoryData = {
campaigns: [
{ id: "cmp_founders", name: "SaaS founders" },
{ id: "cmp_agencies", name: "Agency owners" },
{ id: "cmp_old", name: "Last quarter", status: "paused" },
],
leads: Array.from({ length: 5 }, (_, index) => ({
campaignId: "cmp_founders",
company: "Northwind",
email: `lead${index + 1}@northwind.example.com`,
firstName: "Dana",
id: `lead_${index + 1}`,
})),
messages: [
reply("msg_1", "cmp_founders", "positive"),
reply("msg_2", "cmp_founders", "positive"),
reply("msg_3", "cmp_founders", "negative"),
],
stats: [
{
campaignId: "cmp_founders",
counts: { replied: 3, sent: 120 },
date: "2026-11-12",
},
{
campaignId: "cmp_founders",
counts: { replied: 3, sent: 80 },
date: "2026-11-13",
},
{
campaignId: "cmp_old",
counts: { replied: 9, sent: 900 },
date: "2026-08-01",
},
],
};
import assert from "node:assert/strict";
import { createOutbound } from "outbound-sdk";
import { memory } from "outbound-sdk/memory";
import { seed } from "./seed.ts";
import { campaignSummary } from "./summary.ts";
const outbound = createOutbound({ adapter: memory({ data: seed }) });
const [founders, agencies, ...rest] = await campaignSummary(outbound);
// The paused campaign is left out.
assert.equal(rest.length, 0);
console.log("ok - only active campaigns are summarised");
// 6 replies of 200 sends, 2 of them positive.
assert.deepEqual(founders, {
name: "SaaS founders",
positiveReplies: 2,
replyRate: 0.03,
sent: 200,
});
console.log("ok - counts are added up over the days seeded");
// A campaign with no results has no row at all: the summary must not turn
// that into a zero.
assert.deepEqual(agencies, {
name: "Agency owners",
positiveReplies: 0,
replyRate: null,
sent: null,
});
console.log("ok - no results is null, not 0");
// Every record carries where it came from.
const leads = await outbound.leads.list();
const [lead] = leads.items;
assert.equal(lead?.ref, "memory:default:lead_1");
assert.equal(lead?.campaignRef, "memory:default:cmp_founders");
console.log("ok - refs are provider:workspace:id");
That is the script that printed the four lines above. Three things worth asserting in your own tests:
- A missing count is
null. The campaign with no stats rows has no total, and the summary must not print0for it. - Refs, not ids. Every record carries
provider:workspace:id. Assert onrefwhen the code will ever run on more than one account. - Use dates near now. Code that asks for “the last 7 days” reads the clock. Seed dates relative to it, or pass your own clock with
createOutbound({ runtime: { now } }).
demoData() from outbound-sdk/memory is a ready-made seed for trying things out. For a test, write the seed in the test, so the expected numbers are visible next to the assertions.
Limit capabilities to exercise Unsupported
The in-memory adapter declares every capability. No vendor does. Pass capabilities to turn one off, and the instance refuses the calls that need it, before they reach the adapter, with the same error a vendor’s adapter gives.
To run a test as a particular vendor, limit the in-memory adapter to what that vendor’s adapter declares. The helper is a few lines, and every guide on this site uses it:
import assert from "node:assert/strict";
import { createOutbound } from "outbound-sdk";
import { asVendor, VENDORS } from "../_shared/as-vendor.ts";
import type { Vendor } from "../_shared/as-vendor.ts";
import { seed } from "./seed.ts";
import { campaignSummary } from "./summary.ts";
// The same test once per vendor, on the in-memory adapter limited to what
// that vendor's adapter declares.
const testAs = async (vendor: Vendor): Promise<string> => {
const outbound = createOutbound({ adapter: asVendor(vendor, seed) });
const [founders] = await campaignSummary(outbound);
assert.equal(founders?.sent, 200);
// HeyReach does not label replies, so the summary cannot count positives.
assert.equal(founders?.positiveReplies, vendor === "heyreach" ? null : 2);
return `ok - ${vendor}: sent ${founders?.sent}, positive replies ${founders?.positiveReplies}`;
};
const lines = await Promise.all(VENDORS.map(testAs));
console.log(lines.join("\n"));
ok - instantly: sent 200, positive replies 2
ok - smartlead: sent 200, positive replies 2
ok - heyreach: sent 200, positive replies null
This shows which calls a vendor’s adapter would refuse. It does not show what that vendor’s API returns. HeyReach does not label replies, so the summary reports null for positive replies there and does not call the filter.
Inject failures with a plugin
The in-memory adapter has no failure switch. A plugin’s wrap sits between your call and the adapter, and can throw the error a vendor’s adapter would.
import { OutboundError } from "outbound-sdk";
import type { OutboundErrorCode, OutboundPlugin } from "outbound-sdk";
/**
* Fails the first `times` operations the way a vendor would, then lets the
* rest through. The in-memory adapter has no failure switch of its own.
*/
export const failing = (
code: OutboundErrorCode,
times = 1,
retryAfterMs?: number
): OutboundPlugin => {
let left = times;
return {
name: "failing",
wrap: (operation, next, info) => {
if (left > 0) {
left -= 1;
throw new OutboundError(code, `injected ${code}`, {
provider: info.provider,
retryAfterMs,
});
}
return next(operation);
},
};
};
import assert from "node:assert/strict";
import { createOutbound, OutboundError } from "outbound-sdk";
import { memory } from "outbound-sdk/memory";
import { failing } from "./failing.ts";
import { seed } from "./seed.ts";
import { campaignSummary } from "./summary.ts";
// 1. Turn a capability off and the call is refused before it reaches the
// adapter, as it would be on a vendor that lacks it.
const noStats = createOutbound({
adapter: memory({ capabilities: { "stats.campaign": false }, data: seed }),
});
await assert.rejects(
campaignSummary(noStats),
(error) => OutboundError.is(error) && error.code === "Unsupported"
);
console.log("ok - no campaign stats: Unsupported");
// 2. A capability the code checks for itself: the fallback branch runs.
const noCategories = createOutbound({
adapter: memory({ capabilities: { "replies.category": false }, data: seed }),
});
const [line] = await campaignSummary(noCategories);
assert.equal(line?.positiveReplies, null);
assert.equal(line?.sent, 200);
console.log("ok - no reply categories: positiveReplies is null");
// 3. A vendor error reaches the caller as an OutboundError with its code.
const down = createOutbound({
adapter: memory({ data: seed }),
plugins: [failing("Provider")],
});
await assert.rejects(campaignSummary(down), (error) => {
assert.ok(OutboundError.is(error));
assert.equal(error.code, "Provider");
assert.equal(error.permanent, false, "worth trying again");
return true;
});
// The plugin failed one operation, so the second attempt goes through.
const secondTry = await campaignSummary(down);
assert.equal(secondTry.length, 2);
console.log("ok - Provider once, then the retry succeeds");
// 4. A rate limit carries how long the vendor asked you to wait.
const limited = createOutbound({
adapter: memory({ data: seed }),
plugins: [failing("RateLimited", 1, 30_000)],
});
await assert.rejects(limited.campaigns.list(), (error) => {
assert.ok(OutboundError.is(error));
assert.equal(error.retryAfterMs, 30_000);
return true;
});
console.log("ok - RateLimited carries retryAfterMs");
// 5. A rejected key is permanent: retrying cannot help.
const rejected = createOutbound({
adapter: memory({ data: seed }),
plugins: [failing("Unauthorized", Number.POSITIVE_INFINITY)],
});
await assert.rejects(rejected.campaigns.list(), (error) => {
assert.ok(OutboundError.is(error));
assert.equal(error.permanent, true);
return true;
});
console.log("ok - Unauthorized is permanent");
// 6. A bulk lookup reports a missing record as data. It does not throw.
const outbound = createOutbound({ adapter: memory({ data: seed }) });
const bulk = await outbound.campaigns.getMany(["cmp_founders", "cmp_nope"]);
assert.deepEqual(
bulk.results.map((result) => result.value.name),
["SaaS founders"]
);
assert.deepEqual(
bulk.errors.map((failure) => [failure.id, failure.error.code]),
[["cmp_nope", "NotFound"]]
);
assert.equal(await outbound.campaigns.exists("cmp_nope"), false);
console.log("ok - getMany returns the missing record as an error entry");
ok - no campaign stats: Unsupported
ok - no reply categories: positiveReplies is null
ok - Provider once, then the retry succeeds
ok - RateLimited carries retryAfterMs
ok - Unauthorized is permanent
ok - getMany returns the missing record as an error entry
| What happened | What you get | What to assert |
|---|---|---|
| A capability is off | Unsupported, before the adapter is called |
That your code checks outbound.capabilities first, or lets the error through on purpose. |
| The vendor is down | Provider, with permanent: false |
That your code retries later or reports it. It should not treat it as “no data”. |
| The vendor is rate limiting | RateLimited, with retryAfterMs when the vendor gave one |
That your code waits at least that long. |
| The key is rejected | Unauthorized, with permanent: true |
That your code stops and does not retry. |
| One record of a bulk lookup is missing | An entry in errors. getMany does not throw. |
That your code reads errors as well as results. |
An error thrown by a plugin reaches your code at once. The retries and waits a real adapter does for a 503 or a 429 happen in the request engine, below the plugins, and the in-memory adapter never gets there. To test those, use a real adapter with fetch replaced, as shown further down.
Paging with a small page size
The default page is 50 records. A five-record seed then fits on one page, and code that forgets to follow the cursor passes its tests. Set pageSize to 2.
import assert from "node:assert/strict";
import { createOutbound, OutboundError } from "outbound-sdk";
import { memory } from "outbound-sdk/memory";
import { seed } from "./seed.ts";
// Two records a page, so the five seeded leads are three pages. The default
// page size is 50, which would hide every paging bug in a small seed.
const outbound = createOutbound({
adapter: memory({ data: seed, pageSize: 2 }),
});
const first = await outbound.leads.list();
assert.equal(first.items.length, 2);
assert.equal(first.complete, false);
assert.ok(first.cursor !== undefined);
console.log("ok - a page says it is not the last and gives a cursor");
const second = await outbound.leads.list({ cursor: first.cursor });
const third = await outbound.leads.list({ cursor: second.cursor });
assert.deepEqual(
[...second.items, ...third.items].map((lead) => lead.sourceId),
["lead_3", "lead_4", "lead_5"]
);
assert.equal(third.complete, true);
assert.equal(third.cursor, undefined);
console.log("ok - the cursor continues where the page ended");
const everyLead = await outbound.leads.listAll().toArray();
assert.equal(everyLead.length, 5);
console.log("ok - listAll walks every page");
const capped = outbound.leads.listAll({ max: 3 });
const firstThree = await capped.toArray();
assert.equal(firstThree.length, 3);
assert.equal(capped.truncated, true);
console.log("ok - max stops the walk and says records were left");
// A cursor belongs to the listing that issued it.
await assert.rejects(
outbound.campaigns.list({ cursor: first.cursor }),
(error) => OutboundError.is(error) && error.code === "Invalid"
);
console.log("ok - a leads cursor is refused by campaigns");
ok - a page says it is not the last and gives a cursor
ok - the cursor continues where the page ended
ok - listAll walks every page
ok - max stops the walk and says records were left
ok - a leads cursor is refused by campaigns
Record once, replay in every test
The fixtures plugin saves each answer to a file and serves it back. record calls the adapter and saves, replay answers from disk and never calls the adapter, and auto (the default) replays when a file exists and records when it does not. Personal data is replaced with numbered placeholders before anything is saved.
This sample records against the in-memory adapter into the system’s temporary folder, then replays with an adapter that holds no data at all.
import assert from "node:assert/strict";
import { mkdtemp, readdir, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { createOutbound, OutboundError } from "outbound-sdk";
import { fixtures } from "outbound-sdk/fixtures";
import { memory } from "outbound-sdk/memory";
import { seed } from "./seed.ts";
const dir = await mkdtemp(path.join(tmpdir(), "outbound-fixtures-"));
// Record. Against a real vendor this is the one run that needs a key.
const recording = createOutbound({
adapter: memory({ data: seed }),
plugins: [fixtures({ dir, mode: "record" })],
});
const recorded = await recording.leads.list();
await recording.campaigns.list();
const files = await readdir(dir);
console.log("files:", files.toSorted());
// What was saved, and what the recording run itself got back, is scrubbed.
assert.equal(recorded.items[0]?.email, "person1@example.com");
assert.equal(recorded.items[0]?.company, "Company 1");
console.log("ok - personal data is replaced before it is saved");
// Replay. The adapter here is empty: every answer comes from the files.
const replaying = createOutbound({
adapter: memory(),
plugins: [fixtures({ dir, mode: "replay" })],
});
const replayed = await replaying.leads.list();
assert.deepEqual(replayed.items, recorded.items);
assert.equal(replayed.items.length, 5);
console.log("ok - replay answers from disk without the adapter");
// A read that was never recorded fails. It does not fall through to the
// vendor, and different options are a different read.
await assert.rejects(
replaying.leads.list({ campaignId: "cmp_founders" }),
(error) => OutboundError.is(error) && error.code === "NotFound"
);
console.log("ok - an unrecorded read is NotFound");
await rm(dir, { recursive: true });
files: [
'memory.default.campaigns.list.1d2c822351e4.json',
'memory.default.leads.list.5489d1f8c1a0.json'
]
ok - personal data is replaced before it is saved
ok - replay answers from disk without the adapter
ok - an unrecorded read is NotFound
What to know before relying on it:
- A file is one operation. Its name is the provider, the workspace, the operation and a hash of the workspace and the operation’s exact options. A read with another filter, page size or cursor has no file and fails with
NotFoundinreplay. - The recording run gets the scrubbed data too. Assert on the placeholders (
person1@example.com,Company 1), not on the real values. The same real value always gets the same placeholder from one instance of the plugin. Counts and stats are kept as they are. - List it last when recording, so what is saved is the adapter’s answer and not another plugin’s view of it.
- A recording made with a real key is a snapshot of that account on that day. It is still the only one of these techniques that holds real vendor responses, as mapped by the adapter. Commit the files only after reading them: scrubbing covers the fields the SDK knows to be personal.
The adapter itself, without a key
Everything above replaces the adapter. To run a real adapter’s own code (its mapping, its request count) with no key, give createOutbound a fetch of your own. This one answers from Instantly’s documented payloads in the repository’s fixtures/instantly/ folder. They are built from Instantly’s published schema and are not live responses.
// A real adapter with a made-up key and no network. `fetch` is replaced with
// one that answers from Instantly's documented payloads in the repository's
// fixtures/instantly/ folder. Those are not live responses.
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { createOutbound } from "outbound-sdk";
import { instantly } from "outbound-sdk/instantly";
const FIXTURES = new URL("../../../../fixtures/instantly/", import.meta.url);
const DOCUMENTED = new Map([
["/api/v2/campaigns", "listCampaigns.page2.json"],
["/api/v2/custom-tag-mappings", "listCustomTagMappings.json"],
["/api/v2/custom-tags", "listCustomTags.page2.json"],
]);
const paths: string[] = [];
const outbound = createOutbound({
adapter: instantly({ apiKey: "example-key", workspaceId: "acme" }),
fetch: (input, init) => {
const { pathname } = new URL(new Request(input, init).url);
const file = DOCUMENTED.get(pathname);
paths.push(pathname);
return Promise.resolve(
file === undefined
? new Response("{}", { status: 404 })
: new Response(readFileSync(new URL(file, FIXTURES), "utf-8"))
);
},
});
const page = await outbound.campaigns.list();
// The adapter's own mapping ran: Instantly's numeric status became a word.
assert.equal(page.items[0]?.name, "Third Campaign");
assert.equal(page.items[0]?.status, "paused");
console.log("ok - the documented payload maps to a Campaign");
// Unlike the in-memory adapter, this one counts requests.
assert.equal(page.meta.requests, paths.length);
console.log(`ok - ${page.meta.requests} requests:`, paths.join(", "));
ok - the documented payload maps to a Campaign
ok - 3 requests: /api/v2/campaigns, /api/v2/custom-tag-mappings, /api/v2/custom-tags
A fetch like this can also answer 503 or 429 to exercise retries. Those wait on real timers unless you replace them too, with the runtime option.
Where memory differs from a real vendor
| On the in-memory adapter | On a vendor’s adapter |
|---|---|
| Every capability is on unless you turn it off. | Each vendor lacks some. Run your tests as each vendor you support. |
No HTTP request is made. meta.requests is 0 and onRequest hooks never fire. |
Each read costs requests, some more than one per page. |
| Nothing is rate limited, throttled or retried. | Calls wait for the rate budget and retry failures. deadline and maxRequests matter. |
| Filters are exact: status, date and category do what they say. | Some filters are applied by the SDK after fetching, and some are refused. |
Evidence is always exact. |
An answer can be derived or sampled, and a count the vendor does not report is null. |
A page is pageSize records, by offset. |
Page sizes and cursors are the vendor’s. A page can come back short. |
| Ids are whatever you seeded. | Ids are the vendor’s: UUIDs, numbers, or a LinkedIn profile URL for a HeyReach lead. |
raw is null and there are no caveats. |
raw holds the vendor’s payload, and outbound.caveats lists what the adapter assumes. |
| It never fails by itself. | Keys expire, records disappear, vendors go down. |
A test on the in-memory adapter proves that your code is correct for the data it was given. Whether a vendor returns that data is a separate question, answered by what is verified and by running your code once against a real account.
Limits
- The in-memory adapter holds the data you gave it when it was created. To test “the data changed”, create a second adapter with the second seed.
- Fixture files are tied to the exact operation. Changing a filter in your code means recording again.
- Scrubbed recordings cannot test code that depends on real names, domains or message text.
- Nothing on this page checks a vendor’s behaviour. Instantly and HeyReach are
betaand Smartlead isunverified: see what is verified.