Web3 Testing Guide

How to Test MetaMask Connect with Playwright: Complete 2026 Guide

A scenario-by-scenario walkthrough of testing MetaMask wallet connection flows with Playwright. Provider injection, extension popup handling, account switching, chain switching, transaction signing, EIP-6963 multi-wallet detection, and the tradeoffs between mock providers and real browser extensions.

30M+

MetaMask has over 30 million monthly active users as of 2024, making it the most widely used browser wallet and the default integration target for virtually every dApp.

ConsenSys

0 context switchesPer wallet connect flow
0Scenarios covered
0 chainsTested in examples
0%Fewer lines with Assrt

MetaMask Wallet Connection Flow

UserdApp Pagewindow.ethereumMetaMask PopupBlockchain RPCClick 'Connect Wallet'eth_requestAccountsOpen approval popupReview and approveReturn [accounts]Resolve promise with addresseth_getBalance, eth_chainIdBalance + chain data

1. Why Testing MetaMask Connection Is Harder Than It Looks

MetaMask is a browser extension that injects an Ethereum provider object at window.ethereum into every page the user visits. When your dApp calls ethereum.request({ method: "eth_requestAccounts" }), MetaMask opens a separate popup window (not an iframe, not an overlay) where the user approves or rejects the connection. This popup is a completely separate browser context with its own DOM, its own URL, and its own lifecycle. Standard Playwright page locators cannot reach it unless you explicitly capture it via the browser context's page events.

The complexity goes deeper. MetaMask stores its internal state in an encrypted vault backed by IndexedDB inside the extension's service worker. Every time you launch a fresh browser profile (as Playwright does by default), MetaMask starts in its onboarding state, requiring you to either import a seed phrase or create a new wallet before any test can proceed. The extension also introduces non-deterministic timing: the popup may take 500ms to appear in a fast CI environment or 3 seconds on a loaded machine, and your test needs to handle that range without flaking.

There are five structural reasons this flow is especially difficult to automate. First, Playwright's default browser instances do not load extensions, so you need to launch Chromium with the --load-extension flag and --disable-extensions-except pointing to the MetaMask build directory. Second, the extension popup opens as a new page in the browser context, requiring you to listen for the context.on("page") event to capture it. Third, MetaMask popup DOM changes frequently between versions, breaking hardcoded selectors. Fourth, chain switching and network addition trigger additional confirmation popups, each with their own approval buttons. Fifth, EIP-6963 introduced a new provider announcement protocol that modern dApps use instead of reading window.ethereum directly, and testing this requires dispatching custom events.

MetaMask Extension Architecture

🌐

Browser Launch

--load-extension flag

💳

Extension Injects

window.ethereum provider

⚙️

dApp Requests

eth_requestAccounts

↪️

Popup Opens

Separate page context

User Approves

Click Connect

💳

Provider Returns

Account address array

2. Setting Up Your Test Environment

You have two broad approaches for MetaMask testing: inject a mock provider that simulates MetaMask behavior without any extension, or load the real MetaMask extension into a Chromium instance and automate the popup windows. Each approach has genuine tradeoffs that affect coverage, speed, and reliability.

The mock provider approach is faster (no extension loading, no popup automation) and more deterministic (you control every response). But it cannot catch regressions in how your dApp interacts with the real MetaMask UI, and it skips the entire approval flow that users actually experience. The real extension approach catches those regressions and tests the full user journey, but it is slower, requires downloading the MetaMask build artifact, and is more fragile because MetaMask popup selectors change across versions.

Most production dApp teams use both: mock provider tests for fast iteration on business logic, and a smaller suite of real extension tests for critical path verification. Here is the environment setup for both approaches.

Install Dependencies
.env.test

Test Strategy Decision Tree

Unit Logic?

Mock provider

🌐

UI Integration?

Mock + assertions

💳

Full User Journey?

Real extension

⚙️

CI Speed Critical?

Mock for PR, real for nightly

3

Mock Provider Injection (No Extension Required)

Straightforward

The fastest way to test MetaMask integration is to bypass the extension entirely and inject a mock provider into window.ethereumbefore your dApp code runs. This gives you full control over every RPC response, every account address, and every chain ID. The technique uses Playwright's page.addInitScript() to execute code before any page JavaScript loads.

The mock provider needs to implement the EIP-1193 interface: a request() method that accepts an object with method and params properties, plus an event emitter for accountsChanged and chainChanged events. Your dApp should not be able to distinguish the mock from a real MetaMask installation.

tests/helpers/mock-ethereum-provider.ts
tests/connect-wallet.spec.ts

The mock provider handles the most common RPC methods that dApps call during a connect flow. When your dApp calls eth_requestAccounts, the mock immediately returns the configured accounts without any popup or user interaction. This makes the tests fast and deterministic, typically completing in under 2 seconds.

