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
| Layer | Pitfall | E2E fix |
|---|---|---|
| Link iframe | Cross-origin selectors break | frameLocator for Link; sandbox test credentials |
| Token exchange | UI success before Item exists | Probe /accounts or your linked_accounts table |
| OAuth banks | Redirect leaves your origin | Test callback route + state param validation |
| Sandbox Item reuse | Parallel CI invalidates session | Per-run client_user_id + new link token |
| Micro-deposits | Not available same-day in sandbox | Use user_good / instant auth institutions |
| Webhooks | TRANSACTIONS lags Item create | Poll probe; optional webhook forward in CI |
Bank connect is async—Link onSuccess fires before your backend persists the Item.
Complexity map
| Scenario | Edge case | Why tests break | Approach |
|---|---|---|---|
| Link sandbox login | Wrong institution flow | Stuck on credentials | Use documented sandbox user user_good / pass_good |
| OAuth institution | Full redirect to bank | Lost session cookie | Persist link_token state; probe after return |
| iframe focus | Input not receiving keys | Timeout on username | Scope frameLocator('[title="Plaid Link"]') |
| Token exchange 4xx | Invalid public_token | UI shows generic error | Probe exchange endpoint in Arrange smoke |
| Duplicate Link | User re-links same bank | Multiple Items | Probe single active Item per runId |
| Account select | Multi-account institution | Wrong account funded | Seed expected account_id in probe |
| Identity vs Auth product | Wrong Link products | Missing scopes | Match link token products to scenario |
| Update mode | Re-auth existing Item | Different Link flow | Separate scenario; probe item_id unchanged |
| Item error webhook | ITEM_LOGIN_REQUIRED | Stale balances in UI | Probe item status + disconnect banner |
| Rate limits | Sandbox 429 in parallel | Random CI fail | Per-run tokens; backoff in globalSetup |
| Webhook delay | Accounts not fetched yet | Empty account list | expect.poll linked_accounts count |
| Mobile Link | New tab / in-app browser | Context lost | Mobile viewport spec or API Arrange path |
| European institutions | PSD2 OAuth variance | Untested redirect | Dedicated OAuth sandbox institution |
| Teardown | Orphan sandbox Items | Dashboard clutter | /item/remove by metadata.e2e_run |
Server-side link token seed
// 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.
Playwright spec — Link sandbox flow
// @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:
- Register
redirect_uriin Plaid dashboard for sandbox. - After redirect to bank mock, return to
/plaid/oauth-return?oauth_state_id=.... - 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-pattern | Why it fails | Better approach |
|---|---|---|
| Production Plaid keys in CI | Compliance incident | Sandbox only |
| Shared linked Item across tests | Item login required flakes | Per-run client_user_id |
Assert Link onSuccess callback only | Backend exchange failed | Probe linked_accounts |
| Hard-coded institution DOM | Plaid UI updates | Sandbox fixture list + ai.act fallback |
| Skip OAuth scenarios | Prod-only bank breaks | Dedicated OAuth sandbox spec |
| PLAID_SECRET in browser | Secret leak | Server link-token route |
| UI account mask only | Wrong account linked | Probe account_id |
| No Item teardown | Sandbox clutter | Remove by metadata.e2e_run |
External references
- Plaid Sandbox overview
- Plaid Link web SDK
- Plaid OAuth guide
- Plaid test credentials
- Playwright frameLocator
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.
- Arrange: Seed user and link_token with runId metadata via server routes.
- Act: Sign in; open Connect bank; complete sandbox Link with user_good credentials.
- 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.
Related
- Fintech web app testing
- Stripe payments testing
- OAuth social login
- Webhooks and async events
- Seed routes and probe Assert
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.