Blockchain Transaction Testing Patterns: From Unit Tests to End-to-End

Blockchain Transaction Testing Patterns: From Unit Tests to End-to-End

Blockchain transactions are not like HTTP requests. They have lifecycle states — pending, confirmed, failed — that exist asynchronously across potentially minutes. They can be reorganized out of the canonical chain. They emit event logs that serve as the primary data source for off-chain systems. A testing strategy that treats a transaction like a synchronous function call will miss entire categories of bugs that only appear in production.

Transaction Lifecycle States

Before writing tests, understand the full lifecycle of a transaction:

  1. Submitted — transaction is broadcast to the network, enters the mempool
  2. Pending — transaction is in the mempool but not yet included in a block
  3. Included — transaction is in a block, but the block may not be final
  4. Confirmed — transaction has N blocks on top of it (N depends on the chain and risk tolerance)
  5. Failed — transaction reverted on-chain (block included it but execution failed)
  6. Dropped — transaction was removed from the mempool without being included (low gas, nonce replacement)
  7. Reorganized — transaction was in a block that got orphaned by a chain reorganization

Your application code needs to handle every one of these states. Your tests need to verify that it does.

Testing Pending Transaction States

The most common gap in dApp testing is the pending state. Users submit a transaction and your UI needs to show a spinner, disable the submit button, and prevent duplicate submissions — all before you get a receipt.

Unit testing the transaction state machine

// transaction-store.test.ts
describe("TransactionStore", () => {
  it("transitions from idle to pending on submit", async () => {
    const store = new TransactionStore(mockProvider);
    
    expect(store.state).toBe("idle");
    
    const txPromise = store.send({ to: recipient, value: 1n });
    
    // Check state synchronously after send() is called but before it resolves
    expect(store.state).toBe("pending");
    expect(store.hash).toMatch(/^0x[0-9a-f]{64}$/);
    
    await txPromise;
  });

  it("disables submit button while pending", async () => {
    render(<SendForm store={store} />);
    
    const button = screen.getByRole("button", { name: /send/i });
    
    // Start a transaction
    const txPromise = store.send(params);
    
    // Button should be disabled immediately
    expect(button).toBeDisabled();
    
    await txPromise;
    expect(button).toBeEnabled();
  });

  it("prevents duplicate submission during pending", async () => {
    const sendSpy = jest.spyOn(provider, "sendTransaction");
    
    store.send(params);
    store.send(params); // Second call while first is pending
    
    expect(sendSpy).toHaveBeenCalledTimes(1);
  });
});

Simulating pending transactions in Hardhat

Hardhat's hardhat_setAutomine lets you stop automatic block production and manually control when transactions get mined:

it("shows pending state before block is mined", async () => {
  // Stop automining
  await network.provider.send("hardhat_setAutomine", [false]);
  
  const txResponse = await contract.transfer(recipient, amount);
  
  // Transaction is submitted but not mined
  const receipt = await provider.getTransactionReceipt(txResponse.hash);
  expect(receipt).toBeNull(); // null = pending
  
  // Your UI/service should show pending state
  expect(await trackingService.getStatus(txResponse.hash)).toBe("pending");
  
  // Mine the block
  await network.provider.send("hardhat_mine", []);
  
  // Now it should be confirmed
  const minedReceipt = await provider.getTransactionReceipt(txResponse.hash);
  expect(minedReceipt?.status).toBe(1);
  expect(await trackingService.getStatus(txResponse.hash)).toBe("confirmed");
  
  // Restore automining
  await network.provider.send("hardhat_setAutomine", [true]);
});

Testing Confirmed Transactions

Transaction confirmation requires waiting for N blocks. The right N depends on the chain:

  • Ethereum mainnet: 12 blocks (~2.5 minutes) for high-value transactions
  • Polygon: 256 blocks (~5 minutes) for cross-chain bridge security
  • L2s (Arbitrum, Optimism): 1 block sufficient for most operations; sequencer finality
