---
title: "Local testing"
description: "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`](https://github.com/opencoredev/sms-sdk/tree/main/packages/sms-sdk/examples/test-adapter).

## Use the memory adapter

```ts
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`:

```ts
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.

```ts
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](/reference/testing) for every signature.

### Test fallback

Give two memory adapters different names:

```ts
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`:

```ts
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:

```ts
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:

```ts
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](/reference/testing) 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:

```bash
npx @opencoredev/sms-sdk doctor --adapter telnyx --body "Your package is ready 📦"
```

See [Doctor CLI](/reference/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.

## Next steps

- [Testing reference](/reference/testing)
- [Build a provider adapter](/providers/writing-an-adapter)
