How to Build a Playwright + Dappwright + Cucumber Test Framework for a simple web3 Coin Minting dAPP
- Ivan Ćorić

- Apr 2, 2025
- 4 min read

If you're looking to test decentralized applications (DApps) with realistic browser automation, you'll want a stack that can handle UI interactions, blockchain wallet integrations, and human-readable test scenarios. This is where the combo of Playwright, Dappwright, and Cucumber shines.
In this post, we'll build a test framework based on ivanori1/qa-challenge to test a dummy app that mints coins. The app connects to MetaMask and allows users to mint tokens with one click.
🔧 Tech Stack Breakdown
Playwright – Browser automation for modern web apps.
Dappwright – Playwright extension for MetaMask wallet integration.
Cucumber – Write tests in Gherkin for BDD-style scenarios.
TypeScript – Strong typing and scalable code structure.
1. 🛠 Project Setup
Clone the base project:
git clone https://github.com/ivanori1/qa-challenge.git
cd qa-challenge
npm installThis repo already includes Playwright, Dappwright, and Cucumber integrated with TypeScript.
qa-challenge/
│
├── e2e/
│ ├── features/ # Gherkin files
│ ├── steps/ # Step definitions
│ ├── test-results/ # Tests generated after execution
│
├── .env.local/ # metamask seed and password
├── playwright.config.ts
└── cucumber.jsonHonestly with cucumber setup I tried it to be js file but it didn't work so I sticked with .json file
2. 🧪 Writing Your First Test: Mint Coins
🥒 01-app-access.feature
Feature: The application works only with the Sepolia network
@app_access_positive
Scenario: The user accesses the page with Metamask connected to Sepolia network
Given A user with metamask installed connected to "Sepolia" network
When the user accesses the app page
And the user accepts notifications
Then the page shows the account address
And the page shows the input address field
And the page doesn't show a network error message
@app_access_negative
Scenario: The user accesses the page with Metamask connected to Mainnet network
Given A user with metamask installed connected to "Ethereum Mainnet" network
When the user accesses the app page
And the user accepts notifications
Then the page shows a network error message
And the page shows the switch network button
And the page doesn't show the input address field
@switch_network
Scenario: The user accesses the page with Metamask connected to Mainnet network and uses the switch network button
Given A user with metamask installed connected to "Ethereum Mainnet" network
When the user accesses the app page
And the user accepts notifications
And the user clicks the switch network button
And the user confirms the switch network
Then the page shows the input address field
And the page doesn't show a network error message
🧩 Step Definitions: common.steps.ts
First steps that are reused in multiple scenarios are put in common and it have import
import dotenv from "dotenv";
import { launch, MetaMaskWallet } from "@tenkeylabs/dappwright";
import { Given, When, setDefaultTimeout } from "@cucumber/cucumber";
import { Page, BrowserContext } from "@playwright/test";
then other files like
🧩 01-app-access.steps.ts
are importing from
import { context, page, metamask } from "./common.steps";and then it should look like this:
import { When, Then } from "@cucumber/cucumber";
import { expect } from "@playwright/test";
import { context, page, metamask } from "./common.steps";
When("the user accepts notifications", async () => {
console.log("✅ Checking for MetaMask connection request...");
// Bring MetaMask tab to front
const mmPopup = context
.pages()
.find((p) => p.url().includes("chrome-extension://"));
if (!mmPopup) {
throw new Error("❌ MetaMask extension tab not found!");
}
await mmPopup.bringToFront();
console.log("✅ MetaMask tab is active!");
// Click on Account options menu (three dots)
const accountOptions = mmPopup.locator(
"[data-testid='account-options-menu-button']"
);
await accountOptions.waitFor({ state: "visible", timeout: 5000 });
await accountOptions.click();
console.log("✅ Opened MetaMask menu.");
// Click on "All Permissions"
const allPermissions = mmPopup.locator(
"[data-testid='global-menu-connected-sites']"
);
await allPermissions.waitFor({ state: "visible", timeout: 5000 });
await allPermissions.click();
console.log("✅ Navigated to permissions.");
// Click "Got it" button (if exists)
const gotItButton = mmPopup.locator(".multichain-product-tour-menu__button");
if (await gotItButton.isVisible()) {
await gotItButton.click();
console.log("✅ Dismissed 'Got it' modal.");
}
// Click back arrow button
const backButton = mmPopup.locator("[aria-label='Back']");
await backButton.waitFor({ state: "visible", timeout: 5000 });
await backButton.click();
console.log("✅ Navigated back to connection prompt.");
// Click the "Connect" button to approve localhost connection
const connectPopup = mmPopup.locator("[data-testid='confirm-btn']");
await connectPopup.waitFor({ state: "visible", timeout: 5000 });
await connectPopup.click();
console.log("🟢 Successfully connected MetaMask to localhost!");
// Bring the dApp tab back to front
const dAppPage = context.pages().find((p) => p.url().includes("localhost"));
if (!dAppPage) {
throw new Error("❌ dApp page (localhost) not found!");
}
await dAppPage.bringToFront();
console.log("✅ dApp is now in focus!");
});
....⚠️ Gotchas & Workarounds: MetaMask Double Confirmations
One issue I ran into while testing was when MetaMask asks for confirmation twice for a single transaction. This can happen with certain contracts or networks.
🛠 Simple Workaround:
If the second confirmation doesn't show up automatically:
Keep focus on the MetaMask browser tab (not just the extension popup).
refresh the MetaMask tab
page.reload()The confirmation screen will reappear and allow the test to proceed.
This workaround works consistently and helps stabilize flaky test runs that rely on MetaMask confirmation flows.
✅ Run the Tests
npm run testThis will launch the browser, connect your wallet, interact with the minting DApp, and validate balances.
💬 Final Thoughts
Combining Playwright + Dappwright + Cucumber gives you a solid, extensible framework for testing Web3 DApps in a way that mimics real user behavior. Whether you're minting coins, staking tokens, or bridging assets — this stack can handle it all.
There aren’t many mature options out there for true end-to-end DApp testing — it mostly comes down to choosing between Dappwright and Synpress. And honestly, there’s not a lot of detailed blog content or demos covering this space, especially for real-world workflows like MetaMask confirmation flows. Hopefully, this helps fill that gap a bit!




Comments