---
title: fixtures
description: Record an instance's answers to files in a folder you choose, with personal data replaced by numbered placeholders, and replay them without calling the vendor.
sidebar:
  label: fixtures
related:
  - /guides/test-without-a-key
  - /docs/adapters/memory
  - /docs/plugins/redact
---

`fixtures({ dir })` from `outbound-sdk/fixtures` saves the answer to each read as a JSON file and serves it from that file afterwards, for tests and demos. Before saving, it replaces personal data with numbered placeholders. In `replay` mode it never calls the adapter. Files are written to the folder you name and nowhere else. It uses `node:fs`, so it needs Node or a runtime with that module.

## Sample

The sample records two reads, then replays them on an adapter that holds no data. It runs on the [shared seed](/docs/plugins#every-plugin).

```ts title="run.ts"
import { mkdtemp, readdir, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";

import { createOutbound, OutboundError } from "outbound-sdk";
import { fixtures } from "outbound-sdk/fixtures";
import { memory } from "outbound-sdk/memory";

import { seed } from "../plugins-index/seed.ts";

// In a project this is a folder you commit, such as test/fixtures.
const dir = await mkdtemp(path.join(tmpdir(), "outbound-fixtures-"));

// 1. Record. The read goes to the adapter and the answer is saved, scrubbed.
const recording = createOutbound({
  adapter: memory({ data: seed, workspaceId: "agency" }),
  plugins: [fixtures({ dir, mode: "record" })],
});

const recorded = await recording.campaign("cmp_founders").leads.list();

await recording.campaigns.list();

const files = await readdir(dir);

console.log("files:", files.toSorted());

// What record mode returns is what it saved: the scrubbed records.
for (const lead of recorded.items) {
  console.log(
    lead.email,
    "|",
    lead.firstName,
    lead.lastName,
    "|",
    lead.company
  );
}

// 2. Replay. This adapter holds no data, so the answers come from disk.
const replaying = createOutbound({
  adapter: memory({ workspaceId: "agency" }),
  plugins: [fixtures({ dir, mode: "replay" })],
});

const replayed = await replaying.campaign("cmp_founders").leads.list();

console.log(
  "replayed:",
  replayed.items.length,
  "leads,",
  replayed.items[0]?.email
);

const campaigns = await replaying.campaigns.list();

console.log(campaigns.items.map((item) => item.name));

// 3. A read that was never recorded fails. Replay never calls the adapter.
try {
  await replaying.senders.list();
} catch (error) {
  if (OutboundError.is(error)) {
    console.log(`${error.code}: ${error.message}`);
  }
}

// A different question is a different file, so this was not recorded either.
try {
  await replaying.campaigns.list({ status: "paused" });
} catch (error) {
  if (OutboundError.is(error)) {
    console.log(`${error.code}: ${error.message}`);
  }
}

await rm(dir, { force: true, recursive: true });
```

```text title="Output"
files: [
  'memory.agency.campaigns.list.7d4baf8b2ee5.json',
  'memory.agency.leads.list.fabfa8ccbfc1.json'
]
person1@example.com | Person 1 | Company 1
person2@example.com | Person 2 | Company 2
replayed: 2 leads, person1@example.com
[ 'Campaign 1', 'Campaign 2', 'Campaign 3' ]
NotFound: No fixture was recorded for senders.list (memory.agency.senders.list.98c8b4dfb6f1.json)
NotFound: No fixture was recorded for campaigns.list (memory.agency.campaigns.list.dec736233f07.json)
```

The seed's Jane Doe at Northwind is "Person 1" at "Company 1" in the file and in what the recording read returned. Campaign names were replaced too.

## Options

| Option | Type | Default | Meaning |
| --- | --- | --- | --- |
| `dir` | `string` | required | The folder to write to and read from. It is created when the first file is written. |
| `mode` | `"auto" \| "record" \| "replay"` | `"auto"` | `record` always calls inward and overwrites the file. `replay` answers from disk and throws `NotFound` when there is no file. `auto` replays when a file exists and records otherwise. |
| `scrub` | `boolean` | `true` | Replace personal data with placeholders before saving. |

## Files

One file per operation, named `<provider>.<workspace>.<operation kind>.<hash>.json`, where the hash is taken from the workspace, the operation and all its arguments. In the name, a character of the workspace label that is not a letter, a digit, `.`, `_` or `-` is written as `_`. `fixtureFile(dir, { provider, workspaceId }, operation)` returns the path for one read.

- Another page, filter, date range or id is another file. A test must make exactly the reads that were recorded.
- A get by bare id and a get by ref are two files.
- Two workspaces on one provider write two files for the same read, so several workspaces can share a folder.

A file holds the operation's `items`, its `next` continuation and its `evidence`. Replayed records are checked against the SDK's schemas like any others, so a file edited into an invalid shape fails with `Provider`.

## What scrubbing replaces

| Record | Replaced | With |
| --- | --- | --- |
| Lead | `email`, `company`, `linkedinUrl`, `lastName`, every `custom` value | `person1@example.com`, `Company 1`, `https://www.linkedin.com/in/example-person-1`, a number, `value-1` |
| Lead | `firstName` | `Person` |
| Message, reply | `subject`, `bodyText`, `bodyHtml` | `[subject]`, `[body]`, `null` |
| Sender | `address`, `linkedinUrl`, `name` | `person1@example.com`, a placeholder profile, `Person 1` |
| Campaign, workspace | `name` | `Campaign 1`, `Workspace 1` |
| Block list entry, saved list | `value`, `name` | `value-1` |
| Sender health | `domain` | `example.com` |
| Webhook subscription | `url`, `name` | `https://example.com/webhook`, `value-1` |
| Stats row | Nothing, except an email address or a profile URL inside `scopeRef` | A placeholder address or profile |
| Lead, with `identity` inside | `personKey`, `personKeys` | Keys rebuilt from the placeholders: `email:person1@example.com`, `linkedin:example-person-1` |

The same value always gets the same placeholder within one plugin object, so records still join after scrubbing: a lead and a sender with the same address get the same `personN@example.com`. Numbering restarts with each `fixtures()` call.

`raw` is set to `null` on every record. An id that is an email address or a LinkedIn profile URL is replaced in `sourceId` and in every ref that carries it.

Scrubbing does not touch a lead's `title`, `tags` or `interest`, custom field names, sequence step text, tag names, or ids that are neither an email address nor a profile URL. Read a recorded file before committing it.

> **scrub: false writes real data to disk**
>
> With `scrub: false` the files hold the records as the layers inside returned
> them, including `raw`, the vendor's whole payload. Do not commit them.

## What it changes

It changes no capability. A replayed answer has a fresh `meta`: `requests` is `0` and `fetchedAt` is now. The file does not keep when the answer was recorded.

In `record` and `auto` mode, the recording read returns the scrubbed records, the same ones it saved, not the vendor's.

## With other plugins

- **List it last when recording.** What is saved is then the adapter's answer, not another plugin's view of it. Listed before `clients` or `identity`, it saves their added fields too. `identity`'s `personKey` and `personKeys` are rebuilt from the placeholders, so no address or profile is written. `clients`' `client` label is saved as it is.
- In `replay` mode the layers before it still run on the replayed records: `redact` masks them, `clients` tags them, `cache` caches them.
- Layers after it are skipped on replay. A `budget` listed after it counts nothing.
- A plugin that only adds methods (`insights`, `sync`, `exporter`) works on replayed data like any other.

## When it fails

| What happened | What you get | What to do |
| --- | --- | --- |
| `replay`, and the read was never recorded | `NotFound`, naming the operation and the file | Record it, or make the test ask the recorded question. The last two cases in the sample show this. |
| A file is not valid JSON, or has no `evidence` | `Invalid`: "The fixture ... is not a recorded operation" | Delete the file and record again. |
| The read fails while recording | The error. No file is written. | Fix the read and record again. |
| The folder cannot be written | The file system's error, after the vendor was called | Check the path and its permissions. |
