Skip to main content

How to Test Plaid Link and Open Banking Flows in Playwright

Short answer

Plaid E2E fails when tests drive Link inside iframes without sandbox credentials, share one linked Item across workers, or assert Link success UI before /item/public_token/exchange completes. Use Plaid sandbox with per-run client_user_id, seed link tokens server-side, handle OAuth redirect institutions via test-only callback routes, and probe linked accounts—not Plaid modal button colors alone.

Part of Testing Guides by business flow.

Who this is for

Fintech, neobank, lending, and personal finance teams integrating Plaid Link for account aggregation, ACH funding, or income verification. Stacks: React Link SDK, Next.js API routes for token exchange, mobile web Link, or Lovable apps that added Plaid for bank connect.

Why Plaid needs different Arrange/Assert

LayerPitfallE2E fix
Link iframeCross-origin selectors breakframeLocator for Link; sandbox test credentials
Token exchangeUI success before Item existsProbe /accounts or your linked_accounts table
OAuth banksRedirect leaves your originTest callback route + state param validation
Sandbox Item reuseParallel CI invalidates sessionPer-run client_user_id + new link token
Micro-depositsNot available same-day in sandboxUse user_good / instant auth institutions
WebhooksTRANSACTIONS lags Item createPoll probe; optional webhook forward in CI

Bank connect is async—Link onSuccess fires before your backend persists the Item.

Complexity map

ScenarioEdge caseWhy tests breakApproach
Link sandbox loginWrong institution flowStuck on credentialsUse documented sandbox user user_good / pass_good
OAuth institutionFull redirect to bankLost session cookiePersist link_token state; probe after return
iframe focusInput not receiving keysTimeout on usernameScope frameLocator('[title="Plaid Link"]')
Token exchange 4xxInvalid public_tokenUI shows generic errorProbe exchange endpoint in Arrange smoke
Duplicate LinkUser re-links same bankMultiple ItemsProbe single active Item per runId
Account selectMulti-account institutionWrong account fundedSeed expected account_id in probe
Identity vs Auth productWrong Link productsMissing scopesMatch link token products to scenario
Update modeRe-auth existing ItemDifferent Link flowSeparate scenario; probe item_id unchanged
Item error webhookITEM_LOGIN_REQUIREDStale balances in UIProbe item status + disconnect banner
Rate limitsSandbox 429 in parallelRandom CI failPer-run tokens; backoff in globalSetup
Webhook delayAccounts not fetched yetEmpty account listexpect.poll linked_accounts count
Mobile LinkNew tab / in-app browserContext lostMobile viewport spec or API Arrange path
European institutionsPSD2 OAuth varianceUntested redirectDedicated OAuth sandbox institution
TeardownOrphan sandbox ItemsDashboard clutter/item/remove by metadata.e2e_run
// app/api/test/plaid/link-token/route.ts
import { Configuration, PlaidApi, PlaidEnvironments, Products, CountryCode } from 'plaid';

const plaid = new PlaidApi(
new Configuration({
basePath: PlaidEnvironments.sandbox,
baseOptions: {
headers: {
'PLAID-CLIENT-ID': process.env.PLAID_CLIENT_ID!,
'PLAID-SECRET': process.env.PLAID_SECRET_SANDBOX!,
},
},
}),
);

export async function POST(req: Request) {
if (process.env.E2E_TEST_MODE !== 'true') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { runId, userId } = await req.json();
const response = await plaid.linkTokenCreate({
user: { client_user_id: `e2e-${runId}-${userId}` },
client_name: 'E2E App',
products: [Products.Auth],
country_codes: [CountryCode.Us],
language: 'en',
webhook: `${process.env.APP_URL}/api/webhooks/plaid`,
redirect_uri: `${process.env.APP_URL}/plaid/oauth-return`,
});
return NextResponse.json({ link_token: response.data.link_token, runId });
}
// app/api/test/probe-linked-accounts/route.ts
export async function GET(req: Request) {
if (process.env.E2E_TEST_MODE !== 'true') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const runId = new URL(req.url).searchParams.get('runId');
const { count } = await db.linked_accounts
.select({ count: sql`count(*)` })
.where(eq(linked_accounts.run_id, runId));
return NextResponse.json({ count, runId });
}

Never expose PLAID_SECRET to Playwright browser context—link token creation stays server-side.

// @Scenario: onboarding/connect-bank-ach
import { test, expect } from '../fixtures/run';

