Smart Contract Unit Testing

We design and develop full-cycle blockchain solutions: from smart contract architecture to launching DeFi protocols, NFT marketplaces and crypto exchanges. Security audits, tokenomics, integration with existing infrastructure.
Showing 1 of 1 servicesAll 1306 services
Smart Contract Unit Testing
Medium
~2-3 business days
FAQ
Blockchain Development Services
Blockchain Development Stages
Latest works
  • image_website-b2b-advance_0.png
    B2B ADVANCE company website development
    1215
  • image_web-applications_feedme_466_0.webp
    Development of a web application for FEEDME
    1161
  • image_websites_belfingroup_462_0.webp
    Website development for BELFINGROUP
    852
  • image_ecommerce_furnoro_435_0.webp
    Development of an online store for the company FURNORO
    1043
  • image_logo-advance_0.png
    B2B Advance company logo design
    561
  • image_crm_enviok_479_0.webp
    Development of a web application for Enviok
    823

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.