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:3000does not allowhttp://127.0.0.1:3000orhttp://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.
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,
}),
},
})
}
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.
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())
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
botgroup in the triggers, and a score atmediumorhigh. 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.
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.
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, 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.
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.suspectwebhooks.