Guides

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.

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.

WhatTest withNetwork
Client code, in unit testsA fake transportNone
Components, in React, Vue, Svelte or Angular testsA mocked identify endpointNone
The whole flow, end to endA development key and a real browserReal
Your server's decisionsEvent fixturesNone
Weights and the thresholdRecent events, replayedServer 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.

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.

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,
      }),
    },
  })
}
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 intercepts the SDK's fetch in Vitest and Jest, for every framework.

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())
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
})

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.
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
})

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.

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')
})
CaseFixture changeWhat your code should do
No request IDNone: pass undefinedContinue with missing evidence.
Unknown request IDThe read returns nothing, as for a 404Treat as missing evidence.
Another action's identificationtag changedRefuse to trust it.
An old identificationoccurred_at minutes in the pastRefuse to trust it.
A reused request IDThe same fixture twiceTrust it once.
Not scoredsuspect_score: null, no suspect_levelYour fallback, not low.
Each levelsuspect_level set to low, medium, highEach action in your policy.
A decisive signalA group added to triggersThe 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, 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.
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.