Smart Contract Testing (Unit Tests)
A contract without tests is a contract waiting for an auditor to find what you already know. We've seen projects going to audit with 20% coverage and getting back a 40-page report. Most of those findings would have been caught by a basic test suite.
But coverage itself isn't the goal. 100% line coverage with zero branch coverage — this is an illusion of security. Real story: a token contract with tests for transfer and mint, but no test for transfer(address(0), amount). On mainnet deployment after three days — a bug with token loss. The line was covered, the branch wasn't.
Why Foundry Ousted Hardhat for Unit Tests
Two years ago, most projects wrote tests in JavaScript/TypeScript via Hardhat + Chai. It worked. But Foundry changed the standard.
Speed. Foundry compiles and runs tests natively via EVM implementation in Rust (revm). A test suite of 200 tests — 4-8 seconds vs 45-90 seconds on Hardhat. For TDD, this is fundamental.
Fuzz testing out of the box. Any function with parameters becomes a fuzz test:
function testFuzz_transfer(address to, uint256 amount) public {
vm.assume(to != address(0));
vm.assume(amount <= token.balanceOf(alice));
uint256 balanceBefore = token.balanceOf(to);
vm.prank(alice);
token.transfer(to, amount);
assertEq(token.balanceOf(to), balanceBefore + amount);
}
Foundry runs this test 256 times (default, configurable) with different values. In our practice, fuzz tests caught edge cases — particularly, reward calculation overflow — that manual tests missed.
Cheatcodes. vm.prank, vm.warp, vm.roll, vm.deal — manipulating EVM state directly in Solidity tests. No need to wrap everything in JavaScript promises.
Test Suite Architecture
What to Test First
We don't start with happy path. We start with invariants: what should never break, regardless of call order.
For an ERC-20 token, invariants: totalSupply == sum(balances), balanceOf(address(0)) == 0, allowance after approve == specified value. For a staking contract: totalStaked == sum(userStakes), rewards(user) >= 0.
Invariant tests in Foundry (forge test --match-test invariant) run sequences of random calls and check that invariants hold. This is more powerful than unit tests: it finds violations that occur only with a specific sequence of transactions.
Test File Structure
contract TokenTest is Test {
Token token;
address alice = makeAddr("alice");
address bob = makeAddr("bob");
function setUp() public {
token = new Token("Test", "TST", 1_000_000e18);
deal(address(token), alice, 1000e18);
}
// Unit: specific scenario
function test_transfer_reducesBalance() public {
vm.prank(alice);
token.transfer(bob, 100e18);
assertEq(token.balanceOf(alice), 900e18);
assertEq(token.balanceOf(bob), 100e18);
}
// Edge case
function test_transfer_revertsOnInsufficientBalance() public {
vm.prank(alice);
vm.expectRevert();
token.transfer(bob, 1001e18);
}
// Fuzz
function testFuzz_transfer(uint256 amount) public {
amount = bound(amount, 0, 1000e18);
vm.prank(alice);
token.transfer(bob, amount);
assertEq(token.balanceOf(alice) + token.balanceOf(bob), 1000e18);
}
}
Mocks and Fork Tests
To isolate unit tests from external contracts, we use mock contracts — minimal implementations of interfaces. Not vm.mockCall (it's brittle), but separate contracts like MockERC20, MockChainlinkOracle.
For tests that depend on real state of protocols (Uniswap V3 pool, Aave lending pool), we fork mainnet via vm.createFork(rpcUrl). This is not a unit test — this is an integration test, and it's slower. We separate them into different directories (test/unit/, test/integration/) and run them in CI separately.
Coverage Metrics and What They Mean
forge coverage outputs line, branch, statement and function coverage. We're interested first and foremost in branch coverage: every condition should be tested in both states.
Real coverage goals:
| Contract Type | Line Coverage | Branch Coverage |
|---|---|---|
| Critical (vault, bridge) | 95%+ | 85%+ |
| DeFi (lending, AMM) | 90%+ | 80%+ |
| Helpers (utils, helpers) | 80%+ | 70%+ |
| View-only contracts | 75%+ | 60%+ |
Getting 100% Solidity coverage is difficult — some branches for protective checks require violating EVM invariants, which is impossible in a test. But 85% branch coverage is achievable and sufficient for audit.
Process and Timelines
We write tests in parallel with development, not after. Typical volume: for every 100 lines of contract — 150-300 lines of tests. For a contract of complexity level 2 (staking, vesting, simple AMM) — 2-3 business days for a complete test suite with fuzzing.
Before submission, we run forge test -vvv and forge coverage. A coverage report goes along with the code. If coverage drops below thresholds — we figure out why before deployment, not after.







