# Test your integration

> Mock the SDK in unit and component tests, run end-to-end tests with development keys in automated browsers, and check a policy change before it reaches production.

Last updated: 2026-09-17

An integration has three parts worth testing: the client code that identifies and sends the request ID, the server code that reads the event and decides, and the policy itself. Each is tested differently.

| What | Test with | Network |
| --- | --- | --- |
| Client code, in unit tests | A fake transport | None |
| Components, in React, Vue, Svelte or Angular tests | A mocked identify endpoint | None |
| The whole flow, end to end | A development key and a real browser | Real |
| Your server's decisions | Event fixtures | None |
| Weights and the threshold | Recent events, replayed | Server API |

## Use development keys

- Development and staging keys run the same detection as production and are never billed. Use them for every test that talks to Fingerly.
- Add each origin your tests run on to the public key, exactly: `http://localhost:3000` does not allow `http://127.0.0.1:3000` or `http://localhost:4173`.
- Events from development keys stay out of production dashboards, webhooks and secret-key reads.
- Visitor IDs are not separated by environment. A device you test with in development is already a returning visitor when it reaches production in the same organization.

> **Note:** There is no test mode, allowlist or header that turns detection off. Test traffic is scored like any other.

## Unit tests

### Collect without sending

`identify({ submit: false })` collects signals and computes local verdicts without contacting Fingerly. It is enough for code that only reads verdicts. `requestId` and `visitorId` are empty, `suspectScore` and `suspectLevel` are `null`, and consent is still required.

### Replace the transport

To test code that needs a request ID, give `load()` a `transport` that answers instead of the API. The client is the real one, so consent, results and errors behave as they do in production. `sources: []` skips collection, which keeps tests fast and independent of the test environment.

```ts test/fingerly.ts
import { load } from '@fingerly/web-js'

/** A real client whose identify requests are answered by the test. */
export function fakeFingerly(answer: Record<string, unknown> = {}) {
  return load({
    apiKey: 'fly_pk_us_development_…',
    sources: [],
    transport: {
      submit: async () => ({
        request_id: '01a0a84b-e6a2-7c09-9f51-0b3d7a26c8e4',
        visitor_id: 'X9pL2mRc7KvT4bQw8NdF',
        visitor_is_new: false,
        identifiable: true,
        visitor_confidence: 100,
        state: 'enriched',
        suspect_score: 0,
        suspect_level: 'low',
        triggers: [],
        ...answer,
      }),
    },
  })
}
```

```ts login.test.ts
import { expect, it, vi } from 'vitest'
import { load } from '@fingerly/web-js'
import { fakeFingerly } from './test/fingerly'
import { submitLogin } from '../src/login'

it('sends the request ID with the login', async () => {
  const post = vi.fn()
  await submitLogin(await fakeFingerly(), post, { email: 'a@example.com', password: 'secret' })

  expect(post).toHaveBeenCalledWith(expect.objectContaining({ requestId: '01a0a84b-e6a2-7c09-9f51-0b3d7a26c8e4' }))
})

it('logs in without a request ID when Fingerly cannot be reached', async () => {
  const fingerly = await load({
    apiKey: 'fly_pk_us_development_…',
    sources: [],
    transport: { submit: () => Promise.reject(new Error('offline')) },
  })
  const post = vi.fn()
  await submitLogin(fingerly, post, { email: 'a@example.com', password: 'secret' })

  expect(post).toHaveBeenCalledWith(expect.objectContaining({ requestId: undefined }))
})
```

A transport answer needs only `request_id`; every other field is optional, and the client turns the answer into the usual camel-case result. Without a `submitSupplement` method, `result.deferred` resolves as `skipped` with the reason `unsupported-transport`.

## Component tests

