Testing Web3 Wallet Integrations: MetaMask, WalletConnect, and Beyond

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:

  1. Mock the provider — replace window.ethereum with a controllable mock
  2. Use a test wallet extension — Synpress, MetaMask Test Dapp, or similar tools that automate the real extension
  3. 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/synpress
import { 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.

Read more