Testing smart contracts: unit, fuzz, invariants and static analysis
How contracts holding money are verified: unit tests, fuzzing, solvency invariants, evil token mocks, Slither and Mythril - with an escrow built in Foundry.
In this article
A contract with money cannot be "released and fixed later": after deployment the code cannot be corrected, and a bug costs real funds. So testing takes more time than writing code. Let us go through the layers of verification using our escrow as the example - the Foundry toolkit, static analysers and a way of thinking.
Layer 1. Unit tests
The most basic layer is a test for every scenario and every error. In Foundry tests are written in Solidity. Below are simplified examples in the spirit of our tests:
function test_ConfirmDelivery_PaysSellerMinusFees() public {
uint256 id = _createAndAccept(1_000e6); // a 1000 USDT deal
vm.prank(buyer);
escrow.confirmDelivery(id);
// platform 1% = 10, guarantor 1% of 990 = 9.9, seller 980.1
assertEq(usdt.balanceOf(seller), 980_100_000);
assertEq(usdt.balanceOf(arbiter), 9_900_000);
}
function test_ConfirmDelivery_RevertsForNonBuyer() public {
uint256 id = _createAndAccept(1_000e6);
vm.prank(seller);
vm.expectRevert(Escrow.NotAuthorized.selector);
escrow.confirmDelivery(id);
}Read them as a specification: what must happen and what must not. The more "negative" tests - "wrong caller", "wrong status", "zero amount" - the more reliable the contract.
Layer 2. Fuzzing (fuzz tests)
A human thinks of a limited number of cases. A fuzzer generates hundreds of random inputs itself and looks for one on which the test fails. In Foundry it is enough to declare arguments on the test function:
function testFuzz_SplitPartsSumToWhole(
uint96 amount, uint16 arbiterBps, uint16 buyerBps
) public view {
arbiterBps = uint16(bound(arbiterBps, 0, 3000)); // allowed rates
buyerBps = uint16(bound(buyerBps, 0, 10_000));
(uint256 b, uint256 s, uint256 a, uint256 p) = harness.split(amount, arbiterBps, buyerBps);
assertEq(b + s + a + p, amount); // the parts always equal the whole
}Fuzzing is exactly what catches edge cases: amounts of 1 wei, maximum values, rounding. In our project every such test runs 512 times on random data on every run.
Layer 3. Invariants
Fuzzing checks one function. But real bugs live in the interaction of functions: "created, accepted, disputed, cancelled, withdrew…". An invariant test gives the fuzzer a set of permitted actions and calls them randomly, in random order and with different participants, checking after every step the properties that must never change.
The escrow's main invariant is solvency:
function invariant_ContractIsSolvent() public view {
assertGe(token.balanceOf(address(escrow)), escrow.reserved(address(token)));
}"The contract's balance is never less than the sum of all its obligations." Whatever the participants do, in whatever order they call functions, this must stay true. Our invariant tests run 256 series of 128 calls each - tens of thousands of random actions.
Layer 4. "Evil" environments
To check protection against non-standard tokens and recipients, mocks are written - stand-in contracts with hostile behaviour:
- a token without a returned
bool(like USDT); - a token with a fee on transfer;
- a token with an address blacklist;
- a recipient whose
receive()reverts or burns gas; - a recipient re-entering the contract (a reentrancy check).
If the escrow survived all these mocks, then the defence from the article on non-standard tokens works on more than paper.
Layer 5. Regressions and exploits
When a bug is found it is first reproduced with a test that fails, then the code is fixed, and the test stays forever. This way the bug does not return. Our repository also has tests for the first version's vulnerabilities, run right on the EVM: they show clearly what was broken and how it was fixed.
Layer 6. Static analysis
Analysers read code without running it and look for known bug patterns.
Slither is a fast analyser: it finds reentrancy, uninitialised variables, dangerous calls, order-of-action violations. It produces many findings, some of which are false positives. Each finding must be examined and justified: fixed, or explained why it is safe.
Mythril does symbolic execution: instead of concrete numbers it explores execution paths with "abstract" values and looks for inputs that break the code. Slower, but it finds what others miss.
The results of the review are written down in the repository - a document showing the train of thought, not just a "passed" tick.
Layer 7. Gas and bytecode checks
Separate tests measure the gas of key operations so a stray edit does not make the service costlier. And an opcode-checking script ensures the bytecode has no instructions newer than the chosen EVM version: otherwise the contract would not work on older networks (more in the article on CREATE2).
What tests do not prove
An honest word on the limits.
- Tests do not prove the absence of bugs. They show that the checked scenarios work. Unforeseen ones may lurk.
- Tools are limited. Static analysis does not understand business logic: it will not notice that the guarantor gets the wrong amount if the code is technically correct.
- The specification may be wrong. Tests check that code does what is written in them. If the requirements are wrong, the tests will be wrong too.
So before working with large sums an independent audit is necessary - review by other eyes - followed by bug-bounty programmes. These are separate stages, and no set of automatic checks replaces them.
How to think when designing
A good contract starts with the question not "how do I write this" but "what must always be true". From such statements come invariants, from invariants tests, from tests confidence.
Examples of statements for escrow:
- a deal's payout happens exactly once;
- the sum of payouts always equals the deal amount;
- the platform cannot take more than accumulated fees;
- the guarantor cannot be a participant of the deal;
- from the "Settled" state there is no exit.
What next
Tests do not replace independent review: audits and bug bounties. For practice - the Foundry tutorial. You have walked the whole path: from "what is a blockchain" to the virtual machine, the escrow code and verification methods. Now is the time to apply the knowledge: read the guides by role and create your first deal.
Keep reading
- 3 · Under the hood
Foundry for beginners - compiling, tests, fuzzing and a local network
A practical introduction to Foundry: installation, project structure, unit tests, vm cheatcodes, fuzzing and invariants, forge, cast and anvil, with an example.
6 min read - 3 · Under the hood
Reentrancy, checks-effects-interactions and pull payments
How the reentrancy attack that hacked The DAO works and how to defend against it: operation order, ReentrancyGuard and deferred payments, using escrow.
6 min read
Mentioned in
- What is a smart contract and why you can trust it with money
- How to verify a smart contract before using it - a step-by-step guide
- ERC-20 from scratch - writing a token in Solidity and analysing every line
- Integer math and rounding in smart contracts
- Reentrancy, checks-effects-interactions and pull payments
- Non-standard tokens: USDT without a return value, fee-on-transfer and blacklists
- A catalogue of smart contract vulnerabilities: 12 classic mistakes and defences
- Proxies and upgradeable contracts - delegatecall, UUPS and the price of flexibility
- Foundry for beginners - compiling, tests, fuzzing and a local network
- Audits and bug bounties - how smart contracts are examined and how to read a report
Ready to try it?
Create your first deal in a couple of minutes. The money is locked in a smart contract and paid out by rules you can see in advance.

