Smart Contract Testing: What Your Test Suite Is Missing

Every hacked protocol had tests. Every single one. The tests passed. The contracts were still vulnerable.
This is not because testing is useless. It is because most test suites test the wrong things. They verify that the system works when used correctly. Attackers do not use the system correctly.
We have scanned thousands of repositories through Odin Scan. The correlation between test quality and vulnerability count is direct: repositories with comprehensive adversarial tests have fewer findings. Not slightly fewer. Dramatically fewer.
Here are the specific gaps we see in almost every test suite.
Gap 1: No Tests for Access Control Bypass
The most common critical finding in smart contract audits is a missing access control check. A function that should be restricted is callable by anyone. The test suite calls it from the correct account and it works. Nobody tests what happens when the wrong account calls it.
// What teams write
function test_setFee() public {
vm.prank(owner);
vault.setFee(500);
assertEq(vault.fee(), 500);
}
// What teams skip
function test_setFee_reverts_nonOwner() public {
vm.prank(attacker);
vm.expectRevert("Ownable: caller is not the owner");
vault.setFee(500);
}
The rule: For every function with an access control modifier, write a test that calls it from an unauthorized address and asserts it reverts. This takes one minute per function and catches one of the most expensive vulnerability classes.
Gap 2: No Edge Case Inputs
Smart contracts handle money. Money has edge cases. Zero amounts, maximum uint256 values, amounts that cause rounding to zero, amounts just above or below thresholds.
// What teams write
function test_deposit() public {
vault.deposit(1 ether);
assertEq(vault.balanceOf(user), 1 ether);
}
// What teams skip
function test_deposit_zero() public {
vm.expectRevert("Zero amount");
vault.deposit(0);
}
function test_deposit_dust() public {
vault.deposit(1); // 1 wei
// Does the share calculation round to zero? Is that handled?
assertGt(vault.balanceOf(user), 0);
}
function test_deposit_max() public {
deal(address(token), user, type(uint256).max);
vm.prank(user);
// Does this overflow internally?
vault.deposit(type(uint256).max);
}
The dust deposit test is particularly important for vault-style contracts. If depositing 1 wei gives you 0 shares, that deposit is lost. If it gives you 1 share but the share is worth more than 1 wei due to existing deposits, you have a rounding exploit.
Gap 3: No Reentrancy Tests
If your contract makes external calls, you need a test that attempts reentrancy. Most test suites assume external calls behave normally. Attackers do not behave normally.
// Malicious contract for testing
contract ReentrantAttacker {
Vault target;
uint256 attackCount;
constructor(address _target) {
target = Vault(_target);
}
receive() external payable {
if (attackCount < 2) {
attackCount++;
target.withdraw(1 ether);
}
}
function attack() external {
target.withdraw(1 ether);
}
}
function test_withdraw_reentrancy() public {
ReentrantAttacker attacker = new ReentrantAttacker(address(vault));
deal(address(vault), 10 ether);
vault.deposit{value: 2 ether}();
vm.prank(address(attacker));
vm.expectRevert(); // Should revert due to nonReentrant
attacker.attack();
}
If you are using a nonReentrant modifier, the test verifies it works. If you are not, the test tells you whether you need one.
Gap 4: No Oracle Sanity Tests
Protocols that use price oracles almost never test what happens when the oracle returns an unexpected value. Zero. Negative. Stale. Extremely large. These are all real conditions that oracles can produce.
function test_oracle_returns_zero() public {
mockOracle.setPrice(0);
vm.expectRevert("Invalid price");
vault.getCollateralValue(user);
}
function test_oracle_returns_stale() public {
mockOracle.setUpdatedAt(block.timestamp - 25 hours);
vm.expectRevert("Stale price");
vault.getCollateralValue(user);
}
function test_oracle_returns_negative() public {
mockOracle.setPrice(-1);
vm.expectRevert("Invalid price");
vault.getCollateralValue(user);
}
function test_lst_oracle_sanity() public {
uint256 lstPrice = oracle.getPrice(CBETH);
uint256 ethPrice = oracle.getPrice(WETH);
// LST should be within 80-150% of ETH price
assertGt(lstPrice, ethPrice * 80 / 100);
assertLt(lstPrice, ethPrice * 150 / 100);
}
The last test would have caught the Moonwell oracle misconfiguration. One assertion. $1.78 million saved.
Gap 5: No State Transition Tests
Complex protocols have state machines: proposals go from Active to Queued to Executed. Loans go from Open to Liquidatable to Closed. Most test suites test individual states but not the transitions between them, especially invalid transitions.
function test_cannot_execute_before_queue() public {
uint256 proposalId = governor.propose(...);
// Skip voting, try to execute directly
vm.expectRevert("Proposal not queued");
governor.execute(proposalId);
}
function test_cannot_liquidate_healthy_position() public {
vault.deposit(user, 10 ether); // 200% collateral ratio
vm.expectRevert("Position healthy");
vault.liquidate(user);
}
function test_cannot_deposit_when_paused() public {
vm.prank(owner);
vault.pause();
vm.expectRevert("Paused");
vault.deposit(1 ether);
}
Invalid state transitions are where governance attacks, flash loan exploits, and timing attacks live. If your test suite does not try to break the state machine, attackers will.
Gap 6: No Invariant Tests
Unit tests check specific scenarios. Invariant tests check properties that must always hold, regardless of the sequence of actions. Foundry's invariant testing framework is one of the most underused tools in smart contract security.
// Handler contract that performs random valid actions
contract VaultHandler is Test {
Vault vault;
constructor(Vault _vault) { vault = _vault; }
function deposit(uint256 amount) public {
amount = bound(amount, 1, 100 ether);
deal(address(this), amount);
vault.deposit{value: amount}();
}
function withdraw(uint256 amount) public {
uint256 balance = vault.balanceOf(address(this));
if (balance == 0) return;
amount = bound(amount, 1, balance);
vault.withdraw(amount);
}
}
// Invariant: vault ETH balance always matches total deposits minus withdrawals
function invariant_solvency() public {
assertGe(
address(vault).balance,
vault.totalDeposits() - vault.totalWithdrawals()
);
}
// Invariant: no individual balance exceeds total supply
function invariant_no_balance_exceeds_total() public {
assertLe(vault.balanceOf(actor), vault.totalSupply());
}
Invariant tests find bugs that no human would think to write a unit test for. They explore random sequences of actions and check that core properties hold after every sequence. Run them with enough depth:
forge test --match-test invariant -vvv --fuzz-runs 10000
Gap 7: No Integration Tests Against Forked State
Unit tests with mocks verify your logic in isolation. They do not verify that your contract behaves correctly against the actual state of mainnet. A mock Chainlink oracle always returns exactly what you tell it to. The real one might behave differently.
// Fork mainnet and test against real state
function setUp() public {
vm.createSelectFork("mainnet", 19000000);
}
function test_swap_against_real_uniswap() public {
// Uses actual Uniswap V3 pool state
uint256 amountOut = router.exactInputSingle(
ISwapRouter.ExactInputSingleParams({
tokenIn: WETH,
tokenOut: USDC,
fee: 3000,
recipient: address(this),
amountIn: 1 ether,
amountOutMinimum: 1800e6, // Realistic slippage bound
sqrtPriceLimitX96: 0
})
);
assertGt(amountOut, 1800e6);
}
Fork tests catch integration issues that mocks hide: unexpected reverts from external protocols, gas limits on complex interactions, state assumptions that do not hold on mainnet.
Gap 8: No Tests for Governance and Deployment Scripts
This is the gap that causes the most expensive bugs. Governance proposals, deployment scripts, and migration scripts are code. They configure parameters, set oracle addresses, grant roles, and initialize state. They are almost never tested.
function test_governance_proposal_oracle_config() public {
// Fork mainnet
vm.createSelectFork("mainnet");
// Execute the governance proposal
executeProposal(proposalId);
// Verify every oracle returns a sane price
address[] memory assets = comptroller.getAllMarkets();
for (uint i = 0; i < assets.length; i++) {
uint256 price = oracle.getUnderlyingPrice(assets[i]);
assertGt(price, 1e8, "Price too low"); // > $1
assertLt(price, 1e14, "Price too high"); // < $1M
}
}
The Moonwell hack was a governance proposal bug. The oracle misconfiguration lived in a proposal script, not a core contract. If the proposal had been tested against forked state with price sanity assertions, the bug would have been caught before execution.
The Testing Checklist
For every external function in your contract:
- Happy path test: normal usage works correctly
- Access control test: unauthorized caller reverts
- Zero input test: zero amounts, empty addresses handled
- Boundary test: min and max values do not overflow or underflow
- Reentrancy test: malicious callback does not drain funds
For every oracle integration:
- Zero price test: reverts on zero
- Stale price test: reverts on stale data
- Sanity range test: price falls within expected bounds
For the system as a whole:
- Invariant tests: core properties hold after random action sequences
- Fork tests: integration works against real mainnet state
- Governance/deployment tests: proposals produce correct post-execution state
Odin Scan catches the vulnerabilities your test suite misses. It runs on every PR as part of your CI/CD pipeline, checking for access control gaps, oracle misconfigurations, unsafe arithmetic, and dozens of other patterns automatically. Start your free trial.