What to Assert Beyond the UI

Mock Provider Assertions

  • Connected address matches the mock account
  • Chain ID in the UI matches the injected chainId
  • Balance display parses the hex value correctly
  • Disconnection clears the stored address from state
  • Error states render when provider throws 4001 (user rejected)
4

Real Extension with Synpress

Complex

Synpress is the de facto framework for automating MetaMask in Playwright tests. It extends Playwright with MetaMask-specific fixtures that handle extension loading, wallet setup, popup automation, and network configuration. Under the hood, Synpress downloads a compatible MetaMask build, launches Chromium with the extension flags, imports your seed phrase during a global setup step, and provides helper methods to approve connection requests, confirm transactions, and switch networks.

The key insight is that MetaMask's popup is a separate Pageobject in Playwright's browser context. When your dApp triggers eth_requestAccounts, MetaMask opens a new popup window. Synpress listens for the context.on("page")event, waits for the popup to load, finds the "Connect" button inside it, and clicks it. Without Synpress, you would write this plumbing yourself.

playwright.config.ts
tests/wallet-connect.metamask.spec.ts
tests/wallet-setup/basic.setup.ts

Connect Wallet: Playwright vs Assrt

import { testWithSynpress, MetaMask } from '@synthetixio/synpress';
import basicSetup from './wallet-setup/basic.setup';

const test = testWithSynpress(basicSetup);
const { expect } = test;

test('connects MetaMask wallet', async ({
  context, page, metamaskPage, extensionId,
}) => {
  await page.goto('/');
  await page.getByRole('button', { name: /connect wallet/i }).click();
  const metamask = new MetaMask(
    context, metamaskPage,
    basicSetup.walletPassword, extensionId
  );
  await metamask.connectToDapp();
  await expect(page.getByText('0xf39F...2266')).toBeVisible();
});
43% fewer lines

Try Assrt for free

Open-source AI testing framework. No signup required.

Get Started
5

Chain Switching and Network Addition

Moderate

Chain switching is one of the most common user interactions in multi-chain dApps. When your application calls wallet_switchEthereumChain, MetaMask shows a confirmation popup asking the user to approve the network switch. If the requested chain is not yet configured in the user's MetaMask, the dApp typically follows up with wallet_addEthereumChain, which triggers a different popup asking the user to add the new network. Your tests need to handle both flows and verify the dApp updates its UI to reflect the new chain.

With the mock provider, chain switching is straightforward because you control the response directly. With the real extension, you need to capture the popup, find the "Approve" button, and click it. Synpress provides metamask.approveNewNetwork() and metamask.approveSwitchNetwork() helpers for this.

tests/chain-switching.spec.ts
tests/chain-switching.metamask.spec.ts
6

Transaction Signing Confirmation

Complex

Transaction signing is the most security-sensitive interaction in any dApp. When your application calls eth_sendTransaction, MetaMask presents a confirmation popup showing the recipient, value, gas estimate, and total cost. The user must review these details and click "Confirm" to broadcast the transaction. Your tests need to verify that the dApp correctly formats the transaction parameters, handles the confirmation, processes the returned transaction hash, and updates the UI to show pending and confirmed states.

For personal_sign and eth_signTypedData_v4 (EIP-712), the popup shows the message being signed. Testing these signing methods is critical for dApps that use signature-based authentication (Sign-In with Ethereum) or off-chain order signing (NFT marketplaces, DEX limit orders).

tests/transaction-signing.spec.ts
tests/signing.metamask.spec.ts

Transaction Signing: Playwright vs Assrt

import { testWithSynpress, MetaMask } from '@synthetixio/synpress';
import basicSetup from './wallet-setup/basic.setup';
const test = testWithSynpress(basicSetup);

test('confirms ETH transfer', async ({
  context, page, metamaskPage, extensionId,
}) => {
  await page.goto('/');
  const metamask = new MetaMask(
    context, metamaskPage,
    basicSetup.walletPassword, extensionId
  );
  await page.getByRole('button', { name: /connect wallet/i }).click();
  await metamask.connectToDapp();
  await page.getByLabel('Recipient').fill('0x7099...79C8');
  await page.getByLabel('Amount').fill('0.01');
  await page.getByRole('button', { name: /send/i }).click();
  await metamask.confirmTransaction();
  await expect(page.getByText(/submitted/i)).toBeVisible();
});
53% fewer lines
7

EIP-6963 Multi-Wallet Detection

Complex

EIP-6963 (Multi Injected Provider Discovery) replaced the legacy window.ethereum convention with an event-based protocol. Instead of wallets fighting over a single global variable, each wallet dispatches an eip6963:announceProvider event containing its provider info (name, icon, UUID, rdns) and the actual EIP-1193 provider object. dApps listen for these events and present a wallet selection UI. This is now the standard approach used by libraries like wagmi, RainbowKit, and Web3Modal.

