Why libraries need dedicated tests

A Solidity library is not always easy to test directly. Some libraries contain only internal functions, which means they are compiled into the consuming contract and cannot be called from the outside. Others expose public or external functions, but still benefit from a dedicated test harness so you can isolate behavior and verify assumptions precisely.

Testing libraries directly is useful when you want to:

  • validate pure math or encoding logic independently of application contracts
  • verify boundary conditions such as overflow, underflow, and rounding
  • test internal library functions through a minimal wrapper
  • avoid coupling tests to unrelated contract state
  • create stable regression tests for reusable infrastructure code

A good library test should answer one question: “Does this function behave correctly for all relevant inputs, including edge cases?”


The harness pattern

The most practical way to test an internal library function is to create a small contract that exposes wrapper methods. This contract is often called a harness. It exists only for testing and should be minimal.

Example library

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

library FeeMath {
    function applyBps(uint256 amount, uint256 bps) internal pure returns (uint256) {
        require(bps <= 10_000, "bps too high");
        return (amount * bps) / 10_000;
    }

    function grossUp(uint256 netAmount, uint256 bps) internal pure returns (uint256) {
        require(bps < 10_000, "bps must be < 100%");
        return (netAmount * 10_000) / (10_000 - bps);
    }
}

Test harness

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

import "./FeeMath.sol";

contract FeeMathHarness {
    function applyBps(uint256 amount, uint256 bps) external pure returns (uint256) {
        return FeeMath.applyBps(amount, bps);
    }

    function grossUp(uint256 netAmount, uint256 bps) external pure returns (uint256) {
        return FeeMath.grossUp(netAmount, bps);
    }
}

The harness does not add logic. It only exposes the library in a test-friendly way.


What to test in a Solidity library

A library test suite should be narrower than a contract integration suite, but more exhaustive within its domain.

1. Happy-path behavior

Verify expected outputs for representative inputs:

  • zero values
  • small values
  • typical production values
  • large values near practical limits

2. Boundary conditions

Boundary tests are especially important for arithmetic and validation libraries:

  • minimum and maximum accepted values
  • exact threshold values
  • one unit below and above a threshold

3. Revert conditions

If the library uses require, revert, or custom errors, test the exact failure mode. This is essential for APIs that depend on precise validation.

4. Determinism

Library functions should usually be deterministic. If a function depends on external state, timestamps, or caller context, consider whether it belongs in a library at all. If it must, isolate the dependency and test it explicitly.

5. Idempotence and invariants

Some library functions should preserve invariants:

  • encoding followed by decoding returns the original value
  • normalization produces canonical output
  • repeated application yields the same result

Testing internal functions through a wrapper

Internal functions are common in libraries because they allow inlining and gas-efficient reuse. The tradeoff is testability. A harness solves this without changing production code.

Suppose you have a library that normalizes token amounts to a fixed decimal scale:

library DecimalScale {
    function to18Decimals(uint256 amount, uint8 tokenDecimals) internal pure returns (uint256) {
        require(tokenDecimals <= 18, "unsupported decimals");
        return amount * (10 ** (18 - tokenDecimals));
    }
}

A harness can expose it:

contract DecimalScaleHarness {
    function to18Decimals(uint256 amount, uint8 tokenDecimals) external pure returns (uint256) {
        return DecimalScale.to18Decimals(amount, tokenDecimals);
    }
}

This lets you test cases such as:

  • tokenDecimals = 18 returns the original amount
  • tokenDecimals = 6 scales by 10^12
  • tokenDecimals > 18 reverts

Example test cases with Foundry

The following example uses Foundry-style tests because they are concise and well suited to library harnesses.

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

import "forge-std/Test.sol";
import "../src/FeeMathHarness.sol";

contract FeeMathTest is Test {
    FeeMathHarness harness;

    function setUp() public {
        harness = new FeeMathHarness();
    }

    function testApplyBpsTypical() public {
        uint256 result = harness.applyBps(1_000 ether, 250);
        assertEq(result, 25 ether);
    }

    function testApplyBpsZeroAmount() public {
        uint256 result = harness.applyBps(0, 500);
        assertEq(result, 0);
    }

    function testApplyBpsMaxBps() public {
        uint256 result = harness.applyBps(123, 10_000);
        assertEq(result, 123);
    }

    function testApplyBpsRevertsOnHighBps() public {
        vm.expectRevert(bytes("bps too high"));
        harness.applyBps(100, 10_001);
    }

    function testGrossUpRoundTrip() public {
        uint256 gross = harness.grossUp(900, 1_000);
        uint256 fee = harness.applyBps(gross, 1_000);
        assertEq(gross - fee, 900);
    }
}

This test suite checks both direct outputs and a round-trip invariant. That last test is especially valuable because it validates the relationship between two functions, not just their isolated behavior.


Handling rounding and precision

