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


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 theauctionStartTime
to the current block's timestamp and theauctionEndTime
toblock.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 currenthighestBid
. If it is, it cleverly refunds the previous highest bidder by adding their bid to thebids
mapping, then updateshighestBidder
andhighestBid
.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 theauctionEndTime
has passed. It marks the auction as finalized and transfers thehighestBid
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.Our
BidPlaced
event isevent 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
, andvm.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!
Subscribe to my newsletter
Read articles from Abhiram A directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
