Testing Smart Contracts: Unit Tests, Fuzzing, and Invariants Explained
Testing Smart Contracts is not about chasing 100 percent coverage. It is about proving that the things users rely on never break, even when inputs get weird, transactions get reordered, and attackers intentionally look for edge cases. This guide walks through unit tests, fuzzing, and invariants in a practical, safety-first workflow that you can reuse on every protocol, from small token contracts to complex systems with auctions, governance, and multi-step state machines.
TL;DR
- Unit tests prove specific behaviors with known inputs. They are fast and readable, but they miss unknown unknowns.
- Fuzzing throws huge input ranges at your contract to find unexpected states. It is how you catch corner cases that nobody thinks to write down.
- Invariants encode what must always be true, no matter what sequence of actions happens. They turn security assumptions into continuously checked rules.
- Most expensive bugs happen across functions, across time, and across actors. Invariants and stateful fuzzing are built for that reality.
- Prerequisite reading: Solidity Security Basics and Commit-Reveal Schemes.
- For deeper protocol engineering patterns and testing strategy in the context of advanced systems, keep Blockchain Advance Guides in your rotation.
If you want your tests to actually protect users, you need a threat model, and you need at least one real example of a two-phase protocol that fails when timing and state transitions are wrong. Read Solidity Security Basics for the common failure modes, then scan Commit-Reveal Schemes to see how multi-step logic creates subtle bugs. This article assumes you have those mental models.
Why testing smart contracts feels harder than normal software
In most software, a bug can be fixed and redeployed quietly. In smart contracts, bugs become public incidents. Funds get stuck, liquidity evaporates, governance gets captured, and a “minor” arithmetic mistake can become a permanent exploit. So the standard idea of testing, run some examples, ship it, does not map to reality.
Smart contract testing is hard for three reasons that interact:
- The environment is adversarial. Attackers do not use your UI. They call functions directly, in strange sequences, with weird data, at weird times.
- State is long-lived. In a typical protocol, the interesting bugs show up after many actions: deposit, borrow, repay, liquidate, rebase, distribute, claim, upgrade, settle.
- Ordering matters. Block-level ordering and mempool visibility create a layer of “game” logic: sandwiching, copying calldata, reordering transactions, last-revealer effects.
A good test strategy does not simply “cover functions.” It covers the contract’s guarantees under adversarial behavior. That is why modern smart contract testing stacks are built around three pillars: unit tests, fuzzing, and invariants.
What you should be trying to prove with tests
The highest-leverage shift you can make is to stop thinking about tests as “examples” and start thinking about tests as “proofs of guarantees.” A guarantee is a sentence that a user would rely on when deciding to deposit funds or sign a transaction.
Examples of real guarantees:
- If I deposit token A, my balance increases by exactly the amount deposited, minus explicit fees.
- No one except the owner can change critical parameters, and even the owner cannot exceed sane bounds.
- Total shares and total assets remain consistent: shares do not magically appear, and assets are not created from nothing.
- Withdrawals cannot exceed available liquidity and cannot steal from other users.
- After a settlement is finalized, it cannot be finalized again with a different outcome.
- In a commit-reveal flow, a reveal can only match a prior commit from the same identity, within the correct window.
Your test suite should encode these guarantees, then hammer them with enough input diversity and action diversity that you actually believe the result. That is why unit tests alone are insufficient for most protocols.
Unit tests: the foundation, not the finish line
Unit tests are the easiest to understand: given a known starting state and known inputs, the contract should produce known outputs. They are fast and they act like documentation. If your unit tests are readable, a new engineer can learn your system by reading your tests.
But unit tests have a blind spot: they only cover the cases you already imagined. Attackers are paid to imagine the cases you did not. So the goal with unit tests is not to “test everything.” The goal is to lock down the most important mechanics and the most fragile boundaries, then let fuzzing and invariants explore the rest.
What unit tests should focus on
- Math and accounting. Share minting, fee calculations, rounding direction, slippage bounds, debt updates.
- Access control and roles. Who can call what, and what happens when unauthorized users try.
- State machine gates. If a function is only valid in phase X, it should revert in other phases.
- Events. Events are often how off-chain indexers and dashboards understand the system.
- Edge boundaries. Zero amounts, max amounts, near-overflow values, exact deadlines, off-by-one timestamps.
A concrete unit test example (Foundry style)
Foundry is popular because tests are written in Solidity and run fast. Whether you use Foundry, Hardhat, or another tool, the principles are the same: arrange, act, assert. The key is to test the behavior that matters, not to decorate the codebase with shallow tests.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
interface IERC20 {
function balanceOf(address) external view returns (uint256);
function transfer(address, uint256) external returns (bool);
function approve(address, uint256) external returns (bool);
}
contract SimpleVault {
IERC20 public immutable asset;
uint256 public totalShares;
mapping(address => uint256) public shares;
constructor(IERC20 _asset){ asset = _asset; }
function deposit(uint256 amount) external {
require(amount > 0, "amount=0");
// 1:1 shares for demo
totalShares += amount;
shares[msg.sender] += amount;
require(asset.transfer(address(this), amount), "transfer failed");
}
function withdraw(uint256 amount) external {
require(amount > 0, "amount=0");
require(shares[msg.sender] >= amount, "insufficient shares");
shares[msg.sender] -= amount;
totalShares -= amount;
require(asset.transfer(msg.sender, amount), "transfer failed");
}
}
contract VaultUnitTest is Test {
IERC20 token;
SimpleVault vault;
address alice = address(0xA11CE);
function setUp() public {
// In a real test, deploy a mock token. For brevity assume token exists and is funded.
// token = new MockERC20("Mock","MOCK",18);
// vault = new SimpleVault(token);
}
function testDepositRevertsOnZero() public {
vm.prank(alice);
vm.expectRevert(bytes("amount=0"));
vault.deposit(0);
}
function testWithdrawRevertsWhenInsufficient() public {
vm.prank(alice);
vm.expectRevert(bytes("insufficient shares"));
vault.withdraw(1);
}
function testDepositUpdatesSharesAndTotal() public {
uint256 amount = 100e18;
// Arrange: fund alice and approve
// deal(address(token), alice, amount);
vm.startPrank(alice);
token.approve(address(vault), amount);
// Act
vault.deposit(amount);
// Assert
assertEq(vault.totalShares(), amount);
assertEq(vault.shares(alice), amount);
vm.stopPrank();
}
}
This example is intentionally simple. In a real vault, share price changes, fees exist, and accounting is more complex. But the pattern stays the same: pick the critical invariant-like facts that must hold after each action, then assert them explicitly.
A smell test for weak unit tests
If a unit test would still pass even if an attacker calls functions in a different order, it might be fine, or it might be shallow. The most common shallow pattern is to test only “happy paths” while ignoring:
- Unauthorized callers
- Unusual but valid ranges (very large amounts, tiny amounts)
- Boundary timing (exactly at deadline, one second before, one second after)
- State transitions (calling finalize twice, calling settle before reveal window, calling withdraw before deposit)
- Cross-user interference (Bob’s action affecting Alice’s balance)
Shallow tests are worse than no tests because they give false confidence. That is why you layer fuzzing and invariants.
Fuzzing: the fastest way to find what you forgot to imagine
Fuzzing means running the same test many times with randomized inputs. A fuzz test is not “random chaos.” A fuzz test is a structured question: for all inputs in a range, does this property hold.
There are two main kinds of fuzzing you should understand:
- Stateless fuzzing. One function call with random inputs, starting from a controlled state.
- Stateful fuzzing. Random sequences of actions across multiple functions and actors, exploring the state machine.
Stateless fuzzing catches boundary failures. Stateful fuzzing catches protocol failures. Most real exploits are protocol failures.
What fuzzing finds that unit tests miss
Unit tests tend to cover “expected” user behavior. Fuzzing covers the rest:
- Edge arithmetic. Amounts around zero, around max uint, around rounding boundaries.
- Unexpected revert paths. A function that should never revert for valid inputs does revert.
- Invariant drift. A value that should remain stable slowly drifts under repeated actions.
- Cross-function effects. Calling a function in a weird order bypasses checks or creates impossible states.
- Assumption breakage. The code assumes an external token behaves nicely, but a strange token breaks it.
A concrete fuzz test example (Foundry)
Foundry supports fuzzing by automatically generating inputs for test functions. You can add constraints using assumptions so the fuzzer focuses on meaningful ranges. The trick is to fuzz properties, not specific outcomes.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
interface IERC20 {
function balanceOf(address) external view returns (uint256);
function approve(address, uint256) external returns (bool);
function transfer(address, uint256) external returns (bool);
}
contract VaultLike {
IERC20 public asset;
mapping(address => uint256) public shares;
uint256 public totalShares;
constructor(IERC20 _asset){ asset = _asset; }
function deposit(uint256 amount) external {
require(amount > 0, "amount=0");
totalShares += amount;
shares[msg.sender] += amount;
require(asset.transfer(address(this), amount), "xfer");
}
function withdraw(uint256 amount) external {
require(amount > 0, "amount=0");
require(shares[msg.sender] >= amount, "insufficient");
shares[msg.sender] -= amount;
totalShares -= amount;
require(asset.transfer(msg.sender, amount), "xfer");
}
}
contract VaultFuzzTest is Test {
IERC20 token;
VaultLike vault;
address alice = address(0xA11CE);
function setUp() public {
// token = new MockERC20("Mock","MOCK",18);
// vault = new VaultLike(token);
}
function testFuzz_DepositThenWithdrawSameAmount(uint96 rawAmount) public {
uint256 amount = uint256(rawAmount);
// Constrain to meaningful range
vm.assume(amount > 0);
vm.assume(amount <= 1_000_000e18);
// Fund alice and approve
// deal(address(token), alice, amount);
vm.startPrank(alice);
token.approve(address(vault), amount);
uint256 before = token.balanceOf(alice);
vault.deposit(amount);
vault.withdraw(amount);
uint256 afterBal = token.balanceOf(alice);
// Property: roundtrip should restore balance (in this simplified vault)
assertEq(afterBal, before);
// Property: shares should be zero
assertEq(vault.shares(alice), 0);
// Property: totalShares should be zero
assertEq(vault.totalShares(), 0);
vm.stopPrank();
}
}
The fuzz test above checks a property: deposit then withdraw same amount restores balances. In a real vault, there may be fees, so the property changes to: withdraw returns expected amount and accounting remains consistent. The bigger point is that fuzzing forces you to write down the property clearly, then it tries many sizes of amount.
Fuzzing red flags that quietly waste your time
Fuzzing is powerful, but it is easy to misuse. Here are the common ways teams end up with “fuzzing” that does not actually explore meaningful states:
- Over-assuming. If you constrain inputs too aggressively, you remove the interesting edges.
- Only fuzzing one function. Stateless fuzzing alone misses most protocol-level failures.
- No shrinking signal. When a fuzz failure happens, you want the tool to shrink to a minimal failing case. If your assertions are vague, shrink is less useful.
- Ignoring actor diversity. Many exploits require multiple addresses. Fuzz only as msg.sender and you miss cross-user bugs.
- Not modeling weird tokens. External tokens can reenter, return false, take fees, or have non-standard decimals. If your tests assume a perfect ERC20, you miss integration failures.
Fuzzing works best when you combine it with invariants, because invariants give the fuzzer a clear target: find any sequence that breaks the guarantee.
Invariants: the tool that matches how protocols get exploited
An invariant is a statement that must always be true. It is not about a single call. It is about every call, every sequence, every actor. Invariants are the closest thing smart contract testing has to “continuous proof.”
Think about the class of exploits that show up in postmortems: reentrancy that bypasses an internal check, accounting drift over multiple actions, state machines that can be finalized twice, rewards that can be claimed without earning them, collateral that can be withdrawn while debt remains. These bugs live across time, across functions, and across actors. Invariants are built to detect that.
The most useful invariant categories
You can write invariants for almost anything, but the highest value ones usually fall into a few categories:
- Conservation. Total assets, total shares, total debt, total supply, escrowed balances do not magically increase or decrease beyond explicit rules.
- Authorization. Privileged actions cannot happen without the right role, even through weird sequences.
- Bounds. Parameters remain within defined ranges, and transitions cannot skip phases.
- Monotonicity. Some values only move in one direction, like nonce counters, epoch ids, or finalized flags.
- Relationship integrity. If A implies B, that implication always holds. Example: if “finalized = true” then “settlementRoot != 0”.
A practical invariant example (stateful fuzz)
With Foundry’s invariant testing, you build a “handler” that performs randomized actions. The fuzzer calls handler methods in random order, and after many steps, it checks your invariant functions. The handler is where you model realistic and adversarial behaviors: different actors, weird orderings, partial amounts, repeated calls.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
interface IERC20 {
function balanceOf(address) external view returns (uint256);
function approve(address, uint256) external returns (bool);
function transfer(address, uint256) external returns (bool);
}
contract Vault {
IERC20 public asset;
uint256 public totalShares;
mapping(address => uint256) public shares;
constructor(IERC20 _asset){ asset = _asset; }
function deposit(uint256 amount) external {
require(amount > 0, "amount=0");
totalShares += amount;
shares[msg.sender] += amount;
require(asset.transfer(address(this), amount), "xfer");
}
function withdraw(uint256 amount) external {
require(amount > 0, "amount=0");
require(shares[msg.sender] >= amount, "insufficient");
shares[msg.sender] -= amount;
totalShares -= amount;
require(asset.transfer(msg.sender, amount), "xfer");
}
}
contract Handler {
Vault public vault;
IERC20 public token;
address[] public actors;
constructor(Vault _vault, IERC20 _token, address[] memory _actors){
vault = _vault;
token = _token;
actors = _actors;
}
function _actor(uint256 seed) internal view returns (address) {
return actors[seed % actors.length];
}
function doDeposit(uint256 seed, uint96 rawAmount) external {
address a = _actor(seed);
uint256 amount = uint256(rawAmount);
// Constrain to something realistic
if (amount == 0) return;
if (amount > 1_000_000e18) amount = 1_000_000e18;
// In real tests you would fund actors using deal() for a mock token
// and set approvals. Shown as conceptual.
vm.startPrank(a);
token.approve(address(vault), amount);
vault.deposit(amount);
vm.stopPrank();
}
function doWithdraw(uint256 seed, uint96 rawAmount) external {
address a = _actor(seed);
uint256 amount = uint256(rawAmount);
if (amount == 0) return;
// Withdraw up to current shares to avoid constant reverts
uint256 s = vault.shares(a);
if (s == 0) return;
amount = amount % (s + 1);
if (amount == 0) return;
vm.prank(a);
vault.withdraw(amount);
}
}
contract VaultInvariantTest is Test {
IERC20 token;
Vault vault;
Handler handler;
address alice = address(0xA11CE);
address bob = address(0xB0B);
address carol = address(0xCAro1);
function setUp() public {
// token = new MockERC20("Mock","MOCK",18);
// vault = new Vault(token);
address;
actors[0] = alice; actors[1] = bob; actors[2] = carol;
handler = new Handler(vault, token, actors);
// Tell Foundry to fuzz-call handler methods
targetContract(address(handler));
targetSelector(FuzzSelector({ addr: address(handler), selectors: _selectors() }));
}
function _selectors() internal pure returns (bytes4[] memory sels) {
sels = new bytes4;
sels[0] = Handler.doDeposit.selector;
sels[1] = Handler.doWithdraw.selector;
}
/// Invariant: totalShares equals sum of user shares (simplified example).
function invariant_TotalSharesMatchesSum() public view {
uint256 sum = vault.shares(alice) + vault.shares(bob) + vault.shares(carol);
assertEq(vault.totalShares(), sum);
}
}
The invariant above is simple, but it illustrates the mindset: write down the relationship that must always hold, then let the fuzzer try to break it. In real systems, invariants may include conservation of assets, limits on parameter changes, debt and collateral relationships, and monotonic settlement states.
Why invariants beat raw coverage metrics
Coverage metrics are tempting because they are numeric. But 95 percent coverage can still miss the exploitable path if the missing 5 percent is the one sequence that matters. Invariants do not care about line coverage. They care about whether the protocol’s core guarantees can ever be broken.
A useful mental model is: if you had to explain to a user why funds are safe, you would not list file coverage. You would list guarantees. Invariants are how you turn those guarantees into executable checks.
Risk signals that your test strategy is not security-grade yet
These signals show up again and again in audits and incident writeups. If you see them in your own project, treat them as “testing debt” that must be paid.
- Only happy-path unit tests exist. No adversarial tests, no boundary tests, no revert expectation tests.
- No multi-actor modeling. Everything is tested as a single address, so cross-user attacks are invisible.
- No time modeling. Deadlines, epochs, and timelocks exist, but tests never warp time and never test boundary timestamps.
- No weird-token modeling. Protocol integrates ERC20, but tests assume a perfect token with no fees, no callbacks, no odd decimals.
- No stateful sequences. Deposit and withdraw are tested separately, but not in long random sequences with interleaved actions.
- No invariants. There is no executable statement of what must always remain true.
If your protocol includes any two-phase or time-gated logic, like commit-reveal, auctions, vesting, rewards epochs, or governance timelocks, you should treat invariant testing as non-optional.
A safety-first workflow you can reuse on every contract
A strong testing workflow is not about a specific framework. It is a sequence of steps that forces you to encode security assumptions early, then stress them with tools that match how attackers behave.
Step 1: map assets, trust boundaries, and failure costs
Before tests, you need to know what matters. Identify:
- What assets are held or controlled by the contract: tokens, ETH, debt positions, NFTs, admin rights.
- What external dependencies exist: tokens, oracles, routers, bridges, signature verifiers, randomness sources.
- What failure means: fund loss, fund lock, unfair outcome, governance takeover, user griefing, permanent inflation.
This step is where you decide what to treat as invariant. If you cannot explain failure cost, you cannot prioritize tests.
Step 2: write your guarantees as sentences
Write 10 to 25 guarantee statements. They should be simple enough that a non-engineer could understand them, and precise enough that you can test them. Examples:
- Only authorized roles can change fee rate, and the fee rate cannot exceed a max.
- A user’s withdraw cannot reduce another user’s balance.
- Protocol total assets equals sum of balances in vault and strategy, minus explicit fees.
- Finalization can only happen once per round, and after finalization, outcome cannot change.
Step 3: convert guarantees into unit tests where possible
Convert the crisp, local guarantees into unit tests first. Unit tests lock down expected behavior and give you fast feedback loops. This is where you test: access control, revert reasons, event emissions, and critical math functions.
Step 4: fuzz boundaries and arithmetic edges
Add fuzz tests for the same mechanics you unit-tested. Fuzz tests help you catch cases like:
- Rounding direction flips under certain amount ranges
- Division by zero paths that “should be impossible”
- Parameter limits that were misapplied (off-by-one bounds)
- Revert patterns that lock funds for small deposits
Step 5: write invariants for system-level safety
Turn the highest-cost guarantees into invariants. A useful heuristic is: if breaking the guarantee means losing money, minting money, or permanently locking money, make it an invariant.
Invariants are not just “accounting equals.” They can capture governance constraints, upgrade constraints, and two-phase correctness. If you implement commit-reveal flows, invariants can enforce:
- Reveals cannot happen without a prior commit
- Reveal window rules are enforced
- A reveal cannot be credited to a different identity
- Finalized rounds cannot be finalized again
Step 6: model multiple actors and adversarial sequences
Build a handler with multiple addresses and give it actions that reflect both normal use and attacker behavior. Your handler should include:
- Actions that call functions with partial amounts and repeated calls
- Actions that attempt invalid state transitions
- Actions that interleave user operations with admin operations, if admins exist
- Actions that warp time across epochs and deadlines
Step 7: triage failures like a security engineer, not like a debugger
When a fuzz or invariant test fails, your first instinct should not be to “fix the test.” Your first instinct should be: what guarantee did we violate, and how would an attacker reproduce this. Then:
- Minimize the failing sequence
- Convert it into a deterministic regression test
- Patch the code, rerun the full fuzz/invariant suite
- Ask if the bug reveals a broader missing invariant
Reusable pre-ship checklist
- Every critical role and admin pathway is unit-tested and has bounds tests.
- Boundary timing is tested: before deadline, at deadline, after deadline.
- At least one fuzz test exists for each major accounting pathway.
- System-level invariants exist for conservation and phase correctness.
- Multi-actor sequences are modeled with stateful fuzzing.
- Every found failure became a deterministic regression test.
Tools that make this workflow practical
You can implement the strategy with different tooling, but the ecosystem has matured. A practical stack usually includes:
- Foundry for fast Solidity tests, fuzzing, and invariants.
- Hardhat or a similar JavaScript toolchain for integration tests, scripts, and local network orchestration.
- Static analysis tools for quick pattern detection and baseline linting.
- CI runners that can handle heavy fuzzing loads without slowing down developer machines.
Heavy fuzzing can be compute-intensive. If you want to run longer fuzz sessions in CI without eating local resources, a compute runner can help. Some teams run extended fuzzing jobs on dedicated compute and keep shorter runs in PR checks. If you are experimenting with longer fuzz campaigns and need scalable compute, a service like Runpod can be a practical way to spin up repeatable environments.
For end-to-end security, you also want to test signing workflows and transaction construction. Users do not interact with contracts directly, they sign transactions through wallets. In some teams, hardware wallets like Ledger are used in staging to test real signing flows, address derivation assumptions, and multi-sig processes. That does not replace contract testing, but it catches operational risks that unit tests cannot see.
Protocol testing patterns that catch real exploits
The difference between “tests that pass” and “tests that protect users” is pattern awareness. Below are high-value patterns to incorporate, especially if your protocol has multiple modules.
Pattern 1: time-warp tests for every deadline and epoch
If your contract uses time at all, you should test the boundary explicitly. Typical boundary bugs:
- Off-by-one errors: a function is callable one second longer than expected
- State transitions unlock early due to a wrong comparison operator
- Epoch increments drift because time calculations are rounded incorrectly
- Users get stuck because a phase never transitions
Testing with time warp is not fancy. It is essential. Two-phase flows like commit-reveal are especially timing-sensitive.
Pattern 2: always include at least three actors
Many attacks require a second actor: someone to front run, someone to back run, someone to manipulate a shared pool, someone to steal a reveal. A lot of bugs disappear when you test only as a single msg.sender.
A good default is:
- Alice: normal user
- Bob: another normal user
- Mallory: adversary who tries weird sequences and copied data
You do not need a huge cast. You need enough diversity to see cross-user interference.
Pattern 3: test with “weird tokens”
Integrations are where protocols fail. Your contract might be correct when the token behaves perfectly, but fail when:
- The token takes a fee on transfer
- The token returns false instead of reverting
- The token has fewer decimals
- The token calls back into your contract (reentrancy-like behavior)
A practical approach is to include mock tokens with unusual behavior in tests, then verify invariants still hold. This is how you catch the “works on standard ERC20, breaks on real tokens” class of incidents.
Pattern 4: sequence tests that try to bypass checks
Reentrancy is not only “call withdraw inside withdraw.” It can be more subtle: internal state updates in the wrong order, hooks in external calls, or callback patterns. Even if you use a reentrancy guard, you still want tests that attempt to reenter through alternate function paths.
Pattern 5: mempool copying simulations for critical flows
Some flows are stealable if you do not bind identity correctly. Commit-reveal is a classic case: if a reveal is not bound to the original committer, a bot can copy calldata and steal the credit. Auction settlements, permit-based claims, and signature-based mints can have similar issues.
You can simulate this in tests by having Mallory submit the same parameters and asserting the system rejects it or credits the correct party.
Practical examples you should adapt into your own suite
This section gives you “drop-in ideas” for tests, fuzz properties, and invariants that show up across many real contracts. You do not need to implement all of them. Start with what matches your design.
Example: parameter bounds and access control
Admin risk is one of the biggest sources of user harm. Even when there is no exploit, a protocol can become unsafe if parameters can be changed beyond sane ranges. Your tests should ensure:
- Unauthorized users cannot change parameters
- Authorized users cannot exceed max values
- Parameter changes emit events
- Parameter changes do not break invariants
Example: conservation invariants
Conservation invariants vary by protocol, but the logic is consistent. If the protocol issues shares, the relationship between shares and assets must remain coherent. If it tracks debt, debt and collateral must remain coherent.
A classic invariant shape:
- Total shares equals sum of user shares
- Total assets equals contract asset balance plus strategy balance (if any)
- User withdraw returns at most what their shares represent
These invariants catch “drift bugs” that only show after many actions.
Example: state machine invariants
State machines are where subtle bugs live: rounds, epochs, phases, settlement periods, timelocks, reveal windows. The invariant is often a monotonic condition:
- Phase transitions cannot skip forward
- Finalized rounds remain finalized
- Settlement root cannot change after finalization
- Nonce only increases
If your protocol can be finalized twice, you have a catastrophic class of bug that invariants can reliably catch.
How to review a protocol’s test suite like an investor or auditor
If you are not the developer, you can still learn a lot by reading the tests. A strong test suite signals a team that understands risk. A weak test suite signals a team that may be relying on hope.
When reviewing a test suite, look for:
- Properties. Do tests assert properties that match user safety, or do they only check trivial outputs?
- Actors. Do tests involve multiple addresses, or everything is a single user?
- Time. Do tests warp time around deadlines and epoch boundaries?
- Sequences. Are there stateful tests that run long action sequences?
- Invariants. Are there always-true checks for conservation and phase correctness?
If the protocol involves tokens you are researching, you can complement your code review with a structural scan: the Token Safety Checker helps surface admin-risk patterns, mutability flags, and common token-level red flags so you can focus deeper manual review on the most dangerous areas.
Common mistakes teams make when “doing testing”
These mistakes are common even in otherwise strong engineering teams, because smart contract systems behave differently than typical apps.
Mistake: treating fuzzing as a checkbox
Teams add one fuzz test, run it a few times, and assume they are safe. But fuzzing value comes from volume and from meaningful properties. If the property is weak, fuzzing just burns CPU.
Mistake: ignoring non-standard tokens and integrations
Many real incidents involve integration assumptions: token behavior, oracle behavior, router behavior. If you only test with a standard mock token, your protocol may be safe in theory and unsafe in practice.
Mistake: not turning failures into regression tests
When fuzzing finds a failure, it is a gift. If you do not convert it into a deterministic regression test, you are likely to reintroduce the bug later. The best suites grow from discovered failures.
Mistake: avoiding invariants because they feel “advanced”
Invariants sound advanced, but they are just explicit guarantees. Most protocols already assume invariants, they just never encode them. If your protocol has money, you should encode conservation invariants.
Putting it together into a practical testing plan
If you are starting from scratch, the plan below is realistic for a small team and scales as you grow:
- Week 1. Write unit tests for critical math and access control. Add boundary tests for time gates.
- Week 2. Add fuzz tests for major public functions. Focus on properties like conservation and revert correctness.
- Week 3. Add stateful fuzz with a handler and write 5 to 10 core invariants.
- Week 4. Add weird-token mocks, integration tests, and long-running fuzz campaigns in CI.
The strongest suites are iterative: every audit finding becomes a new test, every incident in the ecosystem becomes a new invariant idea. This is how protocols mature.
Build testing intuition that matches real exploits
Testing becomes easier when you stop thinking in “functions” and start thinking in “guarantees.” If you want deeper protocol patterns and security-first engineering guidance, keep learning through Blockchain Advance Guides and stay current via Subscribe.
Conclusion
Smart contract incidents rarely come from a single obvious bug in a single function. They come from sequences: actions across time, across modules, across users, often under adversarial ordering. That is why a security-grade test strategy must combine unit tests, fuzzing, and invariants.
Unit tests give clarity and lock down expected behavior. Fuzzing explores the edges you did not think to write down. Invariants encode the rules that must hold forever, and they keep checking those rules across randomized sequences. When you treat guarantees as first-class, your tests stop being a checkbox and start being protection.
If you want to strengthen your baseline security thinking, revisit the prerequisite reading Solidity Security Basics and the multi-step protocol breakdown in Commit-Reveal Schemes. For more advanced engineering strategies and deeper patterns, keep learning from Blockchain Advance Guides, and if you are actively researching deployed tokens, pair your manual review with a structural scan using the Token Safety Checker.
FAQs
What is the difference between unit tests and fuzz tests for smart contracts?
Unit tests validate specific behaviors with specific inputs and are designed to be readable and deterministic. Fuzz tests validate a property across many randomized inputs, often finding edge cases that unit tests miss. Unit tests are great for expected behavior and clear documentation. Fuzz tests are great for unexpected inputs and boundary failures.
When should I write invariants instead of more unit tests?
Write invariants when you care about a guarantee across many actions, many users, and many sequences. If breaking the rule could cause fund loss, fund lock, unexpected minting, or phase bypass, it is a good invariant candidate. Invariants do not replace unit tests. They complement them by protecting protocol-level safety.
What are the most important invariants in DeFi-like protocols?
Conservation invariants are the most common: total assets and total shares remain coherent, debt and collateral remain coherent, and users cannot withdraw more value than their position represents. State machine invariants are also critical: settlement cannot happen twice, finalized states cannot be rolled back, and nonces only increase.
How do I test time-based logic like epochs, timelocks, and reveal windows?
Use time-warping in your test environment to simulate boundary cases: just before the deadline, exactly at the deadline, and just after. Time-based bugs are often off-by-one issues or phase gating mistakes. Testing the boundary explicitly catches the failures that users experience in production.
Do I need stateful fuzzing for small contracts?
If the contract is truly small and has no multi-step behavior, unit tests plus some fuzzing may be enough. But as soon as the contract has state transitions, multiple actors, or time gates, stateful fuzzing becomes high value. Many critical bugs only appear after sequences of actions.
What’s the best way to review a project’s tests before trusting it?
Look for evidence that the team tests guarantees, not just functions. Strong signals include: multi-actor tests, time boundary tests, fuzz tests with meaningful properties, and invariant suites that protect conservation and state machine correctness. If you are evaluating tokens or protocols, complement your review with a structural scan using the TokenToolHub Token Safety Checker.
Where should I start if I’m new to smart contract security testing?
Start with threat modeling and common failure modes in Solidity Security Basics, then study multi-step protocol pitfalls in Commit-Reveal Schemes. From there, build a repeatable workflow: unit tests for clarity, fuzzing for edges, invariants for guarantees.
References
Official docs and reputable sources for deeper reading:
- Solidity Documentation
- Ethereum Improvement Proposals (EIPs)
- Foundry Book
- Hardhat Documentation
- TokenToolHub: Solidity Security Basics
- TokenToolHub: Commit-Reveal Schemes
Want more deep dives like this? Explore Blockchain Advance Guides and get updates via Subscribe.
