---
title: "Test without a provider"
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` 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`](https://github.com/opencoredev/sms-sdk/tree/main/packages/sms-sdk/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.

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

```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);
});
```

There is also `acceptedOutcome(providerId, delivery)`, and any entry can be a function `(message, context) => outcome` that decides per send. The [testing reference](/reference/testing) has every signature.

To 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 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 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:

```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. 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](/reference/testing) lists optional fields such as a fixed timestamp or nonce.

:::warning
Use these builders instead of `unsafeSkipVerification: true`, which skips the code you most need to test.
:::

## 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:

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

It supports all six providers. See [Doctor CLI](/reference/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`.

## Next steps

- [Testing reference](/reference/testing)
- [Build your own adapter](/advanced/writing-an-adapter)
