Test without a provider
Test SMS code without a provider account or network: the memory() adapter, scripted failures, mockFetch, and signed webhook requests.
Tests and CI should never send a real text. @opencoredev/sms-sdk/testing gives you a fake adapter for app code, a fake fetch for adapter code, and builders for signed webhook requests. The runnable version of this page is examples/test-adapter.
Use the memory adapter
memory() makes no network calls, records every send, and accepts it unless you script a failure. To use it, have your app code take an SmsClient as an argument, or import it from one module you can swap.
import { expect, test } from "bun:test";
import { createSmsClient, type SmsClient } from "@opencoredev/sms-sdk";
import { memory } from "@opencoredev/sms-sdk/testing";
async function sendLoginCode(sms: SmsClient, to: `+${string}`, code: string): Promise<string> {
const result = await sms.send({ to, body: `Your login code is ${code}`, idempotencyKey: `login:${to}:${code}` });
return result.providerId;
}
test("sends the login code", async () => {
const adapter = memory();
const sms = createSmsClient({ adapters: [adapter] });
const providerId = await sendLoginCode(sms, "+14155550123", "482913");
expect(providerId).toBe("memory_1");
expect(adapter.sent).toHaveLength(1);
expect(adapter.sent[0]?.message.body).toBe("Your login code is 482913");
expect(adapter.sent[0]?.idempotencyKey).toBe("login:+14155550123:482913");
});
Each entry in adapter.sent holds the message the adapter received (with its resolved sender), the attempt number, the idempotencyKey, and the returned outcome. Rejected and unknown sends are recorded too. Call adapter.reset() between tests to clear sends and queued outcomes.
By default the adapter is named "memory", sends from +15005550006, supports every send field and sender type, and has inbound and delivery receipts off. Accepted sends get IDs memory_1, memory_2, and so on. Override any of these with options such as name, from, or capabilities: { mms: false }, the last of which lets you test how your code handles UnsupportedFieldError.
Script failures
Pass outcomes, or call enqueue(), to decide what the next sends return. The adapter uses them in order, one per send, then goes back to accepting.
import { expect, test } from "bun:test";
import { createSmsClient, HandoffUnknownError, ProviderRejectedError } from "@opencoredev/sms-sdk";
import { memory, rejectedOutcome, unknownOutcome } from "@opencoredev/sms-sdk/testing";
test("handles an opt-out rejection and a timeout", async () => {
const adapter = memory({ outcomes: [rejectedOutcome("compliance", { code: "21610" })] });
const sms = createSmsClient({ adapters: [adapter] });
const rejected = await sms.send({ to: "+14155550123", body: "Hi" }).catch((error: unknown) => error);
expect(rejected).toBeInstanceOf(ProviderRejectedError);
adapter.enqueue(unknownOutcome("timeout"));
const unknown = await sms.send({ to: "+14155550123", body: "Hi" }).catch((error: unknown) => error);
expect(unknown).toBeInstanceOf(HandoffUnknownError);
});
There is also acceptedOutcome(providerId, delivery), and any entry can be a function (message, context) => outcome that decides per send. The testing reference has every signature.
To test fallback, give two memory adapters different names:
import { expect, test } from "bun:test";
import { createSmsClient } from "@opencoredev/sms-sdk";
import { memory, rejectedOutcome } from "@opencoredev/sms-sdk/testing";
test("falls back after a sender rejection", async () => {
const primary = memory({ name: "primary", outcomes: [rejectedOutcome("sender", { provider: "primary" })] });
const backup = memory({ name: "backup" });
const sms = createSmsClient({ adapters: [primary, backup], fallback: "on-known-rejection" });
const result = await sms.send({ to: "+14155550123", body: "Hi" });
expect(result.provider).toBe("backup");
expect(result.attemptedProviders).toEqual(["primary", "backup"]);
});
Test a real adapter without the network
mockFetch() returns a fake fetch that records each request and replies with what you give it. Pass it to any adapter’s fetch option:
import { createSmsClient } from "@opencoredev/sms-sdk";
import { mockFetch } from "@opencoredev/sms-sdk/testing";
import { twilio } from "@opencoredev/sms-sdk/twilio";
const fake = mockFetch({ status: 201, body: { sid: "SM0123456789abcdef0123456789abcdef", status: "queued" } });
const sms = createSmsClient({
adapters: [twilio({ accountSid: "AC0123456789abcdef0123456789abcdef", authToken: "test", from: "+15550100001", fetch: fake.fetch })],
});
await sms.send({ to: "+14155550123", body: "Hello" });
console.log(fake.calls[0]?.url, fake.calls[0]?.headers["content-type"]);
The reply can be fixed or a function of the recorded request. { throws: error } simulates a network failure, and { hangUntilAborted: true } waits for the abort so you can test timeoutMs.
Test webhook handlers
These builders sign requests the way each provider does, so your handler runs the real verification code:
import { expect, test } from "bun:test";
import { generateTelnyxKeyPair, signedTelnyxRequest, signedTwilioRequest } from "@opencoredev/sms-sdk/testing";
import { parseSmsWebhook } from "@opencoredev/sms-sdk/webhooks";
test("parses a signed Twilio delivery receipt", async () => {
const url = "https://example.com/webhooks/sms/twilio";
const request = await signedTwilioRequest({
authToken: "test-token",
url,
params: { MessageSid: "SM0123456789abcdef0123456789abcdef", MessageStatus: "delivered", To: "+14155550123" },
});
const event = await parseSmsWebhook({ provider: "twilio", request, publicUrl: url, credentials: { authToken: "test-token" } });
expect(event.type).toBe("message.delivered");
});
test("parses a signed Telnyx inbound message", async () => {
const { privateKey, publicKey } = await generateTelnyxKeyPair();
const request = await signedTelnyxRequest({
privateKey,
url: "https://example.com/webhooks/sms/telnyx",
body: {
data: {
id: "evt_1",
event_type: "message.received",
payload: { id: "msg_1", text: "STOP", from: { phone_number: "+14155550123" }, to: [{ phone_number: "+15550100001" }] },
},
},
});
const event = await parseSmsWebhook({ provider: "telnyx", request, credentials: { publicKey } });
expect(event.type).toBe("recipient.opted_out");
});
For Plivo, signedPlivoRequest({ authToken, url, params }) builds a form POST with V2 signature headers. For Vonage, signedVonageRequest({ signatureSecret, url, body }) builds a JSON POST with an HS256 JWT. Bandwidth and Sinch (Basic mode) check an Authorization: Basic header instead of a signature, so a plain Request with that header is enough; there is no builder for them. The testing reference lists optional fields such as a fixed timestamp or nonce.
Check your configuration with the doctor
Before the first live send, the doctor CLI checks one provider’s environment variables, sender, and message encoding. By default it makes no network calls and sends nothing:
npx @opencoredev/sms-sdk doctor --adapter telnyx --body "Your package is ready 📦"
It supports all six providers. See Doctor CLI for every flag, including the guarded --live send.
Keep live sends out of CI
Build test clients on memory() or mockFetch(), never on real credentials, and keep provider credentials out of CI unless a job exists to send. The SMS SDK repository’s own live test runs only with LIVE_SMS_TESTS=true and a recipient in LIVE_SMS_ALLOWLIST.