> ## Documentation Index
> Fetch the complete documentation index at: https://docs.tappify.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Testing with the SDK

> Render a component against a mock bridge and drive your server the way Tappify does.

The SDK ships the bridge your component talks to and the host's CSS variables, so a test renders
what the host renders. Add the preset to your test setup and use `renderWithTap`.

<Tabs>
  <Tab title="Vitest">
    ```ts theme={null}
    import { defineConfig } from "vitest/config";

    export default defineConfig({
      test: {
        environment: "happy-dom",
        setupFiles: [
          "@testing-library/jest-dom/vitest",
          "@tappify/extension-sdk/testing/vitest",
        ],
      },
    });
    ```
  </Tab>

  <Tab title="Jest">
    ```js theme={null}
    module.exports = {
      testEnvironment: "jsdom",
      setupFilesAfterEnv: ["@tappify/extension-sdk/testing/jest"],
    };
    ```
  </Tab>
</Tabs>

Importing a preset sets the host theme variables on the document, registers the four matchers,
and clears the mock's portal after each test. Both need a DOM environment, because the theme goes
on `document.documentElement`.

## Rendering a component

```tsx theme={null}
import { renderWithTap } from "@tappify/extension-sdk/testing";
import { screen, waitFor } from "@testing-library/react";
import { expect, it } from "vitest";
import Summary from "../src/widgets/summary";

it("renders the number the procedure returned", async () => {
  const { mock } = renderWithTap(<Summary config={{}} />, {
    scopes: ["ui:render", "analytics:read", "storage:write"],
    filters: {
      range: { from: "2026-09-01", to: "2026-09-08", preset: "7d" },
      platform: "all",
      country: "all",
    },
    server: {
      getSummary: () => ({
        installs: 1260,
        delta: 0.087,
        topKeyword: "photo editor",
      }),
    },
  });

  await waitFor(() => expect(screen.getByText("1,260")).toBeInTheDocument());
  expect(mock.calls.telemetry[0]?.name).toBe("summary_viewed");
});
```

It returns everything Testing Library returns, plus `tap`, `mock` and the `queryClient` it built.

| Option          | Does                                                                                                                                                           |
| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `scopes`        | What the install granted. Defaults to all of them. A data or storage call outside them rejects with `TAP_SCOPE_MISSING`, so the missing-scope path is testable |
| `size`          | `slot`, `panel` or `page`, driving `tap.ui.size`                                                                                                               |
| `filters`       | Merged over the fixture filters                                                                                                                                |
| `project`       | Merged over the fixture project                                                                                                                                |
| `params`        | The page's path segments, keyed by position                                                                                                                    |
| `context`       | What `tap.context` holds                                                                                                                                       |
| `theme`         | `light` or `dark`                                                                                                                                              |
| `storage`       | The collections the mock exposes, and whether each is a singleton or a collection. Leave it out and every name works                                           |
| `server`        | Answers `tap.server.<name>` with one function per procedure                                                                                                    |
| `handler`       | Your own handler, so `tap.server.<name>` goes through its real routes                                                                                          |
| `extensionId`   | Who the mock says it is. Defaults to `starter`, and has to match the handler when you pass one                                                                 |
| `confirmAnswer` | What `tap.ui.confirm` resolves to. Defaults to `true`                                                                                                          |
| `mock`          | A mock you built yourself, instead of a fresh one                                                                                                              |
| `queryClient`   | Your own `QueryClient`, when a test reads the cache                                                                                                            |

Every storage call needs `storage:write`, reads included. `handler` has to be built against the
test key set — `createTappifyHandler({ ...handlerOptions, jwks: await testJwks() })` — and
`extensionId` has to name the same extension the handler does, because the mock signs the token it
sends and the handler checks the audience on it.

## The mock

`createTapMock()` builds the same bridge without a component, for a test that has no UI. Storage
is an in-memory implementation of the collections you name, data queries answer from the sandbox
fixtures the portal preview uses, and everything else the host would do is recorded:

| Read                                        | Holds                                                |
| ------------------------------------------- | ---------------------------------------------------- |
| `mock.calls.navigations`                    | Every path passed to `tap.nav.push`                  |
| `mock.calls.toasts`                         | `{ message, tone }` per `tap.ui.toast`               |
| `mock.calls.confirms`                       | The options behind each `tap.ui.confirm`             |
| `mock.calls.actions`                        | `{ actionId, input }` per `tap.actions.run`          |
| `mock.calls.telemetry`                      | `{ name, props }` per `tap.telemetry.event`          |
| `mock.calls.chats`                          | `{ prompt, context }` per `tap.nav.openChat`         |
| `mock.calls.searches`                       | The params passed to each `tap.nav.setSearch`        |
| `mock.calls.externals`                      | Every url passed to `tap.nav.openExternal`           |
| `mock.calls.cards`                          | The cards handed to `tap.ui.openInChat`              |
| `mock.calls.copies`, `mock.calls.downloads` | What `tap.ui.copy` and `tap.ui.download` were handed |
| `mock.documents("preferences")`             | What that collection holds now                       |

The mock drives the bridge from outside the component too:

```ts theme={null}
import { createTapMock } from "@tappify/extension-sdk/testing";

const mock = createTapMock({ storage: { preferences: "singleton" } });

mock.emit("release.shipped", { version: "4.2.0" });
mock.setFilters({
  range: { from: "2026-08-01", to: "2026-08-31", preset: "30d" },
  platform: "ios",
  country: "all",
});
mock.setSize("panel");
mock.setTheme("dark");
mock.setParams({ "0": "funnels", "1": "123" });
```

`setFilters` takes a whole `TapFilters`; the `filters` option is the one that merges. `setParams`
takes the shape the host builds, which is positional — `pageId`, `path`, and one key per segment.

## Matchers

```ts theme={null}
expect(mock).toHaveNavigatedTo("/explore/funnels/123");
expect(mock).toHaveToasted("A release shipped — refreshing the summary.");
expect(mock).toHaveRunAction("send-push", { title: "Sale" });
expect(mock).toHaveStored("preferences", { compact: true });
```

Each failure names what was expected and every call that was recorded. They take the mock itself,
so `expect(mock)` rather than the component or `tap`.

## Testing your server

`createTestClient` drives your handler the way Tappify does, with a signed token, so a procedure
is tested without a running Tappify:

```ts theme={null}
import { createTestClient } from "@tappify/extension-sdk/testing";
import { expect, it } from "vitest";
import { handlerOptions } from "../server/handler";

const client = createTestClient(handlerOptions, {
  scopes: ["ui:render", "analytics:read"],
  documents: { settings: { refreshMinutes: 15 } },
});

it("answers health and getSummary", async () => {
  await expect(client.health()).resolves.toEqual({
    ok: true,
    version: "0.1.0",
  });
  await expect(
    client.procedure("getSummary", { days: 7, platform: "all" }),
  ).resolves.toMatchObject({ installs: 1260 });
});
```

Hand it the options, not a built handler: a handler from `createTappifyHandler` verifies tokens
against Tappify's live key set, so every call would come back 401. The client rebuilds the handler
around a test key set and signs against it.

The client has one method per route — `health`, `metrics`, `tool`, `mention`, `action`,
`procedure`, `context`, `event`, `work` — and `raw(method, path, body)` for the status codes.
`signTestToken()` and `testJwks()` are there when you verify tokens yourself.

Two validators read a response the way a reviewer would and return the list of what is wrong, so
assert the list is empty: `validateMetricsResponse(response, metrics)` against your declared
metric definitions, and `validateToolResponse(value, returns)` against the card shape a tool of
that `returns` kind has to answer with.

```ts theme={null}
import {
  createTestClient,
  validateToolResponse,
} from "@tappify/extension-sdk/testing";
import { expect, it } from "vitest";
import { handlerOptions } from "../server/handler";

const client = createTestClient(handlerOptions);

it("answers keyword_gaps with a table card", async () => {
  const card = await client.tool("keyword_gaps", { limit: 5 });

  expect(validateToolResponse(card, "table")).toEqual([]);
});
```

[`tappify extension doctor`](/extensions/test/doctor-checks) runs your test script and counts a
failing suite as a failed check.
