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:
- Submitted — transaction is broadcast to the network, enters the mempool
- Pending — transaction is in the mempool but not yet included in a block
- Included — transaction is in a block, but the block may not be final
- Confirmed — transaction has N blocks on top of it (N depends on the chain and risk tolerance)
- Failed — transaction reverted on-chain (block included it but execution failed)
- Dropped — transaction was removed from the mempool without being included (low gas, nonce replacement)
- 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.