Testing EIP-6963 requires you to simulate multiple wallets announcing themselves. You dispatch custom events from your test setup, each carrying a different provider with different accounts, and verify that your dApp renders all available wallets and connects to the one the user selects.

tests/eip6963-multi-wallet.spec.ts
8

Account Switching and Disconnection

Moderate

Users frequently switch between accounts in MetaMask, and your dApp must handle the accountsChanged event to update its state. When MetaMask emits this event with an empty array, it means the user disconnected the dApp. When it emits with a different address, the user switched accounts. Both cases require the dApp to update balances, permissions, and UI state.

Testing this with the mock provider is elegant: you call the mock's emit() method directly from your test to simulate MetaMask firing accountsChanged. With the real extension, Synpress provides methods to switch accounts within MetaMask, and the extension fires the event to the dApp automatically.

tests/account-switching.spec.ts

Account Switching: Playwright vs Assrt

test('updates UI on account switch', async ({ page }) => {
  await injectMockProvider(page, {
    accounts: ['0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266'],
  });
  await page.goto('http://localhost:3000');
  await page.getByRole('button', { name: /connect/i }).click();
  await expect(page.getByText('0xf39F...2266')).toBeVisible();
  await page.evaluate(() => {
    window.ethereum.selectedAddress =
      '0x70997970C51812dc3A010C7d01b50e0d17dc79C8';
    window.ethereum.emit('accountsChanged',
      ['0x70997970C51812dc3A010C7d01b50e0d17dc79C8']);
  });
  await expect(page.getByText('0x7099...79C8')).toBeVisible();
});
57% fewer lines

9. Common Pitfalls That Break MetaMask Test Suites

MetaMask testing has a well-documented history of flaky tests and broken CI pipelines. These pitfalls are sourced from Synpress GitHub issues, the MetaMask developer forums, and production incident reports from dApp teams.

Pitfalls to Avoid

  • Hardcoding MetaMask popup selectors: MetaMask redesigns its UI every few major versions. Use Synpress helpers or data-testid attributes when available instead of CSS selectors like .btn-primary
  • Forgetting to wait for the popup page: context.on('page') is async. If you try to interact with the popup before it loads, your test throws 'Target page closed'. Always await the popup page event with a proper timeout
  • Running MetaMask tests in parallel: MetaMask uses a single extension state per browser context. Two tests running simultaneously in the same context will corrupt each other's wallet state. Use serial mode or separate browser contexts
  • Using the funded mainnet seed phrase in tests: A leaked seed phrase in CI logs or a commit means permanent loss of funds. Always use the standard Hardhat test mnemonic: 'test test test test test test test test test test test junk'
  • Not handling the MetaMask onboarding flow: A fresh browser profile means MetaMask starts at its welcome screen. Your global setup must complete the import or create wallet flow before any test runs
  • Ignoring window.ethereum.isConnected() state: Some dApps check isConnected() before requesting accounts. Your mock provider must implement this method or the dApp's connect button may silently fail
  • Testing only happy paths: The user rejection flow (error code 4001) is just as important. dApps that don't handle rejection gracefully will show a blank screen or an unhandled promise rejection
  • Skipping chainChanged event handling: If your dApp doesn't respond to chain switches, users will see stale data from the wrong network. Test that balance, token list, and contract addresses all update on chain change
Common MetaMask Test Failures
After Fixing Popup Timing and Selectors

10. Writing These Scenarios in Plain English with Assrt

Every code sample in this guide is real Playwright TypeScript that you can paste into a project and run. But the extension popup plumbing, the mock provider boilerplate, the Synpress setup files, and the popup timing workarounds add up fast. Assrt lets you describe the same scenarios in plain English and compiles them into the same Playwright code.

Here is the full wallet connection and transaction suite written as an Assrt scenario file. Assrt understands MetaMask interactions natively: it knows how to inject mock providers for fast tests, how to handle extension popups for real extension tests, and how to simulate events like accountsChanged and chainChanged.

tests/metamask-connect.assrt

Assrt compiles each scenario block into the same Playwright TypeScript you saw in the preceding sections, committed to your repo as real tests you can read, run, and modify. When MetaMask redesigns its popup layout or renames confirmation buttons, Assrt detects the failure, analyzes the updated DOM, and opens a pull request with the corrected selectors. Your scenario files stay untouched.

Start with the mock provider connect flow. Once it is green in your CI, add the real extension tests for transaction signing, then chain switching, then EIP-6963 multi-wallet detection, then account switching and disconnection. In a single afternoon you can have complete MetaMask connection coverage that most production dApps never manage to achieve by hand.

Related Guides

Ready to automate your testing?

Assrt discovers test scenarios, writes Playwright tests from plain English, and self-heals when your UI changes.

$npm install @assrt/sdk