← Back to Blog

Testing NFT Mint Flows End-to-End with MetaMask and Playwright

Learn how to test NFT mint flows in EVM dApps using MetaMask and Playwright — connect wallets, confirm transactions, assert results without mocks.

Written by Chroma Team

Minting is one of the most critical user flows in any NFT dApp — and one of the hardest to test reliably. The happy path looks simple: user clicks "Mint," MetaMask opens, they confirm, the token appears in their wallet. But between the RPC call, the wallet popup, and the async state update, there are plenty of places for things to go wrong in ways that unit tests won't catch.

Most teams skip real EVM dApp testing here because driving a browser extension from test code feels out of reach. This article shows it isn't — and explains exactly how to cover the full mint flow, including the rejection path.

The Anatomy of a Mint Flow

Before writing any test, map out what actually happens when a user mints:

  1. User connects their wallet (MetaMask opens a connection popup)
  2. User clicks "Mint" (your dApp calls mint() on the ERC-721 contract)
  3. MetaMask opens a transaction confirmation popup
  4. User confirms → transaction is submitted to the chain
  5. Your dApp polls for the receipt and updates the UI

Each step is an integration point. A broken connection flow, a missing await before the MetaMask popup, or a UI that doesn't re-render after the receipt comes back — none of these show up in unit tests. Only a test that drives a real browser with a real wallet extension can catch them.

Setting Up the Test Environment

The fastest local setup is Anvil (Foundry's local EVM node) with your dApp pointing at http://127.0.0.1:8545. Start it with the standard test mnemonic — the same one you'll pass to importSeedPhrase — so the funded account is always available:

anvil --mnemonic "test test test test test test test test test test test junk"

If you haven't wired Anvil into your Playwright config before, the Chroma CI/CD guide covers the full setup including how to start and stop the node as part of your test run.

Writing the Test with @avalix/chroma

@avalix/chroma handles the MetaMask extension lifecycle — downloading the right version, loading it into the browser, and exposing a clean API for wallet interactions. Your test file stays focused on the flow:

import { createWalletTest, expect } from '@avalix/chroma'

const test = createWalletTest({
  wallets: [{ type: 'metamask' }],
})

test.describe('NFT mint flow', () => {
  test.beforeEach(async ({ wallets }) => {
    await wallets.metamask.importSeedPhrase({
      seedPhrase: 'test test test test test test test test test test test junk',
    })
  })

  test('mints an NFT and shows success state', async ({ page, wallets }) => {
    const metamask = wallets.metamask

    await page.goto('http://localhost:3000')

    // Step 1: connect wallet
    await page.click('button:has-text("Connect Wallet")')
    await metamask.authorize()

    // Step 2: trigger mint
    await page.click('button:has-text("Mint")')

    // Step 3: confirm the transaction in MetaMask
    await metamask.confirm()

    // Step 4: assert the dApp updated correctly
    await expect(page.locator('.mint-success')).toBeVisible()
    await expect(page.locator('.token-id')).toContainText('#')
  })
})

importSeedPhrase runs MetaMask's full onboarding flow — wallet import, password setup — before each test, so every test starts from the same clean state. authorize() handles the connection popup. confirm() handles the transaction popup. You don't need to switch windows or find the extension tab manually.

The assertions at the end use Playwright's built-in auto-waiting: toBeVisible() keeps retrying until the element appears or the timeout hits. If your dApp polls for the transaction receipt before showing the success state, that latency is handled automatically.

Testing the NFT-Specific Rejection Problem

NFT dApps have a rejection bug that's easy to miss: optimistic UI updates. Many mint UIs increment the "Total Minted" counter or show a preview of the incoming token the moment the user clicks "Mint" — before the transaction actually confirms. The idea is to feel fast. The problem is that if the user rejects in MetaMask, that premature state update has nowhere to go.

You end up with a counter that reads 1/100 when no token was actually minted, a spinner that never resolves, or a "Success" banner on a rejected transaction. These bugs are nearly impossible to catch with unit tests because they depend on the timing between a UI state change and a wallet popup that a real user just closed.

test('reverts optimistic UI when user rejects the mint', async ({ page, wallets }) => {
  const metamask = wallets.metamask

  await page.goto('http://localhost:3000')
  await page.click('button:has-text("Connect Wallet")')
  await metamask.authorize()

  // Record the counter before attempting to mint
  const counterBefore = await page.locator('.mint-counter').textContent()

  await page.click('button:has-text("Mint")')
  await metamask.reject()

  // Counter must be back to its original value — not stuck on an optimistic update
  await expect(page.locator('.mint-counter')).toHaveText(counterBefore!)
  await expect(page.locator('.mint-error')).toBeVisible()
  await expect(page.locator('button:has-text("Mint")')).toBeEnabled()
})

This test goes beyond "does the error show" — it verifies that the UI rolled back any state it changed optimistically. That rollback logic is exactly what breaks silently after a refactor, and exactly what users notice when it goes wrong.

What You're Covering

Between these two tests, you're exercising:

  • Your wallet connection logic and the authorize() flow
  • The mint() contract call and its parameters
  • MetaMask transaction signing with a real browser extension
  • Your UI's response to a confirmed transaction (token ID rendered, counter incremented)
  • Your UI's rollback logic after a rejected transaction (optimistic updates cleared, button re-enabled)

That last point is the one most likely to be missing from your current test suite. Optimistic UI rollback only exists as a code path when a user rejects — which means it never gets exercised by your happy-path tests and rarely gets tested manually. It's the kind of thing that silently breaks in production after a state management refactor.

The full Playwright setup, including how to run these headlessly in GitHub Actions, is covered in the @avalix/chroma docs.