End-to-End dApp Testing with Playwright and Hardhat: Test the UI and the Chain Together

End-to-End dApp Testing with Playwright and Hardhat: Test the UI and the Chain Together

Most Web3 testing guides cover smart contract unit tests or frontend component tests in isolation. But users experience your dApp end-to-end: they connect a wallet, click a button, wait for a transaction, and see the updated state. If any part of that chain breaks, they lose money or trust.

This guide shows how to wire Playwright (E2E browser testing) together with a local Hardhat node to test the complete user journey through your dApp.

The Testing Gap

Smart contract tests verify on-chain logic. Frontend tests verify UI rendering. But they don't test:

  • Does the UI correctly parse contract events?
  • Does the error message make sense when a transaction reverts?
  • Does the balance update immediately after a transaction, or does the user see stale data?
  • What happens when the user switches chains mid-interaction?
  • Does the wallet connection flow actually work in different browsers?

E2E testing with a controlled local blockchain answers all of these.

Architecture Overview

Playwright Test Runner
    │
    ├── Browser (Chromium/Firefox/WebKit)
    │       └── dApp Frontend (localhost:3000)
    │               └── MetaMask-compatible wallet (injected mock)
    │
    └── Hardhat Local Node (localhost:8545)
            └── Deployed test contracts with known state

Setup

Install Dependencies

npm install --save-dev \
  @playwright/test \
  hardhat \
  @nomiclabs/hardhat-ethers \
  ethers \
  @metamask/detect-provider

# For wallet mocking
npm install --save-dev \
  @synthetixio/synpress \  # Pre-built MetaMask E2E tooling
  # or build your own with:
  playwright-metamask

Configure Hardhat for Testing

// hardhat.config.js
module.exports = {
  networks: {
    hardhat: {
      chainId: 1337,
      mining: {
        auto: true,        // Mine immediately on each tx
        interval: 0        // No block mining delay
      },
      accounts: {
        mnemonic: "test test test test test test test test test test test junk",
        count: 10,
      }
    }
  },
  paths: {
    artifacts: './src/artifacts',  // Point frontend to test artifacts
  }
};

Global Test Setup

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  globalSetup: './tests/global-setup.ts',
  globalTeardown: './tests/global-teardown.ts',
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
  },
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    }
  ],
});
// tests/global-setup.ts
import { spawn, ChildProcess } from 'child_process';
import { ethers } from 'ethers';

let hardhatProcess: ChildProcess;
let devServer: ChildProcess;

export default async function globalSetup() {
  // Start local Hardhat node
  hardhatProcess = spawn('npx', ['hardhat', 'node', '--port', '8545'], {
    stdio: 'pipe',
  });
  
  // Wait for Hardhat to be ready
  await new Promise<void>((resolve) => {
    hardhatProcess.stdout?.on('data', (data) => {
      if (data.toString().includes('Started HTTP')) resolve();
    });
  });
  
  // Deploy contracts to local node
  await deployContracts();
  
  // Start dev server pointing to local node
  devServer = spawn('npm', ['run', 'dev'], {
    env: {
      ...process.env,
      NEXT_PUBLIC_RPC_URL: 'http://localhost:8545',
      NEXT_PUBLIC_CHAIN_ID: '1337',
    },
    stdio: 'pipe',
  });
  
  // Wait for dev server
  await new Promise<void>((resolve) => {
    devServer.stdout?.on('data', (data) => {
      if (data.toString().includes('ready')) resolve();
    });
  });
}

async function deployContracts() {
  const provider = new ethers.providers.JsonRpcProvider('http://localhost:8545');
  const deployer = provider.getSigner(0);
  
  // Deploy using artifacts
  const Token = await ethers.getContractFactory('MyToken', deployer);
  const token = await Token.deploy(ethers.utils.parseEther('1000000'));
  await token.deployed();
  
  const Vault = await ethers.getContractFactory('Vault', deployer);
  const vault = await Vault.deploy(token.address);
  await vault.deployed();
  
  // Save addresses for tests
  process.env.TEST_TOKEN_ADDRESS = token.address;
  process.env.TEST_VAULT_ADDRESS = vault.address;
  
  // Fund test accounts
  for (const account of TEST_ACCOUNTS) {
    await token.transfer(account, ethers.utils.parseEther('10000'));
  }
}

export const TEST_ACCOUNTS = [
  '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266',  // Hardhat account 0
  '0x70997970C51812dc3A010C7d01b50e0d17dc79C8',  // Hardhat account 1
];

The Wallet Mock

The biggest challenge in dApp E2E testing is wallet interaction. MetaMask uses browser extensions which don't work in headless Playwright. Solutions:

Option 1: Inject a Mock Provider