The framework SDKs create their client from options, so they cannot take a transport. Answer their requests at the network instead. [Mock Service Worker](https://mswjs.io) intercepts the SDK's `fetch` in Vitest and Jest, for every framework.

```ts test/setup.ts
import { afterAll, afterEach, beforeAll } from 'vitest'
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'

export const fingerlyApi = setupServer(
  http.post('*/api/v1/identify', () =>
    HttpResponse.json({
      request_id: '01a0a84b-e6a2-7c09-9f51-0b3d7a26c8e4',
      visitor_id: 'X9pL2mRc7KvT4bQw8NdF',
      visitor_is_new: false,
      identifiable: true,
      visitor_confidence: 100,
      state: 'enriched',
      suspect_score: 0,
      suspect_level: 'low',
      triggers: [],
    }),
  ),
)

beforeAll(() => fingerlyApi.listen())
afterEach(() => fingerlyApi.resetHandlers())
afterAll(() => fingerlyApi.close())
```

```tsx Checkout.test.tsx
import { http, HttpResponse } from 'msw'
import { fingerlyApi } from './test/setup'

it('asks for a second factor when the answer is high', async () => {
  fingerlyApi.use(
    http.post('*/api/v1/identify', () =>
      HttpResponse.json({ request_id: '01a0a84b-e6a2-7c09-9f51-0b3d7a26c8e4', visitor_id: 'X9pL2mRc7KvT4bQw8NdF', suspect_score: 37, suspect_level: 'high', triggers: [] }),
    ),
  )
  // render <Checkout /> inside <FingerlyProvider apiKey="fly_pk_us_development_…"> and assert
})
```

> **Tip:** Leave `deferred_token` out of the fixture. The SDK then sends no deferred report, so there is nothing else to intercept.

> **Warning:** In jsdom and happy-dom the SDK runs as it would in a browser. Without a mock, `identify()` sends a real request, which fails unless the test's origin is allowed on the key.

## End-to-end tests

Run end-to-end tests against real identification, with a development key. That is the only way to test that your origins, proxy, consent flow and server read fit together.

- **Expect automation.** Playwright, Selenium, Puppeteer, Cypress and headless browsers are automated browsers, and Fingerly detects them as such: expect the `bot` group in the triggers, and a score at `medium` or `high`. The same holds for iOS simulators and Android emulators, and for debug builds with developer tooling attached.
- **Do not assert on the score or the level.** Assert that a request ID reached your server, that your server could read the event, and that the flow finished.
- **Make enforcement configurable.** In test environments, have your server log the decision it would make instead of acting on it, and test the enforced paths with event fixtures.

```ts Playwright
import { expect, test } from '@playwright/test'

test('login carries a readable identification', async ({ page }) => {
  const identified = page.waitForResponse('**/api/v1/identify')
  await page.goto('/login')
  await page.getByLabel('Email').fill('e2e@example.com')
  await page.getByLabel('Password').fill(process.env.E2E_PASSWORD!)
  await page.getByRole('button', { name: 'Log in' }).click()

  expect((await identified).status()).toBe(200)
  await expect(page).toHaveURL('/account')
})

test('login still works when identification is blocked', async ({ page }) => {
  await page.route('**/api/v1/identify', (route) => route.abort('blockedbyclient'))
  await page.goto('/login')
  // … the visitor must still be able to continue, with more friction at most
})
```

> **Tip:** Blocking the identify request, as in the second test, is how you check that a content blocker degrades to missing evidence rather than a broken page.

## Server tests

Test your decisions without Fingerly by reading events through one function of your own and replacing it in tests. Start from a real event: copy one from **Identification > Events**, or read one with a development secret key, and save it as a fixture.

```ts decide.test.ts
import { expect, it, vi } from 'vitest'
import event from './fixtures/event.json'
import * as fingerly from '../src/fingerly.server'
import { decide } from '../src/checkout'

vi.mock('../src/fingerly.server')

it('refuses an identification made for another order', async () => {
  vi.mocked(fingerly.readEvent).mockResolvedValue({ ...event, tag: 'checkout:1' })
  expect(await decide('8412', event.request_id)).toBe('refuse')
})
```

| Case | Fixture change | What your code should do |
| --- | --- | --- |
| No request ID | None: pass `undefined` | Continue with missing evidence. |
| Unknown request ID | The read returns nothing, as for a `404` | Treat as missing evidence. |
| Another action's identification | `tag` changed | Refuse to trust it. |
| An old identification | `occurred_at` minutes in the past | Refuse to trust it. |
| A reused request ID | The same fixture twice | Trust it once. |
| Not scored | `suspect_score: null`, no `suspect_level` | Your fallback, not `low`. |
| Each level | `suspect_level` set to `low`, `medium`, `high` | Each action in your policy. |
| A decisive signal | A group added to `triggers` | The rule for that signal. |

## Test a policy change

Your own policy, the code that turns an event into an action, is ordinary code: test it on staging, where keys run the same detection for free.

Risk weights and the threshold you set in the dashboard apply to your organization in every environment at once. There are two ways to see a change before it reaches production.

- **Give staging keys their own profile.** A key's own weights override the organization's for that key alone. Save them on your staging keys with the [management API](https://docs.fingerly.io/reference/management/risk-weights#save-an-sdk-key-s-weights), run staging traffic, then copy the profile to the organization.
- **Replay recent events.** Read recent events and work out how many would change level under the new threshold, as below.

```ts replay-threshold.ts
import { load } from '@fingerly/node'

const fingerly = load({ secretKey: process.env.FINGERLY_SECRET_KEY! })   // a production key
const proposed = 25

// How Fingerly derives a level from a score and a threshold.
const levelFor = (score: number, threshold: number) =>
  score <= 0 ? 'low' : score >= threshold ? 'high' : score >= Math.floor(threshold / 2) ? 'medium' : 'low'

const from = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
const changes: Record<string, number> = {}

for (let page = 1; ; page++) {
  const { rows, page_size } = await fingerly.events.list({ from, page, limit: 200 })
  for (const event of rows) {
    if (event.suspect_score === null) continue
    const next = levelFor(event.suspect_score, proposed)
    if (next !== event.suspect_level) {
      const key = event.suspect_level + ' -> ' + next
      changes[key] = (changes[key] ?? 0) + 1
    }
  }
  if (rows.length < page_size) break
}

console.table(changes)
```

- A listing reaches back at most 10,000 events. For busier traffic, replay several shorter windows.
- For a weight change, add the difference to the score of events where that group fired. Groups with several signals make this an estimate.
- After the change, watch **Insights > Suspect score trends**, and the volume of `visitor.suspect` webhooks.

- [Troubleshooting](https://docs.fingerly.io/docs/troubleshooting): When a test fails for a reason outside your code.
- [Plan your integration](https://docs.fingerly.io/docs/planning-your-integration#roll-out-a-policy): Roll out a policy in stages.
