Testing Web3 Wallet Integrations: MetaMask, WalletConnect, and Beyond
Web3 wallet integration is where most dApp user experience problems live. The smart contracts can be flawless, but if MetaMask's popup doesn't appear, the wrong network is connected, or a transaction confirmation hangs indefinitely, users abandon the app. Testing wallet integrations requires a different approach from both smart contract testing and standard browser testing — you're automating interactions with a browser extension that controls its own UI.
The Challenge of Wallet Testing
Standard Playwright or Selenium tests can't interact with MetaMask's popup window by default. MetaMask runs as a browser extension with its own isolated page context, and it intercepts window.ethereum to gate every transaction.
There are three main strategies:
- Mock the provider — replace
window.ethereumwith a controllable mock - Use a test wallet extension — Synpress, MetaMask Test Dapp, or similar tools that automate the real extension
- Use a headless wallet library — programmatically sign transactions without a browser extension at all
Each approach serves different test scenarios. Mock providers are fastest for unit testing dApp logic. Real extension automation is essential for true end-to-end tests. Headless wallets work well for integration tests that don't need to verify the UI prompts.
Mock Providers
A mock provider implements the EIP-1193 interface (request, on, removeListener) and returns controlled responses. This lets you test your dApp's wallet connection flow, account switching, and transaction handling without a real extension.
Building a minimal mock provider
class MockEthereumProvider extends EventEmitter {
private accounts: string[];
private chainId: string;
private signer: ethers.Wallet;
constructor(privateKey: string, chainId = "0x1") {
super();
this.signer = new ethers.Wallet(privateKey);
this.accounts = [this.signer.address];
this.chainId = chainId;
}
async request({ method, params }: { method: string; params?: unknown[] }) {
switch (method) {
case "eth_requestAccounts":
case "eth_accounts":
return this.accounts;
case "eth_chainId":
return this.chainId;
case "eth_sendTransaction":
const tx = params![0] as TransactionRequest;
const provider = new ethers.JsonRpcProvider(process.env.RPC_URL);
const connectedSigner = this.signer.connect(provider);
const response = await connectedSigner.sendTransaction(tx);
return response.hash;
case "personal_sign":
const [message, address] = params as [string, string];
return await this.signer.signMessage(ethers.getBytes(message));
case "wallet_switchEthereumChain":
const [{ chainId }] = params as [{ chainId: string }];
this.chainId = chainId;
this.emit("chainChanged", chainId);
return null;
default:
throw new Error(`Unimplemented method: ${method}`);
}
}
}Injecting the mock in Playwright
test("connects wallet and displays address", async ({ page }) => {
const privateKey = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
await page.addInitScript((key) => {
// Mock provider must be injected before page scripts run
window.ethereum = new MockEthereumProvider(key);
}, privateKey);
await page.goto("http://localhost:3000");
await page.click('[data-testid="connect-wallet"]');
const address = await page.textContent('[data-testid="wallet-address"]');
expect(address).toContain("0xf39F");
});The addInitScript call is critical — it runs before any page JavaScript, ensuring window.ethereum is set before your dApp's initialization code checks for it.
Automating the Real MetaMask Extension
For genuine end-to-end tests, you need to automate the actual MetaMask extension. The Synpress library wraps Playwright with MetaMask automation capabilities.
Setting up Synpress
npm install --save-dev @synthetixio/synpressimport { MetaMask, testWithMetaMask } from "@synthetixio/synpress";
import { testWithSynpress } from "@synthetixio/synpress";
const test = testWithSynpress({
metamaskConfig: {
seed: "test test test test test test test test test test test junk",
password: "TestPassword123!",
},
});
test("approve token spend via MetaMask", async ({ page, metamask }) => {
await page.goto("http://localhost:3000/swap");
// Select token and amount
await page.selectOption('[data-testid="token-select"]', "USDC");
await page.fill('[data-testid="amount-input"]', "1000");
await page.click('[data-testid="approve-button"]');
// MetaMask approval popup opens automatically
await metamask.confirmPermissionToSpend("1000");
// Verify approval transaction confirmed
await expect(page.locator('[data-testid="swap-button"]')).toBeEnabled();
});Synpress handles the MetaMask extension window, automatic popup detection, and waiting for transaction confirmations. Tests are slower (5–15 seconds per transaction) but verify the real user flow.
WalletConnect Testing
WalletConnect opens a QR code that a mobile wallet scans to establish a connection. In automated tests, you can't scan a QR code — but you can connect programmatically using the WalletConnect SDK directly.
Programmatic WalletConnect connection
import { WalletClient, createWalletClient } from "viem";
import { SignClient } from "@walletconnect/sign-client";
test("connects via WalletConnect", async ({ page }) => {
await page.goto("http://localhost:3000");
await page.click('[data-testid="walletconnect-button"]');
// Extract the WalletConnect URI from the QR code or URI display
const wcUri = await page.locator('[data-testid="wc-uri"]').textContent();
// Connect a programmatic wallet client to this URI
const signClient = await SignClient.init({
projectId: process.env.WC_PROJECT_ID,
});
await signClient.pair({ uri: wcUri! });
// Approve the session proposal
signClient.on("session_proposal", async ({ id, params }) => {
await signClient.approve({
id,
namespaces: {
eip155: {
accounts: [`eip155:1:${TEST_WALLET_ADDRESS}`],
methods: ["eth_sendTransaction", "personal_sign"],
events: ["chainChanged", "accountsChanged"],
},
},
});
});
// Verify connection UI updated
await expect(page.locator('[data-testid="connected-address"]')).toBeVisible();
});Common Failure Modes to Test
Wrong network
Users on the wrong network are the most common support ticket in DeFi. Test that your dApp detects the mismatch and prompts a network switch:
test("prompts network switch when on wrong chain", async ({ page }) => {
// Connect wallet on mainnet (chainId 1) to a Polygon-only app
await page.addInitScript(() => {
window.ethereum = new MockEthereumProvider(TEST_KEY, "0x1");
});
await page.goto("http://localhost:3000");
await page.click('[data-testid="connect-wallet"]');
await expect(page.locator('[data-testid="wrong-network-banner"]')).toBeVisible();
await expect(page.locator('[data-testid="switch-network-button"]')).toBeVisible();
});
test("network switch request sent with correct chain params", async ({ page }) => {
const requests: string[] = [];
await page.addInitScript(() => {
const originalRequest = window.ethereum.request.bind(window.ethereum);
window.ethereum.request = async (args) => {
window.__walletRequests = window.__walletRequests || [];
window.__walletRequests.push(args.method);
return originalRequest(args);
};
});
await page.click('[data-testid="switch-network-button"]');
const walletRequests = await page.evaluate(() => window.__walletRequests);
expect(walletRequests).toContain("wallet_switchEthereumChain");
});Transaction rejection
Users reject transactions. Your UI should handle this gracefully:
test("handles transaction rejection without crashing", async ({ page }) => {
await page.addInitScript(() => {
window.ethereum = {
...new MockEthereumProvider(TEST_KEY),
request: async ({ method }) => {
if (method === "eth_sendTransaction") {
throw { code: 4001, message: "User rejected the request." };
}
return MockEthereumProvider.prototype.request.call(this, { method });
},
};
});
await page.goto("http://localhost:3000");
await page.click('[data-testid="send-button"]');
await expect(page.locator('[data-testid="error-message"]')).toContainText(
"Transaction cancelled"
);
// Page should still be functional
await expect(page.locator('[data-testid="send-button"]')).toBeEnabled();
});Account change mid-session
Users switch accounts in MetaMask while your app is open. Test the accountsChanged event handler:
test("updates UI when account changes", async ({ page }) => {
const provider = new MockEthereumProvider(ACCOUNT_A_KEY);
await page.addInitScript(() => {
window.ethereum = provider;
});
await page.goto("http://localhost:3000");
await page.click('[data-testid="connect-wallet"]');
const initialAddress = await page.textContent('[data-testid="wallet-address"]');
// Simulate account change
await page.evaluate(() => {
window.ethereum.emit("accountsChanged", [ACCOUNT_B_ADDRESS]);
});
const newAddress = await page.textContent('[data-testid="wallet-address"]');
expect(newAddress).not.toEqual(initialAddress);
expect(newAddress).toContain(ACCOUNT_B_ADDRESS.slice(0, 6));
});Pending transaction states
Transactions aren't instant. Your UI needs to show a pending state while waiting for confirmation:
test("shows pending state during transaction confirmation", async ({ page }) => {
// Provider that delays transaction confirmation
await page.addInitScript(() => {
window.ethereum.request = async ({ method }) => {
if (method === "eth_sendTransaction") {
return "0xpending_hash_123"; // Return hash immediately
}
if (method === "eth_getTransactionReceipt") {
// Simulate pending for first 3 calls, then confirmed
window.__receiptCallCount = (window.__receiptCallCount || 0) + 1;
if (window.__receiptCallCount < 3) return null; // null = pending
return { status: "0x1", blockNumber: "0x1234" };
}
};
});
await page.click('[data-testid="send-button"]');
await expect(page.locator('[data-testid="tx-status"]')).toContainText("Pending");
await expect(page.locator('[data-testid="tx-status"]')).toContainText("Confirmed", {
timeout: 10000,
});
});Continuous Monitoring of Live dApps
Wallet connection flows break in production for reasons outside your codebase: MetaMask updates change its behavior, WalletConnect relay servers go down, or a contract upgrade changes the ABI your frontend uses. Automated end-to-end tests in CI catch pre-deploy regressions, but they won't catch a live production break at 3am.
Setting up 24/7 synthetic monitoring against your production dApp's connection flow — even a simple test that loads the page and verifies the connect button is present and clickable — catches these issues before users report them. HelpMeTest's free plan includes unlimited health checks, making it straightforward to monitor that your wallet integration endpoints stay reachable around the clock.
Testing Across Multiple Wallet Types
Different wallets implement EIP-1193 with subtle variations. Your test suite should cover:
| Wallet | Key behaviors to test |
|---|---|
| MetaMask | Transaction confirmation popup, spending limits, network switching |
| Coinbase Wallet | Smart wallet vs EOA mode, different transaction signing flow |
| WalletConnect v2 | Session management, relay reconnection, multi-chain sessions |
| Phantom (Solana) | Different namespace, separate window.solana injection |
| Rabby | Multi-step transaction preview, different eth_sendTransaction response timing |
Abstract your wallet interaction layer behind an interface so you can run the same test scenarios against each wallet implementation. The bugs that matter most are the ones that affect users on the most popular wallets — and those wallets update frequently enough that regression testing against each release is worth automating.