it("marks transaction confirmed after required block depth", async () => {
  const txResponse = await contract.deposit({ value: parseEther("1") });
  
  // After 1 block — transaction included but not yet "confirmed" by our standards
  await network.provider.send("hardhat_mine", [1]);
  expect(await depositService.isConfirmed(txResponse.hash)).toBe(false);
  
  // After 12 blocks — our confirmation threshold
  await network.provider.send("hardhat_mine", [11]); // 11 more = 12 total
  expect(await depositService.isConfirmed(txResponse.hash)).toBe(true);
});

Testing Failed Transactions

Transactions that revert on-chain are different from transactions that fail to submit. A reverted transaction:

  • Has a receipt with status: 0
  • Still costs gas
  • Is permanently recorded on-chain
  • Should NOT trigger your success flow
it("handles reverted transaction correctly", async () => {
  // Send ETH to a contract that will revert
  const txResponse = await revertingContract.alwaysReverts();
  const receipt = await txResponse.wait(1);
  
  expect(receipt?.status).toBe(0); // 0 = reverted
  
  // Your service should classify this as failed, not pending
  expect(await txTracker.getStatus(txResponse.hash)).toBe("failed");
  
  // Balance should be unchanged (minus gas)
  const balanceAfter = await provider.getBalance(sender);
  expect(balanceAfter).toBeLessThan(balanceBefore); // Only gas consumed
});

it("surfaces revert reason to the user", async () => {
  // Test that your UI shows the revert reason, not a generic error
  const result = await contract.withdraw(excessiveAmount);
  
  expect(result.error).toContain("Insufficient balance");
  expect(screen.getByText(/insufficient balance/i)).toBeInTheDocument();
});

Testing out-of-gas failures

// Foundry: test behavior when gas runs out mid-execution
function test_outOfGasHandledGracefully() public {
    // Call a function with insufficient gas
    (bool success, ) = address(contract).call{gas: 1000}(
        abi.encodeWithSignature("expensiveOperation()")
    );
    
    assertFalse(success);
    // State should be unchanged (EVM rolls back on OOG)
    assertEq(contract.someStateVar(), initialValue);
}

Reorg Handling

Chain reorganizations are rare but not theoretical — they happen on every PoW chain and can occur on PoS chains during network partitions. A reorg removes confirmed transactions from the canonical chain, which means:

  • User's transaction may disappear
  • Off-chain database has a state that no longer matches the chain
  • Events emitted by the orphaned transaction should be retracted

Testing reorg detection

it("detects and handles chain reorganization", async () => {
  // Mine transaction at block 100
  const txResponse = await contract.deposit({ value: parseEther("1") });
  await network.provider.send("hardhat_mine", [1]);
  
  const blockNumber = (await txResponse.wait())!.blockNumber;
  await depositService.processDeposit(txResponse.hash);
  
  expect(await depositService.getBalance(user)).toBe(parseEther("1"));
  
  // Simulate reorg: roll back to before the transaction
  await network.provider.send("hardhat_reset", [{
    forking: { blockNumber: blockNumber - 1 }
  }]);
  
  // Service should detect the reorg and revert the state
  await depositService.syncToChain();
  
  expect(await depositService.getBalance(user)).toBe(0n);
});

In practice, reorg handling means listening for block events and re-validating recently confirmed transactions:

provider.on("block", async (blockNumber) => {
  const recentTxs = await txStore.getTransactionsInLastNBlocks(20);
  
  for (const tx of recentTxs) {
    const receipt = await provider.getTransactionReceipt(tx.hash);
    
    if (receipt === null) {
      // Transaction disappeared — reorg occurred
      await txStore.markReorged(tx.hash);
      await revertOffChainState(tx);
    } else if (receipt.blockNumber !== tx.confirmedBlock) {
      // Transaction moved to different block — reorg reordered it
      await txStore.updateConfirmation(tx.hash, receipt.blockNumber);
    }
  }
});

Event Log Assertions

Events are the primary way smart contracts communicate state changes to off-chain systems. Testing that the right events are emitted with the right arguments is as important as testing return values.

Foundry event assertions

function test_depositEmitsCorrectEvent() public {
    vm.expectEmit(true, true, false, true);
    // indexed: user address, indexed: token address, not indexed, data: amount
    emit Deposit(user, USDC, 1000e6);
    
    pool.deposit(USDC, 1000e6);
}