// tests/wallet-mock.ts
// Injected into the page context before the dApp loads

export const walletMockScript = `
window.__mockWallet = {
  accounts: [],
  chainId: '0x539', // 1337 in hex
  
  async request({ method, params }) {
    switch(method) {
      case 'eth_requestAccounts':
        this.accounts = ['${TEST_ACCOUNTS[0]}'];
        return this.accounts;
        
      case 'eth_accounts':
        return this.accounts;
        
      case 'eth_chainId':
        return this.chainId;
        
      case 'eth_sendTransaction':
        // Forward to local Hardhat node
        const response = await fetch('http://localhost:8545', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            jsonrpc: '2.0',
            id: Date.now(),
            method: 'eth_sendTransaction',
            params
          })
        });
        const data = await response.json();
        return data.result;
        
      case 'eth_getBalance':
        const balResponse = await fetch('http://localhost:8545', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            jsonrpc: '2.0',
            id: Date.now(),
            method: 'eth_getBalance',
            params
          })
        });
        const balData = await balResponse.json();
        return balData.result;
        
      default:
        throw new Error(\`Method not supported: \${method}\`);
    }
  },
  
  on(event, handler) {
    // Store handlers for manual triggering in tests
    if (!this._handlers) this._handlers = {};
    this._handlers[event] = handler;
  },
  
  // Test helper: trigger chain switch
  triggerChainChanged(newChainId) {
    this.chainId = newChainId;
    this._handlers?.chainChanged?.(newChainId);
  }
};

// Override window.ethereum
Object.defineProperty(window, 'ethereum', {
  value: window.__mockWallet,
  writable: false
});
`;

Option 2: Synpress (Full MetaMask Integration)

// For tests requiring real MetaMask behavior
import { test } from '@synthetixio/synpress/playwright';

test('connect wallet and deposit', async ({ page, metamask }) => {
  await page.goto('/');
  await metamask.importAccount('private-key-here');
  await page.click('[data-testid="connect-wallet"]');
  await metamask.acceptAccess();
  // ...
});

Writing E2E Tests

// tests/vault.e2e.ts
import { test, expect, Page } from '@playwright/test';
import { ethers } from 'ethers';

const provider = new ethers.providers.JsonRpcProvider('http://localhost:8545');

// Helper: get Hardhat signer (bypasses wallet for direct contract interaction)
async function getHardhatSigner(index = 0) {
  return provider.getSigner(index);
}

// Helper: advance blockchain time
async function advanceTime(seconds: number) {
  await provider.send('evm_increaseTime', [seconds]);
  await provider.send('evm_mine', []);
}

// Helper: read contract state directly
async function getVaultBalance(address: string): Promise<string> {
  const vault = new ethers.Contract(
    process.env.TEST_VAULT_ADDRESS!,
    ['function balances(address) view returns (uint256)'],
    provider
  );
  const balance = await vault.balances(address);
  return ethers.utils.formatEther(balance);
}

