Zero to Hero in Foundry - Part 6: Time Travel & Events in Tests

Abhiram AAbhiram A
6 min read

Recap

What’s up, fellow chain-surfers! 🏄‍♂️

Welcome back to our grand tour of Foundry. So in Part 5 we looked into what Gas is and how it can be optimized to make our contracts user friendly. We wrote a gas-intensive version of an ether batch transfer contract and understood how we can get gas reports and snapshots. Then, we optimized the contract to remove gas intensive parts and used gas saving methods. Finally, we compared the gas used before and after and achieved ~50% reduction.

Please read it if you haven’t already before continuing

Foundry: Zero to Hero - Part 5

Wanna learn Web3 through live and interactive challenges? You’ll love and find that Web3Compass is the best!!

Today’s Outcome

Topic of focus: Time Travel & Events in Tests

Code base - Auction ↗️

We'll be dissecting a simple Auction.sol contract. But the real star of the show isn't the contract itself—it's how we're going to test it. We’re going to become time travelers, bending the blockchain's clock to our will with vm.warp(), and we'll develop advanced hearing to listen for our contract's whispers (aka events) using vm.expectEmit().

Ready to get your hands dirty? Let's dive in!


🧐 Meet Our Specimen: The Auction.sol Contract

First, let's get acquainted with our auction contract. It’s a minimal, no-frills smart contract for a single-item ETH auction.

Here’s our contract - Auction.sol 🚀

Auction State

The contract has several state variables to keep track of everything happening:

  • highestBidder (address) & highestBid (uint256): These do exactly what they say on the tin—they track who's currently winning and with how much.

  • auctionStartTime & auctionEndTime (uint256): These are immutable timestamps that define the auction's lifespan. Once set in the constructor, they can't be changed.

  • seller (address payable): The creator of the auction who will receive the final bid.

  • isCanceled & isFinalized (bool): Simple flags to track the auction's status.

  • bids (mapping): This is our refund ledger. When someone gets outbid, their bid amount is stored here, waiting for them to withdraw it. This is a crucial part of the "withdraw pattern".

Events & Errors

Our contract is a chatterbox! It emits events for key actions, making it easy for off-chain applications to track what's happening:

  • BidPlaced: Fired when a new highest bid is made.

  • Withdrawn: Fired when an outbid user withdraws their funds.

  • AuctionFinalized: Emitted when the seller finalizes the auction and claims the prize.

  • AuctionCanceled: Emitted if the seller cancels the auction.

It also has a bunch of custom errors to give clear feedback when someone tries to do something they shouldn't.

Core Functions

  • constructor(uint256 _endTime): When deploying, the seller specifies a duration for the auction. The constructor sets the auctionStartTime to the current block's timestamp and the auctionEndTime to block.timestamp + _endTime.

  • bid(): This is where the magic happens. Anyone can call this payable function to place a bid. It checks if the bid is higher than the current highestBid. If it is, it cleverly refunds the previous highest bidder by adding their bid to the bids mapping, then updates highestBidder and highestBid.

  • withdraw(): If you've been outbid, you call this function to get your ETH back. It's a pull-based system to prevent nasty re-entrancy bugs.

  • finalizeAuction(): Only the seller can call this, and only after the auctionEndTime has passed. It marks the auction as finalized and transfers the highestBid to the seller.

  • cancelAuction(): The seller has an escape hatch! They can cancel the auction, but only if no bids have been placed yet.


🧪 Putting It to the Test: Auction.t.sol

Alright, we know the contract. Now, let's see how we can test its every nook and corner with Foundry.

Here’s our test contract - Auction.t.sol 🚀

Our test setup (setUp() function) uses a handy address 0xBEEF as the owner and gives it 10 ETH using vm.deal(owner, 10 ether). We then use vm.prank(owner) to make sure the Auction contract is deployed from this address, setting it as the seller.

Now for the main event!

⏰ Bending Time with vm.warp()

