Back to glossary

Unit Test in Solidity

Table of Contents

What is a unit test?

In general programming, a unit test is a method of software testing that verifies the functionality of individual components, such as functions, methods, or classes, independently from the rest of the system. By mocking external dependencies, unit tests ensure that each component behaves as expected according to its design. They are a foundational part of continuous integration workflows, helping to catch bugs early in development.

In Solidity smart contract development, a unit test refers to automated software testing that evaluates individual units or components of a smart contract to ensure they function correctly. Each unit test independently assesses a single function or feature of a contract, verifying that the code behaves exactly as expected under various conditions. Smart contracts are immutable and handle user assets. Rigorous testing is crucial for identifying potential vulnerabilities and logical errors before deployment.

Purpose of unit tests in smart contracts

The primary purposes of writing unit tests for Solidity smart contracts include:

  • Identifying bugs: Unit tests help developers identify and fix bugs, logical errors, or unintended behavior before deployment.
  • Code reliability: Robust tests increase confidence that each function in a contract performs correctly under varying conditions, such as user roles (admin vs. user), token amounts (zero, max uint256).
  • Refactoring: Unit tests allow developers to safely refactor and optimize code, such as modularizing logic, reducing gas costs, or renaming variables, while ensuring functionality remains intact.
  • Automating validation: Automated unit tests make the validation process repeatable by checking for expected outputs, correct state changes, and proper error reverts,  ensuring code consistency and simplifying maintenance.

Unit testing in test-driven development (TDD)

Unit tests are often used in Test-Driven Development (TDD), where developers write tests before implementing the logic. This approach encourages designing for correctness from the beginning and provides a safety net during iteration. In Solidity development, TDD helps ensure that contract logic aligns with business requirements and expected functional behavior.

Key features of unit tests

  • Isolation: Each test evaluates one function or behavior at a time, without interference from other tests. In Solidity, this is achieved by resetting the contract state before each test or using mock contracts to simulate dependencies.
  • Repeatability: Tests can be executed repeatedly, providing consistent validation as the code evolves.
  • Speed and efficiency: Unit tests are designed to run in milliseconds to a few seconds, enabling rapid feedback.
  • Clear assertions: Unit tests clearly define expected outcomes using Solidity-based assertions such as assertEq (for value comparison),and vm.expectRevert (to check for expected failures) to provide straightforward pass/fail results. To explore additional testing utilities and assertion helpers, refer to the Foundry cheatcodes documentation.

Frameworks and tools for Solidity unit tests

Several tools facilitate Solidity unit testing:

  • Foundry: A high-performance toolkit written in Rust and designed for Solidity-first smart contract development. It includes Forge for testing, building, and deploying contracts, Cast for interacting with contracts,sending transactions, hashing data, and other helpful functionality, Anvil as a local Ethereum node for fast testing and debugging, and Chisel  as an interactive Solidity REPL (Read-Eval-Print Loop) for evaluating expressions and testing code snippets. Its speed, simplicity, and tight integration with Solidity have made it the preferred choice for many smart contract developers. For those new to Foundry or looking to go deeper, this Foundry course by Cyfrin Updraft is a great place to start.
  • Hardhat: A widely used framework for EVM development that allows developers to test Solidity contracts using JavaScript or TypeScript. It provides testing support, efficient local networks, extensive plugins, and helpful developer tooling like stack traces, error messages, and console logging.

Coverage metrics and continuous integration (CI)

Unit tests are more effective when paired with tools that measure the extent to which they cover the contract logic. Code coverage tools help identify untested paths, conditions, and edge cases, making it easier to improve test quality.

  • Foundry: Uses forge coverage to generate detailed coverage reports in the terminal.
  • Hardhat: Uses the solidity-coverage plugin to visualize coverage across functions and branches in a codebase.

These metrics are especially useful when integrated into CI pipelines (e.g., GitHub Actions, GitLab CI). Running unit tests and generating coverage reports automatically on each pull request ensures that changes are thoroughly tested and regressions are caught early. CI integration helps teams maintain high-quality contracts as codebases grow.

