Setting up test environment for smart contracts
Project with mature codebase but no configured test environment — manual check of every change. New developer on team spends day to run first test. CI fell, nobody knows why. Testnet deployment instead of local — 10 minutes wait per iteration.
Test environment setup is one-time investment that pays back by second week of active development.
Framework choice for task
For Solidity projects now two real options: Foundry and Hardhat. They solve different tasks and often used together.
| Parameter | Foundry | Hardhat |
|---|---|---|
| Test language | Solidity | TypeScript/JavaScript |
| Speed | Very fast (revm on Rust) | Slower |
| Fuzz-testing | Built-in | Only via plugins |
| Mainnet fork | vm.createFork() |
--fork-url |
| Frontend integration | Harder | Easier (ethers.js) |
| Deploy scripts | Solidity scripts | TypeScript + ethers.js |
| Transaction debugging | forge debug |
console.log() in contract |
Our standard: Foundry for unit and fuzz tests, Hardhat for deploy scripts and frontend integration. Both configs coexist in one repo.
Project structure
project/
├── foundry.toml # Foundry config
├── hardhat.config.ts # Hardhat config
├── contracts/
│ ├── core/
│ └── interfaces/
├── test/
│ ├── unit/ # Foundry tests
│ ├── integration/ # Fork tests
│ └── invariant/ # Invariant tests
├── script/ # Foundry deploy scripts
├── deploy/ # Hardhat deploy scripts
└── fixtures/ # Shared fixtures
Local network
Anvil (included in Foundry) — local EVM node. Faster than Ganache, actively maintained. For development run in mainnet or testnet fork mode:
# Fork Ethereum mainnet with specific block (reproducibility)
anvil --fork-url $MAINNET_RPC --fork-block-number 19500000
# Fork with predefined accounts and balances
anvil --fork-url $MAINNET_RPC --accounts 10 --balance 10000
Fork testing — only way to check integration with Uniswap, Aave, Chainlink without testnet deployment. Transaction in local fork — instantly. On Sepolia — 12-15 seconds.
Mocks and fixtures
For test isolation use fixture hierarchy:
// BaseFixture.sol — common dependencies
abstract contract BaseFixture is Test {
MockERC20 token;
MockChainlinkOracle oracle;
function setUp() public virtual {
token = new MockERC20("Test", "TST", 18);
oracle = new MockChainlinkOracle(2000e8); // $2000 price
}
}
// ProtocolFixture.sol — deploy tested protocol
contract ProtocolFixture is BaseFixture {
Protocol protocol;
function setUp() public override {
super.setUp();
protocol = new Protocol(address(token), address(oracle));
}
}
Don't use vm.mockCall for main dependencies — fragile and doesn't check interface. Create full mock contracts with minimal implementation.
CI/CD configuration
GitHub Actions config for Foundry:
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Install Foundry
uses: foundry-rs/foundry-toolchain@v1
- name: Run unit tests
run: forge test --match-path "test/unit/*" -vvv
- name: Run integration tests
run: forge test --match-path "test/integration/*" --fork-url ${{ secrets.MAINNET_RPC }}
- name: Coverage check
run: forge coverage --min-line-coverage 80
Separate unit and integration tests — unit tests must work without RPC keys. Integration tests only on PR to main.
Testnet configuration
For real network testing set multi-network config in Hardhat:
networks: {
sepolia: {
url: process.env.SEPOLIA_RPC,
accounts: [process.env.DEPLOYER_KEY],
chainId: 11155111,
},
polygon_amoy: {
url: process.env.AMOY_RPC,
accounts: [process.env.DEPLOYER_KEY],
chainId: 80002,
},
}
Faucets for main testnets: Sepolia Faucet (Alchemy/Chainlink), Amoy Faucet (official Polygon).
Timeline
Basic setup (Foundry + Hardhat, Anvil, CI) — 1 working day. With custom mocks for specific protocol and fixtures — 1-2 days. For multi-chain projects (EVM + Solana) — 2-3 days.







