Initial commit
This commit is contained in:
635
skills/skill/references/testing.md
Normal file
635
skills/skill/references/testing.md
Normal file
@@ -0,0 +1,635 @@
|
||||
# Foundry Testing Guide
|
||||
|
||||
Complete guide to testing smart contracts with Forge.
|
||||
|
||||
## Test Structure
|
||||
|
||||
### File Conventions
|
||||
|
||||
- Test files: `ContractName.t.sol`
|
||||
- Script files: `ScriptName.s.sol`
|
||||
- Test contracts inherit from `Test`
|
||||
|
||||
### Function Prefixes
|
||||
|
||||
| Prefix | Purpose |
|
||||
|--------|---------|
|
||||
| `test_` | Unit test |
|
||||
| `testFuzz_` | Fuzz test |
|
||||
| `testFork_` | Fork test |
|
||||
| `invariant_` | Invariant test |
|
||||
| `test_RevertIf_` | Expected revert |
|
||||
| `test_RevertWhen_` | Expected revert |
|
||||
|
||||
### Basic Test Contract
|
||||
|
||||
```solidity
|
||||
// SPDX-License-Identifier: MIT
|
||||
pragma solidity ^0.8.30;
|
||||
|
||||
import {Test, console} from "forge-std/Test.sol";
|
||||
import {Vault} from "../src/Vault.sol";
|
||||
|
||||
contract VaultTest is Test {
|
||||
Vault public vault;
|
||||
address public alice;
|
||||
address public bob;
|
||||
|
||||
function setUp() public {
|
||||
vault = new Vault();
|
||||
alice = makeAddr("alice");
|
||||
bob = makeAddr("bob");
|
||||
deal(alice, 100 ether);
|
||||
deal(bob, 100 ether);
|
||||
}
|
||||
|
||||
function test_Deposit() public {
|
||||
vm.prank(alice);
|
||||
vault.deposit{value: 1 ether}();
|
||||
assertEq(vault.balanceOf(alice), 1 ether);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Unit Testing
|
||||
|
||||
### Assertions
|
||||
|
||||
```solidity
|
||||
// Equality
|
||||
assertEq(actual, expected);
|
||||
assertEq(actual, expected, "custom message");
|
||||
assertNotEq(actual, expected);
|
||||
|
||||
// Comparisons
|
||||
assertGt(a, b); // a > b
|
||||
assertGe(a, b); // a >= b
|
||||
assertLt(a, b); // a < b
|
||||
assertLe(a, b); // a <= b
|
||||
|
||||
// Boolean
|
||||
assertTrue(condition);
|
||||
assertFalse(condition);
|
||||
|
||||
// Approximation (for rounding)
|
||||
assertApproxEqAbs(actual, expected, maxDelta);
|
||||
assertApproxEqRel(actual, expected, maxPercentDelta); // 1e18 = 100%
|
||||
|
||||
// Arrays
|
||||
assertEq(arr1, arr2);
|
||||
```
|
||||
|
||||
### Testing Reverts
|
||||
|
||||
```solidity
|
||||
// Expect any revert
|
||||
vm.expectRevert();
|
||||
vault.withdraw(1000 ether);
|
||||
|
||||
// Expect specific message
|
||||
vm.expectRevert("Insufficient balance");
|
||||
vault.withdraw(1000 ether);
|
||||
|
||||
// Expect custom error (selector only)
|
||||
vm.expectRevert(InsufficientBalance.selector);
|
||||
vault.withdraw(1000 ether);
|
||||
|
||||
// Expect custom error with parameters
|
||||
vm.expectRevert(
|
||||
abi.encodeWithSelector(
|
||||
InsufficientBalance.selector,
|
||||
0, // available
|
||||
1000 // required
|
||||
)
|
||||
);
|
||||
vault.withdraw(1000 ether);
|
||||
|
||||
// Partial revert (match selector only)
|
||||
vm.expectPartialRevert(InsufficientBalance.selector);
|
||||
vault.withdraw(1000 ether);
|
||||
```
|
||||
|
||||
### Testing Events
|
||||
|
||||
```solidity
|
||||
// Expect event emission
|
||||
vm.expectEmit(true, true, false, true);
|
||||
// topic1, topic2, topic3, data
|
||||
emit Transfer(alice, bob, 100);
|
||||
|
||||
// Call that should emit the event
|
||||
token.transfer(bob, 100);
|
||||
|
||||
// Record and inspect logs
|
||||
vm.recordLogs();
|
||||
token.transfer(bob, 100);
|
||||
Vm.Log[] memory logs = vm.getRecordedLogs();
|
||||
|
||||
assertEq(logs[0].topics[0], keccak256("Transfer(address,address,uint256)"));
|
||||
```
|
||||
|
||||
### Testing Access Control
|
||||
|
||||
```solidity
|
||||
function test_OnlyOwner() public {
|
||||
// Should succeed as owner
|
||||
vault.adminFunction();
|
||||
|
||||
// Should fail as non-owner
|
||||
vm.expectRevert("Ownable: caller is not the owner");
|
||||
vm.prank(alice);
|
||||
vault.adminFunction();
|
||||
}
|
||||
```
|
||||
|
||||
## Fuzz Testing
|
||||
|
||||
Forge automatically generates random inputs for test parameters.
|
||||
|
||||
### Basic Fuzz Test
|
||||
|
||||
```solidity
|
||||
function testFuzz_Deposit(uint256 amount) public {
|
||||
// Bound input to valid range
|
||||
amount = bound(amount, 0.01 ether, 100 ether);
|
||||
|
||||
deal(alice, amount);
|
||||
vm.prank(alice);
|
||||
vault.deposit{value: amount}();
|
||||
|
||||
assertEq(vault.balanceOf(alice), amount);
|
||||
}
|
||||
```
|
||||
|
||||
### Fuzz Configuration
|
||||
|
||||
```toml
|
||||
# foundry.toml
|
||||
[fuzz]
|
||||
runs = 256 # Number of fuzz runs
|
||||
seed = 42 # Deterministic seed (optional)
|
||||
max_test_rejects = 65536
|
||||
```
|
||||
|
||||
### Fixtures (Pre-defined Values)
|
||||
|
||||
```solidity
|
||||
// Array fixture (must match parameter name)
|
||||
uint256[] public fixtureAmount = [0, 1, 100, type(uint256).max];
|
||||
|
||||
// Function fixture
|
||||
function fixtureUser() public returns (address[] memory) {
|
||||
address[] memory users = new address[](3);
|
||||
users[0] = makeAddr("alice");
|
||||
users[1] = makeAddr("bob");
|
||||
users[2] = address(0);
|
||||
return users;
|
||||
}
|
||||
|
||||
function testFuzz_Transfer(uint256 amount, address user) public {
|
||||
// amount will be one of: 0, 1, 100, max
|
||||
// user will be one of: alice, bob, address(0)
|
||||
}
|
||||
```
|
||||
|
||||
### Using `vm.assume()`
|
||||
|
||||
```solidity
|
||||
function testFuzz_Transfer(address recipient) public {
|
||||
// Skip invalid inputs
|
||||
vm.assume(recipient != address(0));
|
||||
vm.assume(recipient != address(token));
|
||||
|
||||
// Or use helper
|
||||
assumeNotZeroAddress(recipient);
|
||||
assumeNotPrecompile(recipient);
|
||||
}
|
||||
```
|
||||
|
||||
## Invariant Testing
|
||||
|
||||
Test that properties hold across random function call sequences.
|
||||
|
||||
### Basic Invariant Test
|
||||
|
||||
```solidity
|
||||
contract VaultInvariantTest is Test {
|
||||
Vault public vault;
|
||||
VaultHandler public handler;
|
||||
|
||||
function setUp() public {
|
||||
vault = new Vault();
|
||||
handler = new VaultHandler(vault);
|
||||
|
||||
// Only fuzz the handler
|
||||
targetContract(address(handler));
|
||||
}
|
||||
|
||||
function invariant_SolvencyCheck() public view {
|
||||
assertGe(
|
||||
address(vault).balance,
|
||||
vault.totalDeposits(),
|
||||
"Vault is insolvent"
|
||||
);
|
||||
}
|
||||
|
||||
function invariant_TotalSupplyConsistent() public view {
|
||||
assertEq(
|
||||
vault.totalSupply(),
|
||||
sumAllBalances(),
|
||||
"Supply mismatch"
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Handler Pattern
|
||||
|
||||
Wrap target contract to bound inputs and track state:
|
||||
|
||||
```solidity
|
||||
contract VaultHandler is Test {
|
||||
Vault public vault;
|
||||
uint256 public ghost_totalDeposited;
|
||||
address[] public actors;
|
||||
|
||||
constructor(Vault _vault) {
|
||||
vault = _vault;
|
||||
actors.push(makeAddr("alice"));
|
||||
actors.push(makeAddr("bob"));
|
||||
actors.push(makeAddr("charlie"));
|
||||
}
|
||||
|
||||
function deposit(uint256 actorIndex, uint256 amount) external {
|
||||
// Bound inputs
|
||||
actorIndex = bound(actorIndex, 0, actors.length - 1);
|
||||
amount = bound(amount, 0.01 ether, 10 ether);
|
||||
|
||||
address actor = actors[actorIndex];
|
||||
deal(actor, amount);
|
||||
|
||||
vm.prank(actor);
|
||||
vault.deposit{value: amount}();
|
||||
|
||||
// Track ghost variable
|
||||
ghost_totalDeposited += amount;
|
||||
}
|
||||
|
||||
function withdraw(uint256 actorIndex, uint256 amount) external {
|
||||
actorIndex = bound(actorIndex, 0, actors.length - 1);
|
||||
address actor = actors[actorIndex];
|
||||
|
||||
uint256 balance = vault.balanceOf(actor);
|
||||
amount = bound(amount, 0, balance);
|
||||
|
||||
vm.prank(actor);
|
||||
vault.withdraw(amount);
|
||||
|
||||
ghost_totalDeposited -= amount;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Target Configuration
|
||||
|
||||
```solidity
|
||||
function setUp() public {
|
||||
// Only fuzz specific contracts
|
||||
targetContract(address(handler));
|
||||
|
||||
// Exclude contracts from fuzzing
|
||||
excludeContract(address(vault));
|
||||
excludeContract(address(token));
|
||||
|
||||
// Only use specific senders
|
||||
targetSender(alice);
|
||||
targetSender(bob);
|
||||
|
||||
// Exclude specific senders
|
||||
excludeSender(address(0));
|
||||
|
||||
// Target specific functions
|
||||
bytes4[] memory selectors = new bytes4[](2);
|
||||
selectors[0] = handler.deposit.selector;
|
||||
selectors[1] = handler.withdraw.selector;
|
||||
targetSelector(FuzzSelector({
|
||||
addr: address(handler),
|
||||
selectors: selectors
|
||||
}));
|
||||
}
|
||||
```
|
||||
|
||||
### Invariant Configuration
|
||||
|
||||
```toml
|
||||
# foundry.toml
|
||||
[invariant]
|
||||
runs = 256 # Number of sequences
|
||||
depth = 50 # Calls per sequence
|
||||
fail_on_revert = false # Continue on reverts
|
||||
shrink_run_limit = 5000 # Shrinking iterations
|
||||
```
|
||||
|
||||
## Fork Testing
|
||||
|
||||
Test against live blockchain state.
|
||||
|
||||
### Basic Fork Test
|
||||
|
||||
```solidity
|
||||
contract ForkTest is Test {
|
||||
uint256 mainnetFork;
|
||||
address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48;
|
||||
address constant WHALE = 0x47ac0Fb4F2D84898e4D9E7b4DaB3C24507a6D503;
|
||||
|
||||
function setUp() public {
|
||||
mainnetFork = vm.createFork(vm.envString("MAINNET_RPC_URL"));
|
||||
vm.selectFork(mainnetFork);
|
||||
}
|
||||
|
||||
function testFork_USDCTransfer() public {
|
||||
vm.prank(WHALE);
|
||||
IERC20(USDC).transfer(address(this), 1000e6);
|
||||
assertEq(IERC20(USDC).balanceOf(address(this)), 1000e6);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Fork Cheatcodes
|
||||
|
||||
```solidity
|
||||
// Create fork
|
||||
uint256 forkId = vm.createFork("https://eth-mainnet.alchemyapi.io/v2/...");
|
||||
uint256 forkId = vm.createFork("mainnet"); // Uses foundry.toml rpc_endpoints
|
||||
uint256 forkId = vm.createFork("mainnet", 18000000); // Pin to block
|
||||
|
||||
// Create and select in one call
|
||||
vm.createSelectFork("mainnet");
|
||||
|
||||
// Switch between forks
|
||||
vm.selectFork(mainnetFork);
|
||||
vm.selectFork(arbitrumFork);
|
||||
|
||||
// Get active fork
|
||||
uint256 active = vm.activeFork();
|
||||
|
||||
// Roll fork to different block
|
||||
vm.rollFork(18500000);
|
||||
vm.rollFork(arbitrumFork, 150000000);
|
||||
|
||||
// Make account persist across forks
|
||||
vm.makePersistent(address(myContract));
|
||||
vm.makePersistent(alice, bob, charlie);
|
||||
|
||||
// Check persistence
|
||||
bool isPersistent = vm.isPersistent(address(myContract));
|
||||
|
||||
// Revoke persistence
|
||||
vm.revokePersistent(address(myContract));
|
||||
```
|
||||
|
||||
### Multi-Fork Testing
|
||||
|
||||
```solidity
|
||||
function testFork_CrossChain() public {
|
||||
// Deploy on mainnet
|
||||
vm.selectFork(mainnetFork);
|
||||
address mainnetToken = address(new Token());
|
||||
|
||||
// Deploy on Arbitrum
|
||||
vm.selectFork(arbitrumFork);
|
||||
address arbitrumToken = address(new Token());
|
||||
|
||||
// Verify different deployments
|
||||
vm.selectFork(mainnetFork);
|
||||
assertEq(Token(mainnetToken).name(), "Token");
|
||||
|
||||
vm.selectFork(arbitrumFork);
|
||||
assertEq(Token(arbitrumToken).name(), "Token");
|
||||
}
|
||||
```
|
||||
|
||||
### Fork Configuration
|
||||
|
||||
```toml
|
||||
# foundry.toml
|
||||
[rpc_endpoints]
|
||||
mainnet = "${MAINNET_RPC_URL}"
|
||||
sepolia = "${SEPOLIA_RPC_URL}"
|
||||
arbitrum = "${ARBITRUM_RPC_URL}"
|
||||
optimism = "${OPTIMISM_RPC_URL}"
|
||||
```
|
||||
|
||||
## Differential Testing
|
||||
|
||||
Compare implementations against reference.
|
||||
|
||||
```solidity
|
||||
function testDifferential_MerkleRoot(bytes32[] memory leaves) public {
|
||||
vm.assume(leaves.length > 0 && leaves.length < 100);
|
||||
|
||||
// Solidity implementation
|
||||
bytes32 solidityRoot = merkle.getRoot(leaves);
|
||||
|
||||
// Reference implementation (via FFI)
|
||||
string[] memory cmd = new string[](3);
|
||||
cmd[0] = "node";
|
||||
cmd[1] = "scripts/merkle.js";
|
||||
cmd[2] = vm.toString(abi.encode(leaves));
|
||||
|
||||
bytes memory result = vm.ffi(cmd);
|
||||
bytes32 jsRoot = abi.decode(result, (bytes32));
|
||||
|
||||
assertEq(solidityRoot, jsRoot, "Merkle root mismatch");
|
||||
}
|
||||
```
|
||||
|
||||
## Complete Cheatcode Reference
|
||||
|
||||
### State Manipulation
|
||||
|
||||
```solidity
|
||||
// ETH balance
|
||||
vm.deal(address, uint256);
|
||||
|
||||
// Code at address
|
||||
vm.etch(address, bytes memory code);
|
||||
|
||||
// Storage
|
||||
vm.store(address, bytes32 slot, bytes32 value);
|
||||
bytes32 value = vm.load(address, bytes32 slot);
|
||||
|
||||
// Nonce
|
||||
vm.setNonce(address, uint64 nonce);
|
||||
vm.resetNonce(address);
|
||||
uint64 nonce = vm.getNonce(address);
|
||||
|
||||
// Copy storage
|
||||
vm.copyStorage(address from, address to);
|
||||
```
|
||||
|
||||
### Caller Context
|
||||
|
||||
```solidity
|
||||
// Single call
|
||||
vm.prank(address sender);
|
||||
vm.prank(address sender, address origin);
|
||||
|
||||
// Multiple calls
|
||||
vm.startPrank(address sender);
|
||||
vm.startPrank(address sender, address origin);
|
||||
vm.stopPrank();
|
||||
```
|
||||
|
||||
### Time & Block
|
||||
|
||||
```solidity
|
||||
vm.warp(uint256 timestamp); // block.timestamp
|
||||
vm.roll(uint256 blockNumber); // block.number
|
||||
vm.fee(uint256 gasPrice); // tx.gasprice
|
||||
vm.difficulty(uint256 difficulty); // block.difficulty
|
||||
vm.coinbase(address miner); // block.coinbase
|
||||
vm.chainId(uint256 chainId); // block.chainid
|
||||
vm.prevrandao(bytes32 prevrandao); // block.prevrandao
|
||||
```
|
||||
|
||||
### Expectations
|
||||
|
||||
```solidity
|
||||
// Reverts
|
||||
vm.expectRevert();
|
||||
vm.expectRevert(bytes memory message);
|
||||
vm.expectRevert(bytes4 selector);
|
||||
vm.expectPartialRevert(bytes4 selector);
|
||||
|
||||
// Events
|
||||
vm.expectEmit(bool topic1, bool topic2, bool topic3, bool data);
|
||||
vm.expectEmit(bool topic1, bool topic2, bool topic3, bool data, address emitter);
|
||||
|
||||
// Calls
|
||||
vm.expectCall(address target, bytes memory data);
|
||||
vm.expectCall(address target, uint256 value, bytes memory data);
|
||||
vm.expectCall(address target, bytes memory data, uint64 count);
|
||||
```
|
||||
|
||||
### Snapshots
|
||||
|
||||
```solidity
|
||||
uint256 snapshot = vm.snapshot();
|
||||
vm.revertTo(snapshot);
|
||||
vm.revertToAndDelete(snapshot);
|
||||
```
|
||||
|
||||
### Environment
|
||||
|
||||
```solidity
|
||||
// Read .env
|
||||
string memory value = vm.envString("KEY");
|
||||
uint256 value = vm.envUint("KEY");
|
||||
address value = vm.envAddress("KEY");
|
||||
bool value = vm.envBool("KEY");
|
||||
bytes32 value = vm.envBytes32("KEY");
|
||||
|
||||
// With default
|
||||
uint256 value = vm.envOr("KEY", uint256(100));
|
||||
|
||||
// Check existence
|
||||
bool exists = vm.envExists("KEY");
|
||||
```
|
||||
|
||||
### Cryptography
|
||||
|
||||
```solidity
|
||||
// Sign message
|
||||
(uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, digest);
|
||||
(bytes32 r, bytes32 vs) = vm.signCompact(privateKey, digest);
|
||||
|
||||
// Get address from private key
|
||||
address addr = vm.addr(privateKey);
|
||||
|
||||
// Derive key from mnemonic
|
||||
uint256 key = vm.deriveKey(mnemonic, index);
|
||||
uint256 key = vm.deriveKey(mnemonic, path, index);
|
||||
|
||||
// Create wallet
|
||||
Vm.Wallet memory wallet = vm.createWallet("label");
|
||||
Vm.Wallet memory wallet = vm.createWallet(privateKey);
|
||||
```
|
||||
|
||||
### Labels & Debugging
|
||||
|
||||
```solidity
|
||||
vm.label(address, "name");
|
||||
string memory name = vm.getLabel(address);
|
||||
|
||||
vm.breakpoint(); // Debugger breakpoint
|
||||
vm.breakpoint("name"); // Named breakpoint
|
||||
```
|
||||
|
||||
### Gas Metering
|
||||
|
||||
```solidity
|
||||
vm.pauseGasMetering();
|
||||
// ... expensive operations ...
|
||||
vm.resumeGasMetering();
|
||||
|
||||
Vm.Gas memory gas = vm.lastCallGas();
|
||||
```
|
||||
|
||||
### File I/O
|
||||
|
||||
```solidity
|
||||
string memory content = vm.readFile("path/to/file");
|
||||
vm.writeFile("path/to/file", "content");
|
||||
bool exists = vm.exists("path/to/file");
|
||||
vm.removeFile("path/to/file");
|
||||
```
|
||||
|
||||
## Test Verbosity Levels
|
||||
|
||||
```bash
|
||||
forge test # Summary only
|
||||
forge test -v # + Logs
|
||||
forge test -vv # + Assertion errors
|
||||
forge test -vvv # + Stack traces (failures)
|
||||
forge test -vvvv # + Stack traces (all) + setup
|
||||
forge test -vvvvv # + All traces always
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Test Organization
|
||||
|
||||
1. One test file per contract
|
||||
2. Group related tests in same contract
|
||||
3. Use descriptive function names
|
||||
4. Include failure reason in test name
|
||||
|
||||
### Fuzz Testing
|
||||
|
||||
1. Always `bound()` numeric inputs
|
||||
2. Use `vm.assume()` sparingly (reduces coverage)
|
||||
3. Use fixtures for edge cases
|
||||
4. Set deterministic seed in CI
|
||||
|
||||
### Invariant Testing
|
||||
|
||||
1. Use handler pattern for complex protocols
|
||||
2. Track ghost variables for assertions
|
||||
3. Start with simple invariants
|
||||
4. Increase depth/runs gradually
|
||||
|
||||
### Fork Testing
|
||||
|
||||
1. Pin to specific block for reproducibility
|
||||
2. Configure RPC endpoints in foundry.toml
|
||||
3. Use `vm.makePersistent()` for deployed contracts
|
||||
4. Cache responses with deterministic seed
|
||||
|
||||
### General
|
||||
|
||||
1. Test public interface, not internal state
|
||||
2. Use `makeAddr()` for labeled addresses
|
||||
3. Use `deal()` instead of minting
|
||||
4. Label important addresses for traces
|
||||
5. Test both success and failure paths
|
||||
Reference in New Issue
Block a user