Our contract has time-sensitive logic. The finalizeAuction() function should only work after auctionEndTime. The bid() function should only work before auctionEndTime. How do we test this without sitting around waiting for a day?

We don't! We use vm.warp(). This cheat-code is our time machine; it instantly sets the blockchain's block.timestamp to any value we want.

Let's look at a couple of tests.

First, how do we test that bidding is disabled after the auction ends? Simple:

/// @notice bidding after the auction end time should revert
function testAuctionEnded() public {
    vm.warp(auction.auctionEndTime()); // 1. Fast-forward time!
    vm.expectRevert(Auction.AuctionEnded.selector); // 2. Expect an error
    auction.bid{value: 1 ether}(); // 3. Try to bid
}

In this test, we instantly jump to the auctionEndTime using vm.warp(). At this exact moment, any attempt to bid should fail. We tell Forge to expect the AuctionEnded error, and then we try to bid. If the bid fails with that specific error, the test passes! 🎉

Similarly, to test the happy path for finalizing the auction, we need to be in the future:

function testFinalizeAuction() public {
    // ... users place bids ...

    vm.warp(auction.auctionEndTime()); // Jump to the end

    vm.expectEmit(true, true, true, true);
    emit AuctionFinalized(user2, 2 ether);

    vm.prank(owner);
    auction.finalizeAuction(); // Now, this should work!
}

Without vm.warp(), testing time-dependent logic would be a nightmare. With it, it's a piece of cake.


🎧 Listening for Events with vm.expectEmit()

State changes are great, but a well-behaved contract should also communicate its actions through events. How do we test that the right events are being emitted with the right data? With vm.expectEmit(), of course!

Think of it as putting a stethoscope on your contract. You tell Forge, "I expect to hear this specific sound," and then you trigger the action.

Let's see how we test the BidPlaced event:

/// @notice ensure BidPlaced event is emitted with correct values
function testBid_EmitsBidPlaced() public {
    vm.expectEmit(true, false, false, true); // 1. Set up our listener
    emit BidPlaced(address(this), 1 ether); // 2. Define the expected event

    auction.bid{value: 1 ether}(); // 3. Place the bid
}

Let's break down that vm.expectEmit line, as it can look a bit cryptic: vm.expectEmit(checkTopic1, checkTopic2, checkTopic3, checkData)

  • In Solidity events, parameters marked as indexed become topics. They are easier to search for. Non-indexed parameters are part of the data payload.

  • OurBidPlaced event is event BidPlaced(address indexed bidder, uint256 amount).

    • bidder is the first topic.

    • amount is the data.

  • So, vm.expectEmit(true, false, false, true) means:

    • checkTopic1 = true: Yes, please check the first topic (the bidder's address).

    • checkTopic2 = false: Ignore the second topic (there isn't one).

    • checkTopic3 = false: Ignore the third topic (also doesn't exist).

    • checkData = true: Yes, please check the event's data (the bid amount).

The very next line, emit BidPlaced(address(this), 1 ether);, tells Foundry exactly what to look for: an event where the bidder is the test contract (address(this)) and the amount is 1 ether. When auction.bid() is called, Foundry checks if the emitted event matches these expectations. If it does, we get a green light. ✅


🚀 Wrapping Up

And that's a wrap! We took a seemingly complex Auction contract and wrote powerful, precise tests for it. We saw how to:

  • Become a Time Lord with vm.warp() to test logic that depends on the passage of time.

  • Become a Super-Listener with vm.expectEmit() to ensure our contract is communicating correctly with the outside world.

  • Use other cheat-codes like vm.expectRevert, vm.prank, and vm.deal to create any test scenario we can imagine.

Testing doesn't have to be a chore. With tools like Foundry, it can be an incredibly powerful and, dare I say, fun part of the development process. Now go ahead, clone that repo, run forge test for yourself, and try breaking our contract! Happy forging!

0
Subscribe to my newsletter

Read articles from Abhiram A directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Abhiram A
Abhiram A