Many library bugs come from integer division. Solidity truncates toward zero, so rounding behavior must be intentional and tested.

Consider a library that splits a payment among recipients:

library SplitMath {
    function split(uint256 amount, uint256 parts) internal pure returns (uint256 each, uint256 remainder) {
        require(parts > 0, "parts=0");
        each = amount / parts;
        remainder = amount % parts;
    }
}

A test should not only confirm the quotient, but also the remainder:

  • split(10, 3) returns 3 and 1
  • split(100, 4) returns 25 and 0
  • split(1, 2) returns 0 and 1

Why this matters

If downstream code assumes the remainder is zero, it may silently lose value or misallocate funds. Testing the remainder explicitly prevents this class of defect.

Best practice

When a library involves rounding, document the direction of rounding in the function name or NatSpec comments:

  • roundDown
  • roundUp
  • floorDiv
  • ceilDiv

Then test the exact rounding rule at the boundary.


Comparing testing approaches

ApproachBest forStrengthsLimitations
Direct contract integrationLibrary behavior in real application contextVerifies end-to-end usageHarder to isolate failures
Harness contractinternal library functions and edge casesSimple, deterministic, focusedAdds test-only code
Property-based testingArithmetic and invariant-heavy librariesFinds unexpected input combinationsRequires careful invariant design
Fuzz testingBroad input coverageExcellent for boundary discoveryNeeds strong assertions to be useful

For most libraries, a harness plus a small set of fuzz tests gives the best balance of clarity and coverage.


Fuzz testing libraries effectively

Fuzzing is especially powerful for pure libraries because the input space is large and the behavior should be deterministic. The key is to define assertions that always hold.

Example invariant

For applyBps(amount, bps), when bps <= 10_000, the result should never exceed amount.

function testFuzzApplyBpsNeverExceedsAmount(uint256 amount, uint256 bps) public {
    vm.assume(bps <= 10_000);
    uint256 result = harness.applyBps(amount, bps);
    assertLe(result, amount);
}

Another invariant

For a split function:

function testFuzzSplitPreservesValue(uint256 amount, uint256 parts) public {
    vm.assume(parts > 0);
    (uint256 each, uint256 remainder) = SplitMath.split(amount, parts);
    assertEq(each * parts + remainder, amount);
}

This kind of test is powerful because it validates the algebraic contract of the library, not just a few examples.


Testing libraries with custom errors

Custom errors are preferable to string reverts in many libraries because they are cheaper and more structured. They are also easier to assert precisely in modern test frameworks.

Example:

error UnsupportedDecimals(uint8 decimals);

library DecimalScale {
    function to18Decimals(uint256 amount, uint8 tokenDecimals) internal pure returns (uint256) {
        if (tokenDecimals > 18) revert UnsupportedDecimals(tokenDecimals);
        return amount * (10 ** (18 - tokenDecimals));
    }
}

A test can assert the selector and argument:

function testTo18DecimalsReverts() public {
    vm.expectRevert(abi.encodeWithSelector(UnsupportedDecimals.selector, 19));
    harness.to18Decimals(1, 19);
}

This is more robust than checking a generic revert string, especially when libraries are reused across projects.


Common mistakes to avoid

Testing only one path

A library with one happy-path test is not well tested. Arithmetic and validation code often fails at the extremes, not the center.

Ignoring overflow assumptions

Solidity 0.8+ reverts on overflow, but that does not make arithmetic safe by default. Multiplication before division can still overflow for large values. Test large inputs deliberately.

Mixing library logic with application state

If a library depends on storage, access control, or external calls, its tests become harder to reason about. Prefer pure or view functions where possible.

Overusing mocks

Libraries are usually deterministic and do not need mocks. If you find yourself mocking heavily, the code may belong in a contract rather than a library.

Forgetting canonical behavior

For encoding, hashing, and normalization libraries, test that the output is stable and canonical. Small deviations can break signatures, Merkle proofs, or cross-contract compatibility.


A practical checklist for library test suites

Use this checklist when creating tests for a new Solidity library:

  • expose internal functions through a minimal harness
  • test representative happy-path inputs
  • test zero, boundary, and near-boundary values
  • assert exact revert behavior
  • verify rounding and remainder semantics
  • add at least one invariant or round-trip test
  • fuzz the most error-prone parameters
  • keep the harness free of business logic

If your library is reused by multiple contracts, treat its test suite as a first-class asset. It is often the fastest way to detect regressions before they reach integration tests.


Conclusion

Testing Solidity libraries well requires a different mindset from testing full contracts. The focus is on deterministic behavior, edge-case coverage, and algebraic correctness. A small harness contract makes internal functions testable without polluting production code, while fuzz tests and invariants help uncover failures that example-based tests miss.

When a library is mathematically precise or security-sensitive, its tests should be equally precise. That discipline pays off every time the library is reused.

Learn more with useful resources