test.describe('Vault dApp', () => {
  test.beforeEach(async ({ page }) => {
    // Inject wallet mock before page loads
    await page.addInitScript(walletMockScript);
    await page.goto('/vault');
  });

  test('connects wallet and displays balance', async ({ page }) => {
    // Click connect button
    await page.click('[data-testid="connect-wallet-btn"]');
    
    // Wait for connection (mock responds immediately)
    await expect(page.locator('[data-testid="wallet-address"]'))
      .toContainText('0xf39F...2266');
    
    // Balance should show pre-funded amount
    await expect(page.locator('[data-testid="token-balance"]'))
      .toContainText('10,000');
  });

  test('deposit tokens updates on-chain state and UI', async ({ page }) => {
    // Connect wallet
    await page.click('[data-testid="connect-wallet-btn"]');
    await expect(page.locator('[data-testid="wallet-address"]')).toBeVisible();
    
    // Enter deposit amount
    await page.fill('[data-testid="deposit-input"]', '100');
    
    // Click deposit and handle approval transaction
    await page.click('[data-testid="approve-btn"]');
    
    // Wait for approval transaction confirmation UI
    await expect(page.locator('[data-testid="tx-status"]'))
      .toContainText('Approval confirmed', { timeout: 10000 });
    
    // Click deposit
    await page.click('[data-testid="deposit-btn"]');
    
    // Wait for deposit confirmation
    await expect(page.locator('[data-testid="tx-status"]'))
      .toContainText('Deposit confirmed', { timeout: 10000 });
    
    // Verify on-chain state
    const onChainBalance = await getVaultBalance(TEST_ACCOUNTS[0]);
    expect(parseFloat(onChainBalance)).toBe(100);
    
    // Verify UI shows updated vault balance
    await expect(page.locator('[data-testid="vault-balance"]'))
      .toContainText('100.0');
  });

  test('transaction revert shows helpful error message', async ({ page }) => {
    await page.click('[data-testid="connect-wallet-btn"]');
    
    // Try to withdraw more than deposited (0 balance)
    await page.fill('[data-testid="withdraw-input"]', '100');
    await page.click('[data-testid="withdraw-btn"]');
    
    // Should show user-friendly error, not raw revert reason
    await expect(page.locator('[data-testid="error-message"]'))
      .toContainText('Insufficient balance', { timeout: 5000 });
    
    // Should NOT show raw error like "execution reverted: Insufficient balance"
    await expect(page.locator('[data-testid="error-message"]'))
      .not.toContainText('execution reverted');
  });

  test('handles chain switch during transaction', async ({ page }) => {
    await page.click('[data-testid="connect-wallet-btn"]');
    await page.fill('[data-testid="deposit-input"]', '100');
    
    // Simulate chain switch mid-interaction
    await page.evaluate(() => {
      (window as any).__mockWallet.triggerChainChanged('0x1');
    });
    
    // UI should detect wrong network
    await expect(page.locator('[data-testid="network-warning"]'))
      .toContainText('Please switch to');
  });

  test('displays transaction in history after confirmation', async ({ page }) => {
    await page.click('[data-testid="connect-wallet-btn"]');
    
    // Make a deposit
    await page.fill('[data-testid="deposit-input"]', '50');
    await page.click('[data-testid="approve-btn"]');
    await expect(page.locator('[data-testid="tx-status"]'))
      .toContainText('Approval confirmed', { timeout: 10000 });
    await page.click('[data-testid="deposit-btn"]');
    await expect(page.locator('[data-testid="tx-status"]'))
      .toContainText('Deposit confirmed', { timeout: 10000 });
    
    // Navigate to history
    await page.click('[data-testid="history-tab"]');
    
    // Should show the transaction
    await expect(page.locator('[data-testid="tx-history-item"]').first())
      .toContainText('Deposit');
    await expect(page.locator('[data-testid="tx-history-item"]').first())
      .toContainText('50');
  });

  test('stale state refresh after external transaction', async ({ page }) => {
    await page.click('[data-testid="connect-wallet-btn"]');
    
    // Initial vault balance: 0
    await expect(page.locator('[data-testid="vault-balance"]')).toContainText('0');
    
    // Make deposit directly via contract (bypassing UI — simulating another device)
    const signer = await getHardhatSigner(0);
    const token = new ethers.Contract(process.env.TEST_TOKEN_ADDRESS!, ERC20_ABI, signer);
    const vault = new ethers.Contract(process.env.TEST_VAULT_ADDRESS!, VAULT_ABI, signer);
    
    await token.approve(vault.address, ethers.utils.parseEther('200'));
    await vault.deposit(ethers.utils.parseEther('200'));
    
    // UI should refresh and show new balance (polling or event listener)
    await expect(page.locator('[data-testid="vault-balance"]'))
      .toContainText('200', { timeout: 15000 });
  });
});

Running Tests

# Run E2E tests
npx playwright test

# Run specific test file
npx playwright test tests/vault.e2e.ts

# Run with UI mode (visual debugging)
npx playwright test --ui

# Run with trace recording
npx playwright test --trace on

# Run in headed mode (see browser)
npx playwright test --headed

CI Integration

# .github/workflows/e2e.yml
name: E2E Tests

on: [push, pull_request]

jobs:
  e2e:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          
      - run: npm ci
      
      - name: Install Playwright browsers
        run: npx playwright install --with-deps chromium
        
      - name: Run E2E tests
        run: npx playwright test
        env:
          MAINNET_RPC_URL: ${{ secrets.MAINNET_RPC_URL }}  # For fork tests
          
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: playwright-report/

Beyond CI: Continuous Monitoring with HelpMeTest

For production dApps, write tests in HelpMeTest that run against your live deployment:

Test: Token approval and deposit flow
Environment: Mainnet (read-only verification) + local fork for writes
Steps:
  1. Navigate to app.myprotocol.xyz/vault
  2. Connect wallet (using saved auth state)
  3. Verify contract address displayed matches known deployment
  4. Verify displayed APY matches on-chain rate within 0.1%
  5. Verify gas estimate shown is within 20% of actual gas used

HelpMeTest runs these tests 24/7 against your production dApp, alerting you when the UI diverges from contract state — whether due to a frontend bug, an RPC node issue, or an unexpected contract state change.

The combination of Playwright + Hardhat for development testing and HelpMeTest for production monitoring gives you complete coverage of the user experience layer that pure contract tests miss.

Start now free