
Testing Solidity Libraries with Deterministic Harnesses
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
internallibrary 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 = 18returns the original amounttokenDecimals = 6scales by10^12tokenDecimals > 18reverts
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)returns3and1split(100, 4)returns25and0split(1, 2)returns0and1
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:
roundDownroundUpfloorDivceilDiv
Then test the exact rounding rule at the boundary.
Comparing testing approaches
| Approach | Best for | Strengths | Limitations |
|---|---|---|---|
| Direct contract integration | Library behavior in real application context | Verifies end-to-end usage | Harder to isolate failures |
| Harness contract | internal library functions and edge cases | Simple, deterministic, focused | Adds test-only code |
| Property-based testing | Arithmetic and invariant-heavy libraries | Finds unexpected input combinations | Requires careful invariant design |
| Fuzz testing | Broad input coverage | Excellent for boundary discovery | Needs 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
internalfunctions 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.
