Test design patterns, best practices, and examples for comprehensive Solidity testing. Use when writing tests for smart contracts or improving test coverage.
This skill inherits all available tools. When active, it can use any tool Claude has access to.
This skill provides patterns, best practices, and examples for testing Solidity smart contracts using Foundry and Hardhat.
Testing Language:
Use this skill when:
Foundry projects: Write tests in Solidity
Hardhat projects: Write tests in TypeScript (strict mode)
Advantages:
Basic Test:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
import {Test} from "forge-std/Test.sol";
import {MyContract} from "../src/MyContract.sol";
contract MyContractTest is Test {
MyContract public myContract;
address public owner = address(1);
function setUp() public {
vm.prank(owner);
myContract = new MyContract();
}
function test_BasicFunctionality() public {
// Arrange
uint256 expected = 42;
// Act
myContract.setValue(expected);
// Assert
assertEq(myContract.value(), expected);
}
}
Advantages:
TypeScript Configuration (tsconfig.json):
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true
},
"include": ["./test", "./scripts", "./typechain-types"],
"files": ["./hardhat.config.ts"]
}
Basic Test (TypeScript):
import { expect } from "chai";
import { ethers } from "hardhat";
import { MyContract } from "../typechain-types";
import { SignerWithAddress } from "@nomicfoundation/hardhat-ethers/signers";
describe("MyContract", function () {
let myContract: MyContract;
let owner: SignerWithAddress;
beforeEach(async function () {
[owner] = await ethers.getSigners();
const MyContractFactory = await ethers.getContractFactory("MyContract");
myContract = await MyContractFactory.deploy();
});
it("should set value correctly", async function () {
const expected: number = 42;
await myContract.setValue(expected);
expect(await myContract.value()).to.equal(expected);
});
});
Foundry:
test/
├── unit/
│ ├── MyContract.t.sol
│ └── Token.t.sol
├── integration/
│ ├── Integration.t.sol
│ └── Workflow.t.sol
├── fuzz/
│ └── Fuzz.t.sol
└── invariant/
└── Invariant.t.sol
Hardhat (TypeScript):
test/
├── unit/
│ ├── MyContract.test.ts
│ └── Token.test.ts
├── integration/
│ ├── Integration.test.ts
│ └── Workflow.test.ts
└── fixtures/
└── deploy.ts
Foundry (Solidity):
ContractName.t.solContractNameTesttest_FunctionName_Condition()testFuzz_FunctionName()invariant_ConditionName()Hardhat (TypeScript):
ContractName.test.tsfunction test_Transfer() public {
// ARRANGE: Set up test conditions
address recipient = address(0xBEEF);
uint256 amount = 100;
deal(address(token), user, 1000);
// ACT: Perform the action
vm.prank(user);
token.transfer(recipient, amount);
// ASSERT: Verify the result
assertEq(token.balanceOf(recipient), amount);
assertEq(token.balanceOf(user), 900);
}
Foundry:
contract MyTest is Test {
MyContract public myContract;
address public user1;
address public user2;
function setUp() public {
// Runs before each test
myContract = new MyContract();
user1 = makeAddr("user1");
user2 = makeAddr("user2");
vm.deal(user1, 100 ether);
vm.deal(user2, 100 ether);
}
}
Hardhat (TypeScript):
import { ethers } from "hardhat";
import { MyContract } from "../typechain-types";
describe("MyContract", function () {
let myContract: MyContract;
beforeEach(async function () {
// Runs before each test
const MyContractFactory = await ethers.getContractFactory("MyContract");
myContract = await MyContractFactory.deploy();
});
afterEach(async function () {
// Cleanup after each test (if needed)
});
});
Hardhat (TypeScript):
import { loadFixture } from "@nomicfoundation/hardhat-network-helpers";
import { ethers } from "hardhat";
import { Token } from "../typechain-types";
import { SignerWithAddress } from "@nomicfoundation/hardhat-ethers/signers";
async function deployTokenFixture() {
const [owner, addr1, addr2]: SignerWithAddress[] = await ethers.getSigners();
const TokenFactory = await ethers.getContractFactory("Token");
const token: Token = await TokenFactory.deploy();
return { token, owner, addr1, addr2 };
}
describe("Token", function () {
it("should transfer tokens", async function () {
const { token, addr1 } = await loadFixture(deployTokenFixture);
await token.transfer(addr1.address, 100);
expect(await token.balanceOf(addr1.address)).to.equal(100);
});
});
Foundry:
function test_RevertWhen_InsufficientBalance() public {
vm.expectRevert("Insufficient balance");
myContract.withdraw(1000);
}
function test_RevertWhen_Unauthorized() public {
vm.prank(address(0xBEEF));
vm.expectRevert("Ownable: caller is not the owner");
myContract.adminFunction();
}
// Custom error
function test_RevertWhen_CustomError() public {
vm.expectRevert(MyContract.InsufficientBalance.selector);
myContract.withdraw(1000);
}
Hardhat (TypeScript):
import { expect } from "chai";
it("should revert when insufficient balance", async function () {
await expect(myContract.withdraw(1000))
.to.be.revertedWith("Insufficient balance");
});
it("should revert with custom error", async function () {
await expect(myContract.withdraw(1000))
.to.be.revertedWithCustomError(myContract, "InsufficientBalance");
});
Foundry:
function test_EmitsTransferEvent() public {
vm.expectEmit(true, true, false, true);
emit Transfer(user1, user2, 100);
vm.prank(user1);
token.transfer(user2, 100);
}
Hardhat (TypeScript):
import { expect } from "chai";
it("should emit Transfer event", async function () {
await expect(token.transfer(addr1.address, 100))
.to.emit(token, "Transfer")
.withArgs(owner.address, addr1.address, 100);
});
Purpose: Test with random inputs to find edge cases
Foundry:
function testFuzz_Transfer(address to, uint256 amount) public {
// Foundry will call this with random values
vm.assume(to != address(0));
vm.assume(amount <= type(uint256).max);
deal(address(token), user, amount);
vm.prank(user);
if (amount <= token.balanceOf(user)) {
token.transfer(to, amount);
assertEq(token.balanceOf(to), amount);
}
}
// Configure fuzzing
/// forge-config: default.fuzz.runs = 1000
/// forge-config: default.fuzz.max-test-rejects = 100000
Hardhat (with Echidna):
contract EchidnaTest is MyContract {
function echidna_balance_never_negative() public view returns (bool) {
return balances[msg.sender] >= 0;
}
}
Purpose: Properties that should always hold true
Foundry:
contract InvariantTest is Test {
MyContract public myContract;
Handler public handler;
function setUp() public {
myContract = new MyContract();
handler = new Handler(myContract);
targetContract(address(handler));
}
function invariant_TotalSupplyEqualsSumOfBalances() public {
assertEq(
myContract.totalSupply(),
handler.sumOfBalances()
);
}
function invariant_BalancesNeverExceedSupply() public {
assertTrue(handler.maxBalance() <= myContract.totalSupply());
}
}
// Handler contract for invariant testing
contract Handler {
MyContract public myContract;
uint256 public sumOfBalances;
constructor(MyContract _myContract) {
myContract = _myContract;
}
function transfer(address to, uint256 amount) public {
// Bounded random actions
amount = bound(amount, 0, myContract.balanceOf(msg.sender));
myContract.transfer(to, amount);
}
}
Example Properties:
// Sum of parts equals whole
function invariant_SumEqualsTotal() public {
uint256 sum = 0;
for (uint i = 0; i < holders.length; i++) {
sum += balances[holders[i]];
}
assertEq(sum, totalSupply);
}
// Operation reversibility
function test_DepositWithdrawIdentity(uint256 amount) public {
uint256 balanceBefore = user.balance;
vm.prank(user);
vault.deposit{value: amount}();
vm.prank(user);
vault.withdraw(amount);
assertEq(user.balance, balanceBefore);
}
// Monotonic properties
function test_BalanceNeverDecreases() public {
uint256 balanceBefore = token.balanceOf(user);
// Some operation that should only increase balance
token.mint(user, 100);
assertTrue(token.balanceOf(user) >= balanceBefore);
}
function test_OnlyOwnerCanMint() public {
vm.prank(owner);
token.mint(user, 100); // Should succeed
assertEq(token.balanceOf(user), 100);
}
function test_RevertWhen_NonOwnerMints() public {
vm.prank(user);
vm.expectRevert("Ownable: caller is not the owner");
token.mint(user, 100);
}
function test_OwnershipTransfer() public {
address newOwner = address(0xBEEF);
vm.prank(owner);
token.transferOwnership(newOwner);
assertEq(token.owner(), newOwner);
// New owner can now mint
vm.prank(newOwner);
token.mint(user, 100);
assertEq(token.balanceOf(user), 100);
}
function test_PauseStopsTransfers() public {
// Setup
deal(address(token), user, 1000);
// Pause
vm.prank(owner);
token.pause();
// Try transfer
vm.prank(user);
vm.expectRevert("Pausable: paused");
token.transfer(address(0xBEEF), 100);
}
function test_UnpauseRestoresTransfers() public {
deal(address(token), user, 1000);
vm.prank(owner);
token.pause();
vm.prank(owner);
token.unpause();
// Transfer should work now
vm.prank(user);
token.transfer(address(0xBEEF), 100);
assertEq(token.balanceOf(address(0xBEEF)), 100);
}
contract Attacker {
MyContract public target;
uint256 public attackCount;
constructor(MyContract _target) {
target = _target;
}
function attack() public payable {
target.deposit{value: msg.value}();
target.withdraw(msg.value);
}
receive() external payable {
if (attackCount < 3) {
attackCount++;
target.withdraw(msg.value);
}
}
}
function test_ReentrancyProtection() public {
Attacker attacker = new Attacker(myContract);
vm.deal(address(attacker), 1 ether);
vm.expectRevert("ReentrancyGuard: reentrant call");
attacker.attack{value: 1 ether}();
}
function test_UpgradePreservesStorage() public {
// Deploy V1
MyContractV1 v1 = new MyContractV1();
v1.initialize(owner);
v1.setValue(42);
// Deploy V2
MyContractV2 v2 = new MyContractV2();
// Upgrade
vm.prank(owner);
// Simulate upgrade (depends on proxy pattern)
// Verify storage preserved
assertEq(v2.value(), 42);
}
// Set block timestamp
vm.warp(block.timestamp + 1 days);
// Set block number
vm.roll(block.number + 100);
// Skip time
skip(1 days);
// Rewind time
rewind(1 hours);
// Set msg.sender for next call
vm.prank(user);
// Set msg.sender for all subsequent calls
vm.startPrank(user);
vm.stopPrank();
// Create labeled address
address user = makeAddr("user");
// Give ETH to address
vm.deal(user, 100 ether);
// Set token balance
deal(address(token), user, 1000);
// Expect revert
vm.expectRevert("Error message");
// Expect emit
vm.expectEmit(true, true, false, true);
// Mock calls
vm.mockCall(
address(token),
abi.encodeWithSelector(token.balanceOf.selector, user),
abi.encode(1000)
);
// Take snapshot
uint256 snapshot = vm.snapshot();
// Revert to snapshot
vm.revertTo(snapshot);
Foundry:
# Generate coverage report
forge coverage
# Generate detailed report
forge coverage --report lcov
# Generate HTML report
genhtml lcov.info --output-directory coverage
# View specific file
forge coverage --report debug > coverage.txt
Hardhat:
# Generate coverage
npx hardhat coverage
# Coverage stored in coverage/index.html
Focus on:
Good:
function test_RevertWhen_WithdrawWithInsufficientBalance() public {}
function test_TransferUpdatesBalances() public {}
function testFuzz_CannotOverflowTotalSupply(uint256 amount) public {}
Bad:
function test1() public {}
function testTransfer() public {} // Too generic
function test_withdraw() public {} // Doesn't describe outcome
// ✅ Good: Clear what's being tested
function test_Transfer_UpdatesSenderBalance() public {
uint256 balanceBefore = token.balanceOf(sender);
token.transfer(recipient, 100);
assertEq(token.balanceOf(sender), balanceBefore - 100);
}
function test_Transfer_UpdatesRecipientBalance() public {
uint256 balanceBefore = token.balanceOf(recipient);
token.transfer(recipient, 100);
assertEq(token.balanceOf(recipient), balanceBefore + 100);
}
// ✅ Good: Each test is independent
function test_Scenario1() public {
uint256 snapshot = vm.snapshot();
// Test logic
vm.revertTo(snapshot);
}
function test_Scenario2() public {
// Fresh state from setUp()
}
// ❌ Bad: Magic numbers
function test_Transfer() public {
token.transfer(address(0x123), 42);
}
// ✅ Good: Named constants
function test_Transfer() public {
address recipient = makeAddr("recipient");
uint256 transferAmount = 100 * 10**18; // 100 tokens
token.transfer(recipient, transferAmount);
}
function test_TransferZeroAmount() public {}
function test_TransferMaxAmount() public {}
function test_TransferToZeroAddress() public {}
function test_TransferToSelf() public {}
function test_TransferWithNoBalance() public {}
contract IntegrationTest is Test {
Token public token;
Vault public vault;
Oracle public oracle;
function setUp() public {
token = new Token();
oracle = new Oracle();
vault = new Vault(address(token), address(oracle));
}
function test_DepositAndEarn() public {
// Setup
deal(address(token), user, 1000);
vm.startPrank(user);
// Approve
token.approve(address(vault), 1000);
// Deposit
vault.deposit(1000);
// Warp time
vm.warp(block.timestamp + 30 days);
// Check earnings
uint256 earned = vault.earned(user);
assertTrue(earned > 0);
vm.stopPrank();
}
}
Foundry:
contract ForkTest is Test {
function setUp() public {
vm.createSelectFork(vm.envString("MAINNET_RPC_URL"));
}
function test_InteractWithUniswap() public {
IUniswapV2Router router = IUniswapV2Router(UNISWAP_ROUTER);
// Test against real mainnet contracts
}
}
Hardhat (TypeScript):
import { ethers, network } from "hardhat";
import { IUniswapV2Router } from "../typechain-types";
describe("Fork Test", function () {
before(async function () {
await network.provider.request({
method: "hardhat_reset",
params: [{
forking: {
jsonRpcUrl: process.env.MAINNET_RPC_URL,
blockNumber: 15000000
}
}]
});
});
it("should interact with Uniswap", async function () {
const ROUTER_ADDRESS: string = "0x...";
const router: IUniswapV2Router = await ethers.getContractAt(
"IUniswapV2Router",
ROUTER_ADDRESS
);
// Test
});
});
# Run tests
forge test
# Run specific test
forge test --match-test test_Transfer
# Run with gas report
forge test --gas-report
# Run with coverage
forge coverage
# Run with verbosity
forge test -vvvv
# Fuzz testing
forge test --fuzz-runs 10000
# Run tests
npx hardhat test
# Run specific test
npx hardhat test test/MyContract.test.ts
# With gas reporter
REPORT_GAS=true npx hardhat test
# With coverage
npx hardhat coverage
Remember: Good tests are the first line of defense against bugs and vulnerabilities. Aim for comprehensive coverage, but focus on meaningful test scenarios over arbitrary coverage percentages.