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 stateSetup
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-metamaskConfigure 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 --headedCI 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 usedHelpMeTest 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.