← Back to Blog

E2E Testing Your EVM dApp with MetaMask: No Mocks, Just Real Clicks

Run Playwright tests against a real MetaMask extension. Connect, sign, and reject flows covered in CI. No mocks, no manual clicks.

Written by Chroma Team

Mock the wallet and you leave connection prompts, chain switching, and signing dialogs untested. Rely on manual clicks and tests become slow and brittle. Automated E2E tests that drive a real MetaMask extension (the same one your users have) give you connect, sign, and reject coverage before you ship.

Here’s how to get that with Chroma: Playwright tests that control MetaMask Flask, import an account, authorize connections, and confirm or reject transactions, all in code.

Why test with a real MetaMask?

Real-wallet tests catch what mocks miss:

  • Integration bugs: Connection prompts, chain switching, and signing dialogs behave differently than mocks.
  • Real user flows: Same extension and UI your users see.
  • Regression safety: Wallet or dApp UI changes show up as failing tests.

Chroma uses MetaMask Flask (v13.17.0), a stable, test-friendly build. Download it once via the CLI; your tests drive it like a real user.

Setup

Before installing Chroma, initialize Playwright in your project:

npm init playwright@latest

Then install Chroma and prepare the MetaMask extension:

npm add -D @avalix/chroma
npx chroma download-extensions

Create a test that attaches the MetaMask wallet to the browser context:

import { createWalletTest } from '@avalix/chroma'

const test = createWalletTest({
  wallets: [{ type: 'metamask' }],
})

test('my metamask test', async ({ page, wallets }) => {
  const metamask = wallets.metamask
  // ...
})

You get a metamask handle that can import an account, authorize connection requests, and confirm or reject transactions.

Import an account (seed phrase)

Use a test-only Secret Recovery Phrase so MetaMask has an account before your dApp runs. Chroma runs MetaMask’s onboarding for you (import wallet, set password, complete setup):

await metamask.importSeedPhrase({
  seedPhrase: 'test test test test test test test test test test test junk',
})

Never use real seed phrases or mainnet accounts in tests. Use test accounts with no real funds.

Import the account before navigating to your dApp so the wallet is ready when the dApp asks to connect.

Authorize the connection

When the dApp triggers “Connect wallet” (e.g. “Connect Wallet” or “Continue with a wallet”), MetaMask shows a connection popup. In your test: click the button, then approve in MetaMask:

await page.goto('https://your-dapp.example.com')
await page.getByRole('button', { name: 'Connect Wallet' }).click()
// MetaMask popup appears; approve it
await metamask.authorize()

After authorize(), the dApp should see the connected account and update the UI. You can then assert on the connected state (e.g. address or “Connected” label).

Confirm or reject transactions

When the dApp sends a transaction or signing request, MetaMask shows a confirmation popup. Approve or reject it from the test:

// User flow that triggers a tx (e.g. send, approve, swap)
await page.getByRole('button', { name: 'Submit' }).click()
await metamask.confirm()

To test rejection:

await metamask.reject()

For MetaMask, Chroma uses confirm() and reject() instead of approveTx() / rejectTx() used for other wallets.

Full example: connect and sign on a live dApp

The snippet below follows a real flow used in Chroma’s own tests: open a dApp (e.g. Privy demo), connect with MetaMask Flask, authorize, then confirm a signing step. Adjust selectors and URL for your app.

import { createWalletTest, expect } from '@avalix/chroma'

const SEED_PHRASE = 'test test test test test test test test test test test junk'

const test = createWalletTest({
  wallets: [{ type: 'metamask' }],
})

test.setTimeout(60_000)

test('connect with MetaMask and complete signing', async ({ page, wallets }) => {
  const metamask = wallets.metamask

  await metamask.importSeedPhrase({ seedPhrase: SEED_PHRASE })

  await page.goto('https://demo.privy.io')
  await page.waitForLoadState('domcontentloaded')
  await page.bringToFront()

  await page.getByRole('button', { name: 'REJECT ALL' }).click()
  await page.waitForTimeout(2000)

  await page.getByRole('button', { name: 'Continue with a wallet' }).click()
  await page.getByPlaceholder('Search wallets').fill('metamask flask')
  await page.getByRole('button', { name: 'MetaMask Flask' }).first().click()

  await metamask.authorize()
  await metamask.confirm()

  await expect(page.getByText(/0x[a-fA-F0-9]{4}\.\.\.[a-fA-F0-9]{4}/).first()).toBeVisible()
})

Flow in short:

  1. Import the test account into MetaMask.
  2. Open the dApp and accept cookies/consent if needed.
  3. Start “Continue with a wallet” and choose MetaMask Flask.
  4. authorize() to accept the connection; confirm() to accept the subsequent signing step.
  5. Assert that the UI shows the connected address (or another success state).

Swap the URL and selectors for your dApp and run the same flow.

Troubleshooting

Extension not loading: Ensure extensions are installed: npx chroma download-extensions. Chroma installs MetaMask Flask under .chroma/metamask-extension-13.17.0.

Popup or step timing out: Increase the test timeout, e.g. test.setTimeout(60000).

Account not found / wrong account: Call importSeedPhrase before page.goto(...) so MetaMask is ready when the dApp requests connection.

Flaky connection or confirm: Add short waits after navigation or after opening modals so the extension and dApp are in sync; avoid hard-coded long sleeps where possible.

If you run into issues when using MetaMask with Chroma, please report them on the Chroma GitHub repository. Your feedback helps improve the integration for everyone.

What’s next

With Chroma, your E2E tests run against a real MetaMask in CI and locally. No mocks, just real clicks and coverage you can ship on.


This post was drafted with AI assistance and reviewed by the Chroma team.