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

# Testing

> Three-line tests with fakeSite(), and a sweep of a real site.

## Three lines

```ts blog.test.ts theme={"dark"}
import { test } from 'node:test';
import { fakeSite } from '@fetchski/attract-cms-sdk/testing';

test('reads a post', async () => {
  const site = fakeSite();
  await site.blogPosts().getBlog({ id: 'example-id' });
  site.assertCalled('blogPosts.getBlog');
});
```

> [!TIP]
>
> Nothing is sent: no URL, key or JSON. Every call answers a valid model. A `FakeSite` is a `SiteClient`,
> so hand it to the code under test.

## Answers

Give a method its answer by key, `<accessor>.<method>`:

```ts A model theme={"dark"}
import { factories, fakeSite } from '@fetchski/attract-cms-sdk/testing';

const site = fakeSite({ 'blogPosts.getBlog': factories.Blog({ title: 'Hi' }) });
```

```ts A refusal theme={"dark"}
import { fakeSite, validationError } from '@fetchski/attract-cms-sdk/testing';

const site = fakeSite({
  'testimonials.updateTestimonial': validationError({ quote: 'Required.' })
});
```

```ts A function theme={"dark"}
import { factories, fakeSite, notFound } from '@fetchski/attract-cms-sdk/testing';

const site = fakeSite({
  'blogPosts.getBlog': (args) => (args.id === 'gone' ? notFound() : factories.Blog())
});
```

```ts Pages in turn theme={"dark"}
import { factories, fakeSite, sequence } from '@fetchski/attract-cms-sdk/testing';

const site = fakeSite({
  'blogPosts.listBlogs': sequence(
    factories.BlogList({ pages: 2 }),
    factories.BlogList({ pages: 2, currentPage: 2 })
  )
});
```

> [accordion] Every kind of answer
>
> A model, a plain object, a function `(args, site) => …` (it may be async), an `Error` (thrown), or
> `sequence(first, second)` (each call takes the next). `blogPosts.*` answers a whole accessor. A key that is no
> method throws, suggesting the closest one.

> [accordion] Answers in turn: sequence()
>
> `sequence(page1, page2)` takes each answer as its own argument (not an array); each call takes the
> next. One call past the end throws an `Error`: "The sequence for blogPosts.listBlogs ran out." Give
> one answer per call you expect.

> [accordion] Errors, as the real SDK throws them
>
> `validationError({...})`, `notFound()`, `forbidden()`, `rateLimited(30)`, `unreachable()`,
> `siteError(500)`: the real error classes, for the faked site and route.

> [accordion] Factories
>
> Every answer model has `factories.<Model>(overrides)`: a valid model, the overrides a deep partial
> (a nested object merges onto the nested fake; each item of a list of objects, given as a partial
> object, is filled in over that item's example the same way).

> [accordion] Walks, finds, jobs and request()
>
> A `listBlogsAll()` walk is answered through `blogPosts.listBlogs`, a `findBlog()` through
> `blogPosts.getBlog`, `site.request()` through the key `request`. A job's `wait()` needs the
> management SDK, under the fake too. `fakeSite({}, { slug: 'globex' })` fakes another site.

## Your own values, nested

Fake one answer by key, override a nested field, and assert the call:

```ts media.test.ts theme={"dark"}
import { strictEqual } from 'node:assert';
import { test } from 'node:test';
import { factories, fakeSite } from '@fetchski/attract-cms-sdk/testing';

test('lists office images', async () => {
  const site = fakeSite({
    'fetchskiMedia.listMediaImages': factories.ListMediaImagesResponse({
      results: [{ title: 'Office', urls: { full: 'https://cdn.example/office.jpg' } }]
    })
  });

  const images = await site.fetchskiMedia().listMediaImages({ q: 'office' });

  strictEqual(images.results[0]?.urls.full, 'https://cdn.example/office.jpg');
  site.assertCalled('fetchskiMedia.listMediaImages', (args) => args.q === 'office');
});
```

Each item of `results` is a partial object filled in over `MediaImage`'s example, like any nested
model, so every field you don't name is still valid.

## Assertions

| Assertion | Passes when |
| - | - |
| `site.assertCalled(key, (args, site) => …)` | the method was called (and the function agrees) |
| `site.assertCalledTimes(key, 2)` | it was called exactly that often |
| `site.assertNotCalled(key)` | it was never called |
| `site.assertNothingCalled()` | no method was |

They throw `node:assert`'s `AssertionError`, listing what was called, so they work under `node:test`,
`bun:test` and Vitest alike.

## Engine calls

`fakeSite()` fakes the SDK's methods, not the engine: `site.api()` on it throws, saying so. For those,
pass a `fetch` to `createSiteClient()` (with `retries: 0` unless the test checks retries), and a test
key that keeps to a real key's characters:

```text API key theme={"dark"}
fsk_acme_0a1b2c3d_not-a-real-secret
```

```text Signing secret theme={"dark"}
fss_not-a-real-secret
```

## Sweeping a real site

`readSweep()` calls every read a site offers and reports what each did: a health check.

```ts theme={"dark"}
import { SiteApis } from '@fetchski/attract-cms-sdk/engine';
import { apiAccessors, readSweep } from '@fetchski/attract-cms-sdk/testing';

const sweep = await readSweep(site, apiAccessors(SiteApis));
console.log(sweep.report()); // one line per call, then the count
if (sweep.broken.length) throw new Error(sweep.broken.join('\n'));
```

> [card] [Next: keeping keys secret](https://docs.fetchski-attract.com/typescript/security.md)
>
> Where a key never shows up, and the one place it does.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.