Example of a unit test in Solidity (using Foundry)

Here's a basic Solidity unit test using Foundry's Forge:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import {Test} from "forge-std/Test.sol";
import {MyToken} from "../src/MyToken.sol";

contract MyTokenTest is Test {
    MyToken token;

    function setUp() public {
        token = new MyToken("Example Token", "EXT");
    }

    function testMint() public {
        token.mint(address(this), 100);
        assertEq(token.balanceOf(address(this)), 100);
    }

    function testBurn() public {
        token.mint(address(this), 100);
        token.burn(address(this), 40);
        assertEq(token.balanceOf(address(this)), 60);
    }

    function testFailUnauthorizedMint() public {
vm.prank(makeAddr("notAuthorizedAddr"));
vm.expectRevert(Token.Token__OnlyAuthorized.selector); token.mint(address(0), 50);
    }
}

In this code:

  • The setUp() function initializes the contract’s instance before each test runs.
  • testMint() and testBurn() ensure token functions behave correctly and that state updates such as balance changes occur as expected.
  • testFailUnauthorizedMint() validates proper failure on incorrect conditions.

Best practices for Solidity unit tests 

  • Cover edge cases: Write tests for known boundary conditions that your contract logic explicitly handles. These may include zero values, max or min inputs where relevant, empty arrays (if accepted), and special-case addresses such as address(0) or predefined role-based addresses.
  • Meaningful naming: Name each test function to describe exactly what behavior or condition is being verified (e.g., testMintRevertsIfCallerNotOwner).
  • Test independence: Avoid dependencies between tests so that each test can run in isolation and still pass.
  • Comprehensive coverage: Aim to test all critical paths, success and failure conditions, and permission checks. Tools like forge coverage (Foundry) or solidity-coverage (Hardhat plugin) can help measure and visualize how much of your contract logic is covered by tests.

Limitations of unit testing

While unit testing is necessary, it has inherent limitations. Unit tests only validate isolated functions and do not capture full system behavior or on-chain dynamics. Some areas that unit tests do not cover include:

  • Cross-contract interactions: Unit tests cannot detect issues arising from how a contract interacts with external contracts or protocols.
  • Gas-related edge cases: Unit tests do not automatically simulate gas constraints or identify inefficiencies like exceeding block gas limits or unexpected out-of-gas errors.
  • Real-world state assumptions: Unit tests use clean environments and cannot account for conditions present on live networks.

Integration tests, fork tests, invariant testing, and real-world simulations address these gaps. 

A comprehensive testing strategy utilizes unit tests as a foundational layer, building upon them to achieve complete confidence.

Role of unit tests in smart contract security and testing

Unit tests only validate isolated components. To fully assess contract behavior, they must be combined with broader testing strategies:

  • Integration tests validate how contracts or systems of contracts interact within complete workflows. These tests simulate realistic usage scenarios, from input to final state change, ensuring that all pieces of the system work together as intended.
  • Fork tests simulate real network conditions by forking a live blockchain and testing against the actual state.
  • Fuzz tests use randomly generated inputs to test individual functions in isolation. These tests are designed to uncover unexpected behaviors, edge cases, and reverts by exploring a wide range of inputs without maintaining state across calls. Unlike invariant tests, fuzz tests do not evaluate sequences of interactions with persistent contract state.
  • Invariant tests assert that certain properties (e.g., total supply remains constant) always hold, after randomized sequences of contract calls. Unlike basic fuzz tests, which check individual inputs in isolation, invariant tests are stateful and evaluate whether a condition remains true across an entire execution path across sequences of function calls.

Each test type builds on guarantees established by unit testing, expanding test coverage and increasing confidence in the contract’s behavior under real-world conditions.

Through diligent unit testing, Solidity developers improve code quality, reduce risk, and establish a solid foundation for robust and secure smart contracts.

Learn more about smart contract security and testing with Updraft’s Smart Contract Security and Assembly and Formal Verification courses.

Related Terms

No items found.