test('user links sandbox bank and sees accounts', async ({ page, request, runId }) => {
const seed = await request.post('/api/test/seed-user', { data: { runId } });
const { email, password } = await seed.json();
const tokenRes = await request.post('/api/test/plaid/link-token', {
data: { runId, userId: email },
});
const { link_token } = await tokenRes.json();

await page.goto('/login');
await page.getByLabel('Email').fill(email);
await page.getByLabel('Password').fill(password);
await page.getByRole('button', { name: 'Sign in' }).click();

await page.goto(`/connect-bank?link_token=${link_token}`);
await page.getByRole('button', { name: 'Connect bank' }).click();

const linkFrame = page.frameLocator('iframe[title="Plaid Link"]');
await linkFrame.getByLabel('Phone').fill('4155550011'); // sandbox flow entry
await linkFrame.getByRole('button', { name: 'Continue' }).click();
await linkFrame.getByLabel('Username').fill('user_good');
await linkFrame.getByLabel('Password').fill('pass_good');
await linkFrame.getByRole('button', { name: 'Submit' }).click();
await linkFrame.getByRole('button', { name: 'Continue' }).click(); // account select

await expect.poll(async () => {
const res = await request.get(`/api/test/probe-linked-accounts?runId=${runId}`);
return (await res.json()).count;
}, { timeout: 45_000 }).toBeGreaterThan(0);

await expect(page.getByRole('heading', { name: 'Connected accounts' })).toBeVisible();
});

Institution picker steps vary—capture stable sandbox institution names in fixtures; use ai.act sparingly only on institution search if labels churn.

OAuth redirect institutions

For OAuth test institutions:

  1. Register redirect_uri in Plaid dashboard for sandbox.
  2. After redirect to bank mock, return to /plaid/oauth-return?oauth_state_id=....
  3. Resume Link with the same link_token—probe accounts before UI assert.

See OAuth social login patterns for redirect state validation parallels.

Webhooks and balances

TRANSACTIONS and balance updates may arrive via webhook after Link success. Poll probe linked_accounts and optional balance snapshot row—do not assert formatted balance strings on first paint.

Pair with webhooks and async processing and fintech web app testing.

TestChimp workflow

Mark specs with // @Scenario: flows/plaid-connect so /testchimp test repairs iframe selectors when Link UI updates. /testchimp init can scaffold link-token and probe routes. Use TrueCoverage to prioritize funding vs verify-income Link products separately.

Anti-patterns

Anti-patternWhy it failsBetter approach
Production Plaid keys in CICompliance incidentSandbox only
Shared linked Item across testsItem login required flakesPer-run client_user_id
Assert Link onSuccess callback onlyBackend exchange failedProbe linked_accounts
Hard-coded institution DOMPlaid UI updatesSandbox fixture list + ai.act fallback
Skip OAuth scenariosProd-only bank breaksDedicated OAuth sandbox spec
PLAID_SECRET in browserSecret leakServer link-token route
UI account mask onlyWrong account linkedProbe account_id
No Item teardownSandbox clutterRemove by metadata.e2e_run

External references

Example scenario

Situation: New user connects Chase via Plaid Link to fund wallet.

Expected outcome: Item created; at least one depository account linked; ACH funding enabled flag set.

Why UI-only automation breaks: Link success toast shows while public_token exchange 500'd—accounts page empty.

  1. Arrange: Seed user and link_token with runId metadata via server routes.
  2. Act: Sign in; open Connect bank; complete sandbox Link with user_good credentials.
  3. Assert: Poll probe-linked-accounts count > 0; optional UI shows masked account.

TestChimp workflow: // @Scenario: links Plaid connect to markdown; /testchimp test preserves probe Assert when Link iframe structure changes.

Same Arrange/Act/Assert pattern as expired-coupon checkout.

Frequently asked questions

Can Plaid Link run in headless CI?

Yes in sandbox with iframe frameLocator and documented test credentials. OAuth institutions need redirect handling—use dedicated job or API Arrange for PR smoke.

Which sandbox credentials should we use?

user_good / pass_good for instant auth flows. See Plaid test credentials doc for error scenarios like INVALID_CREDENTIALS in negative tests.

How to test OAuth banks without real Chase?

Use Plaid sandbox OAuth test institutions with registered redirect_uri and resume Link after callback.

Assert UI or probe for linked accounts?

Probe authoritative linked_accounts rows and optional Plaid /accounts API via server route. UI masks are supplementary.

Parallel CI and Plaid rate limits?

Issue per-run link tokens with unique client_user_id. Avoid reusing the same sandbox Item across workers.

How to test ITEM_LOGIN_REQUIRED?

Use Plaid sandbox reset login or error triggers; probe item status webhook handler updates disconnect state in UI.

Link vs hosted Link?

Same probe strategy—token exchange and account persistence matter more than embed vs redirect hosting.

TestChimp with Plaid flows?

/testchimp init scaffolds link-token and probe routes; // @Scenario ties SmartTests to open-banking markdown; ai.act helps institution search when labels churn.

Apply these patterns in your repo

Run `/testchimp init` to connect TestChimp to your repo, then `/testchimp test` on PRs to turn these patterns into maintained SmartTests. Use `/testchimp evolve` when you want to expand coverage as your app grows.

Start free on TestChimp · Book a demo