Recommended resources
The freely available resources this lesson will direct you to.
Hello! Welcome to your ninth lesson in the "Modern Development & Testing Toolchain" module.
Introduction
In our previous lessons, we've focused on testing contracts in isolation. You've learned to write unit tests for a contract's state, reverts, and events, and you've explored how to use fuzz testing to automatically discover edge cases in specific functions. These are fundamental skills for ensuring a single contract is robust.
However, real-world decentralized applications are rarely a single contract. They are complex systems of multiple, interconnected contracts that call each other, transfer tokens, and manage shared state. A bug in the interaction between two perfectly-tested contracts can be just as devastating as a bug within a single one.
This is where integration testing comes in. It's the process of verifying that all the pieces of your protocol work together as a cohesive whole.
Learning Outcome: By the end of this lesson, you will be able to write integration tests for multi-contract interactions within a single protocol.
Today, we will cover:
- The distinction between unit tests and integration tests.
- How to set up a complex test environment with multiple contracts using fixtures.
- Writing tests that verify interactions, such as token transfers between contracts.
- An introduction to mainnet forking for highly realistic integration testing.
1. Unit Tests vs. Integration Tests
Before we dive in, let's clarify the distinction between unit and integration tests. A unit test focuses on the smallest possible "unit" of code—typically a single function—in isolation. An integration test, on the other hand, verifies the behavior of multiple components working together.
This short video from Chainlink provides a clear, high-level explanation of this difference.
This video will help you solidify the conceptual difference between unit and integration tests in the context of smart contract development.
Please watch from 07:41 to 08:30. Focus on the definition of each test type and the examples given (e.g., integration tests involving other contracts or oracles).
In essence:
- Unit Test: Does
myContract.calculateReward()return the correct value for a given input? - Integration Test: When a user calls
stakingContract.stake(), does thetokenContractcorrectly transfer their tokens to thestakingContract, and is the user's new balance reflected properly?
2. Case Study: An Auction Protocol
The best way to understand integration testing is through a practical example. We'll use a simple auction protocol that involves three distinct smart contracts working in concert.
This system is an excellent case study because its core functions inherently rely on cross-contract communication.
The Complete Hardhat Testing Guide for Secure Smart ...
This article from Lee Marreros on Medium.com introduces a clear use case for integration testing: an auction process. Please read this first section to understand the system we'll be testing.
Read the section titled 'Testing Use Case: Auction process'. Focus on the 5-step auction flow and the description of the three interacting smart contracts: USDC token, Auction, and NFT.
As you've read, the protocol involves:
- An NFT contract (
BBitesNFT.sol) to mint the auction prize. - A USDC mock contract (
USDCMockCoin.sol) to handle bids. - The main Auction contract that orchestrates the entire process.
A user can't create an auction without the Auction contract pulling an NFT from the BBitesNFT contract. A bidder can't place a bid without the Auction contract pulling USDC from the USDCMockCoin contract. Testing any of these actions requires testing the integration of these contracts.
3. Setting Up the Integration Test Environment
To test a multi-contract system, your test setup must deploy and configure all the necessary contracts. This can become repetitive if done in every single test. Hardhat's fixtures are the standard solution to this problem. A fixture is a setup function that you define once and can then load in any test, giving you a clean, pre-configured state to work with.
The following reading demonstrates how to build a comprehensive fixture for our auction protocol. This is a critical pattern for effective integration testing.
The Complete Hardhat Testing Guide for Secure Smart ...
This part of the article details how to construct a fixture that prepares the entire auction ecosystem for testing. It's a dense but crucial section.
First, quickly scan the 'Smart Contracts' section to see the code for Auction.sol, USDCMockCoin.sol, and BBitesNFT.sol. You don't need to understand every line, just get a feel for them. Then, read the section '3. Fixture setting for the auction process' carefully. Pay close attention to the steps involved in the setUpSmartContracts function: Getting test accounts (signers). Deploying all three contracts (USDCMockCoin, Auction, BBitesNFT). Minting mock USDC and NFTs to different test accounts. Granting allowances from the test accounts to the Auction contract (a crucial step for transferFrom to work).
The fixture you just reviewed performs all the necessary setup to test the auction flow:
- It deploys the three core contracts.
- It funds bidder accounts (
alice,bob) with mock USDC. - It gives auctioneer accounts (
carl,dave) NFTs to auction. - It approves the
Auctioncontract to spend the bidders' USDC and the auctioneers' NFTs.
With this fixture, we can write clean tests that focus purely on the interaction logic, not the setup.
4. Writing an Integration Test
Now, let's write a test for a core piece of the interaction: placing a bid. This action involves the bidder, the USDCMockCoin contract, and the Auction contract.
We want to verify that when a user places a bid, the correct amount of USDC is transferred from their wallet to the Auction contract. Hardhat (with the hardhat-chai-matchers plugin) provides a powerful assertion, changeTokenBalances, specifically for this purpose.
The next reading shows exactly how to implement this test.
The Complete Hardhat Testing Guide for Secure Smart ...
This section demonstrates testing the placeBid function, including error cases and, most importantly, verifying the token transfer.
Read the section '7. Placing a bid testing: errors and token transfers'. Focus on subsection 'III. USDC tokens are transferred'. Notice how changeTokenBalances is used to assert that Alice's balance decreases and the Auction contract's balance increases by the bid amount in a single, expressive assertion.
The key piece of code for the integration test is:
it("USDC tokens are transferred", async () => {
const TEN_USDC = 10000000n; // Assumes 6 decimals for the mock USDC
var tx = await auction.connect(alice).placeBid(auctionId, TEN_USDC);
await expect(tx).to.changeTokenBalances(
usdc, // The ERC20 token contract instance
[alice.address, await auction.getAddress()], // Accounts to check
[-TEN_USDC, TEN_USDC] // Expected balance changes
);
});
This test elegantly verifies the core interaction:
- It simulates
alicecallingplaceBid. - It then asserts that this transaction caused the balance of
aliceto decrease by10 USDCand the balance of theauctioncontract to increase by10 USDC.
This is the essence of integration testing: verifying the effects of an action across the boundaries of multiple contracts.
5. Advanced Integration Testing with Mainnet Forking
So far, we've used a USDCMockCoin. This is great for isolated testing, but what if your protocol needs to interact with the real USDC contract, or with a complex DeFi protocol like Uniswap or Aave? Deploying all of their contracts to a local network would be impossible.
The solution is mainnet forking. Hardhat can start a local test node that is a snapshot of the actual Ethereum mainnet at a specific block. This gives you the entire state of the mainnet to test against, locally and instantly.
This video gives a concise overview of how to set up mainnet forking.
Fork Ethereum Mainnet Locally with Hardhat | Call Contract Methods on Wrapped Ether and CurveFi
This video from Blockman Codes demonstrates the basic setup for forking mainnet in your Hardhat configuration.
Watch from 01:11 to 06:36. Focus on: The changes needed in hardhat.config.js to enable forking (you'll need an RPC URL from a service like Alchemy or Infura). How to start the local forked node. How to write a script that gets a contract instance of a real, deployed mainnet contract (like Wrapped Ether) and calls its functions from your local test environment.
A key challenge with forking is that while you have the state of mainnet, you don't have the private keys for any accounts. How can you test a function that requires an account to have, say, 1 million USDC?
The answer is account impersonation. Hardhat lets you take control of any address on the forked chain and sign transactions on its behalf. This allows you to "become" a whale account and use its assets to test your protocol's interactions in a highly realistic environment.
The auction testing article provides an excellent walkthrough of this advanced technique.
The Complete Hardhat Testing Guide for Secure Smart ...
This final reading shows how to adapt the auction test setup to use a mainnet fork, replacing the mock contracts with real ones (USDC and Bored Ape Yacht Club) and using account impersonation.
Read the section '9. Test against Mainnet & account impersonation'. You don't need to implement this now, but focus on understanding the strategy: The test fixture is modified to detect if it's running against a fork (isLocalhost). If it is, instead of deploying mocks, it gets contract instances for the real USDC and BAYC contracts. It uses impersonateAccount to take control of a known USDC whale and an NFT whale. It makes the impersonated whales transfer real assets to the test accounts (alice, carl) within the local fork. This allows the exact same integration tests to run against a realistic mainnet state.
Mainnet forking is an incredibly powerful tool for late-stage integration testing, allowing you to verify how your system will behave with live, deployed protocols before you spend any real gas.
Conclusion
You've now moved beyond testing single contracts and have the tools to validate entire systems. This is a significant step towards building complex, production-ready dApps.
Key Takeaways:
- Integration tests verify that multiple smart contracts interact correctly as a system.
- Fixtures (
loadFixture) are essential for creating a clean, pre-configured test environment involving multiple contracts, accounts, and initial states. - Assertions like
changeTokenBalancesare purpose-built for verifying cross-contract interactions involving token transfers. - Mainnet forking provides the most realistic integration testing environment by allowing you to test your contracts against the live state of deployed protocols.
- Account impersonation is a key technique used with forking to control assets held by real accounts for testing purposes.
Next Steps:
Now that you've written unit, fuzz, and integration tests, a new question arises: how do you know if your tests are comprehensive? Have you tested all the functions and logical branches in your code? In our next and final lesson of this module, we will explore test coverage analysis to answer exactly that.