function test_multipleEventsInOrder() public {
    vm.expectEmit(true, false, false, true);
    emit Transfer(address(0), user, 1); // Mint event
    
    vm.expectEmit(true, true, false, true);
    emit Approval(user, spender, 1); // Approval event
    
    nft.mintAndApprove(user, spender, 1);
}

Hardhat event assertions

it("emits Transfer event with correct args", async () => {
  await expect(token.transfer(recipient, 100))
    .to.emit(token, "Transfer")
    .withArgs(sender.address, recipient.address, 100);
});

it("emits multiple events in one transaction", async () => {
  const tx = await pool.depositAndStake(amount);
  const receipt = await tx.wait();
  
  const depositEvent = receipt?.logs.find(
    log => log.topics[0] === pool.interface.getEvent("Deposit").topicHash
  );
  const stakeEvent = receipt?.logs.find(
    log => log.topics[0] === staking.interface.getEvent("Staked").topicHash
  );
  
  expect(depositEvent).toBeDefined();
  expect(stakeEvent).toBeDefined();
});

Testing event indexers

If you're building an indexer or subgraph that processes events, test that it correctly handles:

  • Events from the same block in order
  • Events from multiple contracts in the same transaction
  • Events from failed transactions (which should NOT be indexed — they're reverted)
it("does not index events from reverted transactions", async () => {
  // Force a revert mid-transaction
  await revertingBatch.executeBatch([validTx, revertingTx]);
  
  // Neither transaction's events should be indexed
  const indexed = await indexer.getEventsForBlock(await provider.getBlockNumber());
  expect(indexed).toHaveLength(0);
});

End-to-End Transaction Testing

An E2E transaction test covers the full flow: user action → wallet request → transaction submission → pending state → confirmation → UI update.

test("full transfer flow from UI to confirmation", async ({ page }) => {
  // Setup: inject wallet mock with automated signing
  await page.addInitScript(() => {
    window.ethereum = new AutoSigningProvider(TEST_KEY, RPC_URL);
  });
  
  await page.goto("http://localhost:3000/send");
  
  // Fill form
  await page.fill('[data-testid="recipient"]', RECIPIENT_ADDRESS);
  await page.fill('[data-testid="amount"]', "0.1");
  await page.click('[data-testid="send-button"]');
  
  // Verify pending state appears
  await expect(page.locator('[data-testid="tx-status"]')).toContainText("Pending");
  await expect(page.locator('[data-testid="tx-hash"]')).toBeVisible();
  
  // Wait for confirmation (mock confirms after 2s in test mode)
  await expect(page.locator('[data-testid="tx-status"]')).toContainText("Confirmed", {
    timeout: 15000,
  });
  
  // Verify balance updated
  await expect(page.locator('[data-testid="balance"]')).toContainText("0.9");
});

Monitoring Transaction Infrastructure in Production

Even with comprehensive tests, production blockchain infrastructure can fail in ways tests don't cover: RPC nodes go down, WebSocket connections drop, mempool monitoring lags. Transaction tracking services need continuous monitoring.

HelpMeTest's 24/7 health checks work well here — set up checks that verify your RPC endpoint responds, your transaction indexer's latest block is within N blocks of the chain tip, and your pending transaction queue isn't growing unboundedly. The free plan's unlimited health checks mean you can monitor every critical endpoint without cost concerns.

Summary: The Transaction Testing Checklist

State coverage

  • Pending state shows correct UI, prevents duplicate submissions
  • Confirmed state triggers correct downstream actions
  • Failed/reverted state shows error, does not trigger success flow
  • Dropped transaction detected and surfaced to user

Chain properties

  • Correct confirmation depth before marking "final"
  • Reorg detection reverts off-chain state
  • Gas estimation tested at boundary conditions

Events

  • All events emitted with correct arguments and ordering
  • Events from reverted transactions are not processed
  • Event ordering within a block is preserved by indexers

E2E

  • Full flow from UI action to on-chain confirmation
  • Network switch / wrong chain handled
  • Wallet rejection handled gracefully
  • Concurrent transactions managed correctly (nonce sequencing)

The gap between "tests pass locally" and "works in production" in blockchain development is larger than in most software domains. Filling that gap requires testing not just the happy path but every asynchronous state transition that the blockchain's nature forces upon you.

Read more