Integration testing a dapp means checking that the frontend and the contracts agree: the UI sends the right transaction, the contract changes state, and the UI shows the result. The practical setup is a local Hardhat chain for the contracts, a browser test runner (Playwright) with an injected wallet provider, and a test that goes through one user action end to end.
I’ll use a decentralized exchange (DEX) as the example. You have an ERC20 token contract, an exchange contract and a React frontend. Unit tests pass on both sides and the swap button still does nothing. That gap is what integration tests cover.
What breaks between a contract and a frontend
The failures are rarely logic bugs inside the contract. They sit at the boundary:
- The frontend ships with an old ABI or contract address after a redeploy.
- The UI shows token amounts without accounting for decimals. A token with 6 decimals, like USDC, shows a million times too much if the UI assumes 18.
- A transaction reverts and the UI stays on “pending” forever.
- The frontend waits for an event the contract never emits, or emits with different arguments.
- The app is configured for one chain ID and the wallet is on another.
Each of these passes a contract unit test and a component test on its own. I’m describing the classes of bug here, not one specific project.
Step 1: Catch drift before you open a browser
Two of the failures above can be caught with plain Hardhat tests, with no browser. Compare the ABI the frontend uses with the one the compiler produced, and check amounts against the token’s real decimals:
const { expect } = require('chai');
const { ethers, artifacts } = require('hardhat');
it('frontend ABI matches the compiled Exchange contract', async () => {
const artifact = await artifacts.readArtifact('Exchange');
const frontendAbi = require('../frontend/src/abi/Exchange.json');
expect(frontendAbi).to.deep.equal(artifact.abi);
});
it('parses user input with the token decimals', async () => {
const token = await ethers.deployContract('Token');
const decimals = await token.decimals();
expect(ethers.parseUnits('10', decimals)).to.equal(10n * 10n ** decimals);
});
These run in milliseconds and fail the moment someone redeploys without updating the frontend. The second test only helps if the frontend uses the same helper, so import the parsing function from your app code rather than re-implementing it in the test.
Step 2: Run a local chain and deploy to it
For the browser tests you need a running chain with your contracts on it. Start a Hardhat node, deploy, and write the addresses to a file the tests and the frontend both read:
// scripts/deploy.js
const { ethers } = require('hardhat');
const fs = require('fs');
async function main() {
const token = await ethers.deployContract('Token');
const exchange = await ethers.deployContract('Exchange', [
await token.getAddress(),
]);
fs.writeFileSync(
'deployments.json',
JSON.stringify({
token: await token.getAddress(),
exchange: await exchange.getAddress(),
})
);
}
main();
npx hardhat node # terminal 1
npx hardhat run scripts/deploy.js --network localhost # terminal 2
In ethers v6 a contract’s address is await contract.getAddress(), not contract.address.
Step 3: Inject a wallet instead of mocking the contract
Tests against a public network are slow and flaky, so keep them out of integration tests. Run the contracts on the local chain, and replace only what the browser normally supplies: the wallet. A dapp talks to the wallet through window.ethereum, which follows the EIP-1193 request({ method, params }) interface. In Playwright you can inject your own object that forwards JSON-RPC calls to the local node. The Hardhat node holds unlocked test accounts, so it signs transactions itself:
// e2e/wallet.ts
import type { Page } from '@playwright/test';
const RPC_URL = 'http://127.0.0.1:8545';
export async function injectWallet(page: Page) {
await page.exposeFunction(
'__rpc',
async (method: string, params: unknown[]) => {
// Treat the connect request as "list the unlocked accounts"
const rpcMethod =
method === 'eth_requestAccounts' ? 'eth_accounts' : method;
const res = await fetch(RPC_URL, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: rpcMethod,
params,
}),
});
const body = await res.json();
if (body.error) throw new Error(body.error.message);
return body.result;
}
);
await page.addInitScript(() => {
(window as any).ethereum = {
isMetaMask: true,
request: ({ method, params }: { method: string; params?: unknown[] }) =>
(window as any).__rpc(method, params ?? []),
on() {},
removeListener() {},
};
});
}
This is a sketch of the pattern, not a drop-in library. Adapt it to how your app detects wallets (some use EIP-6963 events instead of window.ethereum). Write down what the injected object stands in for, because a mock that drifts from real wallet behavior is how a green suite ships a broken dapp. It does not cover wallet popups or rejected signatures. If you need the real extension, Playwright can load one in a persistent browser context, at the cost of slower and more brittle tests.
Step 4: Test one full user action
Now test the part users actually do. This test connects the wallet, types an amount, clicks Swap, waits for the success message and then reads the contract state directly from the chain:
// e2e/swap.spec.ts
import { test, expect } from '@playwright/test';
import { ethers } from 'ethers';
import { injectWallet } from './wallet';
import deployments from '../deployments.json';
import tokenAbi from '../frontend/src/abi/Token.json';
const USER = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266'; // Hardhat account #0
test('user can swap tokens', async ({ page }) => {
await injectWallet(page);
await page.goto('/');
await page.getByRole('button', { name: 'Connect wallet' }).click();
await page.getByLabel('Amount').fill('10');
await page.getByRole('button', { name: 'Swap' }).click();
// The UI reports success...
await expect(page.getByText('Swap Successful!')).toBeVisible();
// ...and the chain agrees.
const provider = new ethers.JsonRpcProvider('http://127.0.0.1:8545');
const token = new ethers.Contract(deployments.token, tokenAbi, provider);
expect(await token.balanceOf(USER)).toBe(ethers.parseUnits('10', 18));
});
The assertion on both the UI message and the contract balance matters. Checking only the UI would pass if the frontend showed success without a transaction being mined. Adjust the expected balance to what your exchange actually does with the swap.
Wire it together in playwright.config.ts so one command starts the chain and the app:
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
use: { baseURL: 'http://localhost:3000' },
globalSetup: './e2e/global-setup.ts', // runs scripts/deploy.js against the node
webServer: [
{
command: 'npx hardhat node',
url: 'http://127.0.0.1:8545',
reuseExistingServer: !process.env.CI,
},
{
command: 'npm run dev --prefix frontend',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
],
});
Playwright starts both servers, waits for each URL, and shuts them down afterwards (docs). The deploy step needs the node to be up. If your Playwright version runs globalSetup before the servers are ready, call the deploy script from a test.beforeAll hook instead.
Three problems that make dapp tests flaky
- Async timing. Everything on-chain is async. Await every call and use Playwright’s auto-waiting assertions such as
toBeVisibleinstead of fixed sleeps. - Gas estimation. Estimates vary between environments. Use the local node’s defaults or set fixed gas limits in tests.
- Events. Test that the contract emits the event and that the frontend reacts to it. The contract side looks like this, using the Hardhat Chai matchers:
it('should emit Transfer event', async () => {
const [owner, recipient] = await ethers.getSigners();
const token = await ethers.deployContract('Token');
const amount = ethers.parseEther('1');
await expect(token.transfer(recipient.address, amount))
.to.emit(token, 'Transfer')
.withArgs(owner.address, recipient.address, amount);
});
Add a UI test that waits for the on-screen change that event should cause.
What to run on every pull request
- Contract tests and the ABI and decimals checks on a local Hardhat chain.
- The Playwright suite from above against the local chain.
- Fuzz tests on the contract side for inputs the UI might send, such as zero, very large or malformed amounts.
- All of it in CI on every push or pull request. See the automated testing setup for a GitHub Actions config. Add
npx playwright install --with-depsbefore the Playwright step.
Weird token behavior reaches the UI too. A fee-on-transfer token makes the “you receive” amount wrong, which is why I build the mocks in creating test data for DeFi. If the web2 side of this is unfamiliar, web2 vs web3 testing covers why a wallet changes how you test.
More in the web3 testing posts. If you want a specific part of this covered in more depth, tell me which.