Meta description: I built a full automated testing suite for Solidity smart contracts using Hardhat and Foundry — here’s my battle-tested approach to avoiding costly bugs on-chain.
Last updated: June 8, 2026
Introduction
I once watched a DeFi protocol lose $6 million because a single integer overflow wasn’t caught in testing. The contract had unit tests — just not the right unit tests. The edge case lived in a boundary condition nobody had thought to probe. That incident stuck with me, and it’s why I’ve spent the last three years obsessing over automated testing for smart contracts in Solidity. Unlike traditional backend code, a deployed smart contract can’t be patched. A bug is permanent until you migrate to a new contract — if you’re lucky enough to have built in an escape hatch. The stakes are different, and your testing strategy has to be too.
TL;DR
- Use Hardhat for a JavaScript/TypeScript testing workflow; use Foundry (Forge) for fast, Solidity-native fuzz testing.
- Always test edge cases, not just happy paths — fuzz testing with
forge fuzzcatches boundary bugs that unit tests miss. - Run static analysis with Slither before every deploy; it catches common vulnerability patterns in seconds.
Why Automated Testing for Smart Contracts Is Non-Negotiable
In traditional software, bugs are expensive. In smart contracts, bugs are often catastrophic and irreversible. The Ethereum blockchain alone has seen over $3 billion lost to smart contract vulnerabilities since 2016 — the DAO hack, the Parity wallet freeze, Ronin Bridge, and hundreds of smaller incidents.
Manual code review catches a lot, but it doesn’t scale and it misses edge cases at the boundary of human attention. Smart contract testing automation gives you a reproducible, systematic way to verify behavior before funds are at risk. Formal verification exists but is expensive and requires specialist knowledge. Automated testing — unit tests, integration tests, and fuzz tests — is the practical middle ground every Solidity developer needs.
[INTERNAL LINK: related article on Solidity security patterns]
Prerequisites
You’ll need:
- Node.js 18+ and npm (for Hardhat)
- Rust and Cargo installed (for Foundry — install via
curl -L https://foundry.paradigm.xyz | bash) - Basic familiarity with Solidity and smart contract concepts
- A test wallet with Sepolia ETH if you want to run integration tests against a live testnet
# Verify your setup
node --version # Should return v18.x.x or higher
forge --version # Should return forge 0.2.x or higher
[SOURCE: https://hardhat.org/hardhat-runner/docs/getting-started]
Step-by-Step: Building an Automated Smart Contract Test Suite
Step 1: Set Up a Hardhat Project
Hardhat is my default choice for JavaScript/TypeScript teams. It has a rich ecosystem, excellent debugging (with console.log inside contracts), and integrates well with frontend tooling.
mkdir my-contract && cd my-contract
npm init -y
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
npx hardhat init
# Choose "Create a TypeScript project"
Your project structure should look like this:
my-contract/
├── contracts/
│ └── MyToken.sol
├── test/
│ └── MyToken.ts
├── hardhat.config.ts
└── package.json
Step 2: Write a Solidity Contract to Test
Here’s a simple ERC-20 token with a vesting cliff — a common pattern with real-world bugs:
// contracts/VestedToken.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract VestedToken is ERC20 {
address public beneficiary;
uint256 public cliffEnd;
uint256 public vestedAmount;
constructor(address _beneficiary, uint256 _cliffDuration, uint256 _amount)
ERC20("VestedToken", "VST")
{
beneficiary = _beneficiary;
cliffEnd = block.timestamp + _cliffDuration;
vestedAmount = _amount;
_mint(address(this), _amount);
}
function release() external {
require(msg.sender == beneficiary, "Not beneficiary");
require(block.timestamp >= cliffEnd, "Cliff not reached");
require(balanceOf(address(this)) > 0, "Nothing to release");
_transfer(address(this), beneficiary, balanceOf(address(this)));
}
}
Step 3: Write Unit Tests with Hardhat + Ethers.js
Good smart contract unit tests follow the Arrange-Act-Assert pattern and test every require statement independently:
// test/VestedToken.ts
import { expect } from "chai";
import { ethers } from "hardhat";
import { time } from "@nomicfoundation/hardhat-network-helpers";
describe("VestedToken", function () {
const ONE_YEAR = 365 * 24 * 60 * 60;
const VESTED_AMOUNT = ethers.parseEther("1000");
async function deployFixture() {
const [owner, beneficiary, other] = await ethers.getSigners();
const VestedToken = await ethers.getContractFactory("VestedToken");
const token = await VestedToken.deploy(beneficiary.address, ONE_YEAR, VESTED_AMOUNT);
return { token, owner, beneficiary, other };
}
describe("release()", function () {
it("should revert if called before cliff", async function () {
const { token, beneficiary } = await deployFixture();
await expect(token.connect(beneficiary).release())
.to.be.revertedWith("Cliff not reached");
});
it("should revert if called by non-beneficiary", async function () {
const { token, other } = await deployFixture();
await time.increase(ONE_YEAR + 1);
await expect(token.connect(other).release())
.to.be.revertedWith("Not beneficiary");
});
it("should transfer full balance after cliff", async function () {
const { token, beneficiary } = await deployFixture();
await time.increase(ONE_YEAR + 1);
await token.connect(beneficiary).release();
expect(await token.balanceOf(beneficiary.address)).to.equal(VESTED_AMOUNT);
});
it("should revert on second call (nothing left)", async function () {
const { token, beneficiary } = await deployFixture();
await time.increase(ONE_YEAR + 1);
await token.connect(beneficiary).release();
await expect(token.connect(beneficiary).release())
.to.be.revertedWith("Nothing to release");
});
});
});
Run the tests:
npx hardhat test
# Output: 4 passing (1s)
Important: Every
requirestatement in your contract should have a corresponding test that triggers the revert condition. If arequiredoesn’t have a test that makes it fail, you don’t know it’s actually guarding what you think it is.
Step 4: Add Foundry for Fuzz Testing
This is where the real safety net comes in. Fuzz testing automatically generates hundreds of random inputs and searches for cases that break your invariants. Install Foundry and initialize it alongside your Hardhat project:
curl -L https://foundry.paradigm.xyz | bash
foundryup
# Init Foundry in the same project (mixed setup)
forge init --no-git --force
Now write a fuzz test in Solidity:
// test/VestedToken.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "forge-std/Test.sol";
import "../contracts/VestedToken.sol";
contract VestedTokenFuzzTest is Test {
VestedToken token;
address beneficiary = address(0xBEEF);
function setUp() public {
token = new VestedToken(beneficiary, 365 days, 1000 ether);
}
// Fuzz: release should never succeed before cliff, for any time t
function testFuzz_releaseBeforeCliff(uint256 timeWarp) public {
// Only test times strictly before the cliff
timeWarp = bound(timeWarp, 0, 365 days - 1);
vm.warp(block.timestamp + timeWarp);
vm.prank(beneficiary);
vm.expectRevert("Cliff not reached");
token.release();
}
// Fuzz: beneficiary balance after release should equal initial supply
function testFuzz_releaseFullAmount(uint256 timeWarp) public {
timeWarp = bound(timeWarp, 365 days, 10 * 365 days);
vm.warp(block.timestamp + timeWarp);
vm.prank(beneficiary);
token.release();
assertEq(token.balanceOf(beneficiary), 1000 ether);
}
}
Run fuzz tests:
forge test --match-path test/VestedToken.t.sol -v
# Forge runs 256 randomized inputs per fuzz function by default
Increase the fuzz run count for critical contracts:
forge test --fuzz-runs 10000
Step 5: Static Analysis with Slither
Before I deploy anything, I always run Slither, the open-source static analysis framework from Trail of Bits. It catches reentrancy vulnerabilities, unprotected selfdestruct calls, weak access control, and dozens of other patterns.
pip3 install slither-analyzer
slither contracts/VestedToken.sol --solc-remaps "@openzeppelin=node_modules/@openzeppelin"
A common output you’ll see on real contracts:
VestedToken.release() (contracts/VestedToken.sol#22-27)
- Reentrancy in VestedToken.release(): External calls first, then state changes
- Reference: https://github.com/crytic/slither/wiki/Detector-Documentation#reentrancy-vulnerabilities
In our VestedToken case, Slither flags that we call _transfer (which triggers a callback in some token standards) before we’ve cleared the balance state. For a basic ERC-20 this is benign, but it’s exactly the pattern that causes reentrancy exploits in more complex contracts. Fix it by updating state before external calls.
[SOURCE: https://github.com/crytic/slither]
Step 6: Measure and Enforce Coverage
Never deploy without checking coverage. Hardhat has a coverage plugin:
npm install --save-dev solidity-coverage
# Add to hardhat.config.ts: require("solidity-coverage")
npx hardhat coverage
I enforce a minimum 90% line coverage in CI using a simple check:
# In your CI pipeline (GitHub Actions, etc.)
COVERAGE=$(npx hardhat coverage --silent | grep "All files" | awk '{print $4}' | tr -d '%')
if (( $(echo "$COVERAGE < 90" | bc -l) )); then
echo "Coverage $COVERAGE% is below 90% threshold"
exit 1
fi
Real-World Tips I Use in Production
Use fixtures, not beforeEach re-deploys. Hardhat’s loadFixture snapshots the blockchain state and restores it instead of re-running the full deploy. On large test suites, this is 3–5x faster.
Test events, not just state. Every important state change in your contract should emit an event, and your tests should verify those events fire with the correct arguments. Use Hardhat’s expect(tx).to.emit(contract, "EventName").withArgs(...).
Test on a forked mainnet for DeFi integrations. If your contract interacts with Uniswap, Aave, or Chainlink, test against a fork of mainnet state:
npx hardhat test --network hardhat --fork https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY
Common Errors and How I Fixed Them
Problem: Error: Transaction reverted without a reason string in tests, making it impossible to debug which require failed. Fix: Make sure every require in your Solidity code has a reason string. No excuses. require(condition) with no message is a debugging nightmare. Switch to custom errors in Solidity 0.8.4+ for gas efficiency: error CliffNotReached(); if (block.timestamp < cliffEnd) revert CliffNotReached();
Problem: Fuzz tests were passing even on obviously incorrect logic because the bound() range was wrong. Fix: I had used bound(timeWarp, 0, 365 days) instead of bound(timeWarp, 0, 365 days - 1) for the pre-cliff test. Off-by-one errors in fuzz bounds are a real footgun. Always double-check your bound() calls match the exact boundary you intend to test.
Problem: Slither reported false positives on OpenZeppelin inherited functions, cluttering output. Fix: Use Slither’s --filter-paths flag: slither . --filter-paths "node_modules". This eliminates noise from third-party code and lets you focus on your own contracts.
FAQ
Q: What is the best framework for automated testing of Solidity smart contracts in 2026? A: The most effective setup is using both Hardhat and Foundry together. Hardhat excels at integration testing and works naturally with JavaScript/TypeScript frontends. Foundry’s Forge is faster for unit and fuzz testing in pure Solidity. They’re not mutually exclusive — many production projects use both in the same repository.
Q: How do I test smart contract interactions with other DeFi protocols in Solidity? A: Use Hardhat or Foundry’s mainnet forking feature. You fork the current state of Ethereum mainnet into a local test network, then interact with live deployed contracts (Uniswap, Aave, etc.) in your tests without spending real ETH. This is the only reliable way to test complex DeFi integrations.
Q: What is fuzz testing for smart contracts and why should I use it? A: Fuzz testing automatically generates hundreds or thousands of randomized inputs and checks whether your contract invariants hold for all of them. It’s especially valuable for catching integer overflow/underflow edge cases, unexpected boundary conditions, and logic errors that unit tests with manually chosen inputs would never hit.
Q: How much test coverage should a Solidity smart contract have before deployment? A: For any contract handling user funds, I recommend a minimum of 90% line coverage and 85% branch coverage as a floor, not a ceiling. High coverage doesn’t guarantee security — you also need fuzz tests and static analysis — but low coverage is a near-certain sign of unverified behavior.
Q: What is Slither and how does it help with smart contract security testing? A: Slither is an open-source static analysis framework built by Trail of Bits that scans Solidity code for known vulnerability patterns without executing the code. It detects reentrancy vulnerabilities, unprotected ownership functions, integer issues, and 70+ other detector types. It runs in seconds and should be part of every pre-deploy checklist, even when your manual review is thorough.
Conclusion
Automated testing for smart contracts isn’t optional — it’s the difference between shipping with confidence and hoping nobody finds your bug before you do. Start with Hardhat for unit tests, layer in Foundry fuzz tests for boundary conditions, and run Slither before every deployment. That three-layer approach has saved me from shipping broken contracts more times than I care to admit.
About the Author
I’m a full-stack and smart contract engineer with 8 years in software development, the last three focused on Ethereum and EVM-compatible chains. My stack includes Solidity, Hardhat, Foundry, TypeScript, and an excessive number of audit reports I’ve read on weekends. I’ve audited and tested contracts across DeFi, NFT infrastructure, and DAO governance systems. You can find more of my work on security and contract architecture here at SpiritCode.

