BitAzure
Engineering

Testing Smart Contracts: Unit, Invariant and Fuzz

A Foundry-style workflow for smart contract testing: unit tests for behaviour, invariants for properties that must always hold, fuzzing to find what breaks them, and CI gates.

BitAzure Engineering · Smart Contract EngineeringPublished Updated 5 min read

A smart contract is code that cannot be patched after the fact without either an upgrade mechanism, which is its own risk, or a migration, which is expensive and public. Testing therefore has to do work that in other systems is shared with monitoring and hotfixes. The workflow below is the one we run on contract engagements. It is built around Foundry because the tooling makes fuzzing and invariant testing cheap enough to use every day, but the layering applies with any framework.

Three layers, three questions

Unit tests answer "does this function do what the specification says for these inputs". Invariant tests answer "is there any sequence of calls that puts the system into a state that should be impossible". Fuzz tests answer "is there any input to this function that violates its properties". Teams often stop at the first layer because it is the one that resembles ordinary software testing. The second and third are where the exploits live.

Unit tests: the specification in executable form

Every public function gets tests for its documented behaviour, its access control, its revert conditions and its events. Write the revert tests first; they are the ones that encode what the contract refuses to do. Use a fresh deployment per test, label addresses so traces are readable, and assert on state changes rather than on return values alone. When a function has a subtle branch, such as rounding direction in a share calculation, write a test that fails if the rounding flips, because that is exactly the kind of change that slips through review.

Fork tests belong in this layer too. Running against a fork of mainnet state lets you test integration with live protocols, oracles and tokens with unusual behaviour, such as fee-on-transfer or non-standard return values, without mocking them badly.

Invariants: define what must always be true

An invariant is a property of the whole system that no sequence of valid calls should break. Defining them is the most valuable part of the exercise, and it is a design activity as much as a testing one. For a lending protocol: total borrows never exceed total supplied collateral value at the protocol's collateral factors; the sum of user balances equals the token's total supply; no account is both liquidatable and unliquidatable under the same price. For a vault: shares times price per share never exceeds assets held plus accrued yield; a deposit followed by a withdrawal never returns more than was deposited.

Foundry's invariant runner calls random sequences of functions on a set of target contracts and checks the invariants after each call. Left unconstrained, it spends most of its time on reverting calls. The productive pattern is a handler contract that wraps the target's functions, bounds inputs to meaningful ranges, tracks ghost variables that the invariants reference and keeps a list of actors so calls come from realistic senders.

contract VaultHandler is Test {
    Vault public vault;
    uint256 public ghostDeposited;
    uint256 public ghostWithdrawn;

    function deposit(uint256 amount) external {
        amount = bound(amount, 1, 1e24);
        deal(address(vault.asset()), msg.sender, amount);
        vm.startPrank(msg.sender);
        vault.asset().approve(address(vault), amount);
        vault.deposit(amount, msg.sender);
        vm.stopPrank();
        ghostDeposited += amount;
    }
}

contract VaultInvariants is Test {
    function invariant_assetsCoverShares() public view {
        assertGe(vault.totalAssets(), vault.convertToAssets(vault.totalSupply()));
    }

    function invariant_conservation() public view {
        assertEq(handler.ghostDeposited() - handler.ghostWithdrawn(), vault.totalAssets());
    }
}

Ghost variables are the bridge between what the handler knows and what the contract exposes. If an invariant can be expressed without ghosts, it is usually a stronger one, but ghosts are how you check conservation properties that the contract itself does not track.

Fuzzing: let the tool find the input

Property-based fuzz tests take parameters instead of fixed values and let the runner generate inputs. The discipline is in writing the property rather than the example: "for any amount within range, depositing then withdrawing returns at most the deposited amount", or "for any two orders that cross, matching produces a trade at the resting price". Bound inputs with the framework's helpers rather than with early returns, so the runner does not waste iterations on discarded cases.

Corpus and coverage

Keep the fuzz corpus. Inputs that once found a failure are the most valuable test cases you own, and a persisted corpus lets the runner start from interesting states rather than from scratch. Increase the number of runs for nightly jobs and keep it modest for pull requests so the feedback loop stays fast. Measure coverage, but read it correctly: line coverage tells you which code executed, not whether the properties checked there were meaningful. A branch covered by a fuzz run that never asserted anything is not tested.

  • Pull requests: unit tests, short fuzz runs, invariant runs with a small depth.
  • Nightly: long fuzz campaigns, deep invariant runs, fork tests against recent mainnet state.
  • Pre-release: a full campaign with the persisted corpus, a coverage report reviewed by a second engineer, and a gas snapshot diff.

CI gating

The build fails if any test fails, if coverage on the contracts under change drops, if the gas snapshot changes without an accompanying update, or if static analysis reports new findings above an agreed severity. Treat the gas snapshot as a change detector: an unexpected gas difference often indicates a behavioural change the author did not intend. Store fuzz seeds and corpus artefacts from CI so a failure found in a nightly run can be reproduced exactly on a developer's machine.

Preparing for an audit

Auditors are most effective when they are not spending their time discovering what the system is supposed to do. Hand them the invariants in plain language and as code, the threat model, the list of trusted roles and what each can do, the external dependencies and the assumptions made about them, a description of known limitations and the coverage report. Freeze the commit under review and do not develop against it while the audit runs. Every finding, including informational ones, gets a written response and a test that would have caught it.

A passing test suite proves the contract does what you wrote down. The invariants are where you write down what must never happen, and that is the list that matters when real value is at stake.

None of this replaces an independent review; it makes the review count. A codebase that arrives with well-defined invariants, a persisted fuzz corpus and a clean CI history gives reviewers the room to look for the subtle issues rather than the obvious ones.

DeFi 5 min read

Order-Book DEX vs AMM: Engineering Trade-offs

Capital efficiency, latency, MEV exposure and oracle dependence compared across on-chain order books, automated market makers and the hybrid designs between them.

BitAzure Engineering
Security 5 min read

Custody Architectures Compared: HSM, MPC and Multisig

Trust assumptions, operational trade-offs and approval workflows for hardware security modules, threshold signatures and on-chain multisig, and when to combine them.

BitAzure Engineering
Architecture 5 min read

Event Sourcing for Financial Ledgers

Immutable events, rebuildable projections, double-entry invariants enforced at write time, replay for audit and the migration pitfalls that catch teams adopting the pattern.

BitAzure Engineering
Start a conversation

Ready to build your blockchain or fintech product?

Tell us about your platform, timeline and constraints. Our engineers will respond with an architecture-first perspective, not a sales pitch.