Local testing
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 has an in-memory adapter for app code, a recording fake fetch for adapter tests, and builders for signed webhook requests. The runnable version of this page is examples/test-adapter.
Use the memory adapter
import { createSmsClient } from "@opencoredev/sms-sdk";
import { memory } from "@opencoredev/sms-sdk/testing";
const sms = createSmsClient({ adapters: [memory()] });
await sms.send({ to: "+14155550123", body: "Test message" });
memory() makes no network calls, records every send, and accepts it unless you script a failure. By default its name is "memory", its sender is +15005550006, it supports every send field and sender type, and inbound and delivery receipts are off. Accepted sends get provider IDs memory_1, memory_2, and so on, numbered by send count.
To test it, your app code should take an SmsClient as an argument or import it from one module you can swap.
Assert what was sent
Keep a reference to the adapter and read sent:
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 sent holds the message the adapter received, with its resolved sender, plus the attempt number, the idempotencyKey, and the returned outcome. Rejected and unknown sends are recorded too. Call adapter.reset() between tests to clear recorded sends and queued outcomes.
Script failures
Pass outcomes, or call enqueue(), to make the next sends fail. 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);
});
Besides rejectedOutcome() and unknownOutcome(), there is acceptedOutcome(providerId, delivery). Any entry can also be a function (message, context) => outcome that computes the outcome per send. See the testing reference for every signature.
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 capability errors
Turn a capability off to check how your code handles UnsupportedFieldError:
import { createSmsClient } from "@opencoredev/sms-sdk";
import { memory } from "@opencoredev/sms-sdk/testing";
const sms = createSmsClient({ adapters: [memory({ capabilities: { mms: false } })] });
const check = sms.validate({ to: "+14155550123", body: "Photo", mediaUrls: ["https://example.com/a.png"] });
console.log(check.supported, check.issues[0]?.code); // false "unsupported_field"
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 responder is a fixed reply or a function of the recorded request. { throws: error } simulates a network failure. { hangUntilAborted: true } waits for the abort, which lets you test timeoutMs.
Test webhook handlers
These builders sign requests the way each provider does, so your handler runs the real verification path:
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. The testing reference lists optional fields such as a fixed timestamp or nonce.
Use these instead of unsafeSkipVerification: true, which skips the code you most need to test.
Check configuration without sending
Before the first live send, run the doctor in dry-run mode. It makes no network calls:
npx @opencoredev/sms-sdk doctor --adapter telnyx --body "Your package is ready 📦"
See Doctor CLI.
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 an allowlisted recipient.