Skip to main content

5、Best Practices

Learn the best practices for writing maintainable, reliable, and efficient smart contract tests.

Test Organization

Directory Structure

test/
├── unit/
│ ├── Token.t.sol
│ └── Vault.t.sol
├── integration/
│ ├── TokenVault.t.sol
│ └── SwapFlow.t.sol
├── invariant/
│ ├── handlers/
│ │ └── TokenHandler.sol
│ └── TokenInvariant.t.sol
├── fork/
│ └── UniswapFork.t.sol
└── helpers/
└── TestHelpers.sol

File Naming Conventions

// ✅ Good
test/unit/Counter.t.sol
test/integration/SwapFlow.t.sol

// ❌ Bad
test/CounterTests.sol
test/test_counter.sol

Test Naming

Descriptive Names

// ✅ Good - Clear what is being tested
function test_Transfer_RevertsWhen_InsufficientBalance() public {}
function test_Deposit_UpdatesBalance() public {}
function testFuzz_Mint_NeverExceedsMaxSupply(uint256 amount) public {}

// ❌ Bad - Unclear purpose
function test1() public {}
function testTransfer() public {}
function test_stuff() public {}

Naming Patterns

// Unit tests
test_<FunctionName>_<ExpectedBehavior>

// Revert tests
test_<FunctionName>_RevertsWhen_<Condition>

// Fuzz tests
testFuzz_<FunctionName>_<Property>

// Invariant tests
invariant_<PropertyDescription>

Test Structure

Arrange-Act-Assert Pattern

function test_Transfer_UpdatesBalances() public {
// Arrange
uint256 amount = 100;
token.mint(alice, amount);
uint256 aliceBalanceBefore = token.balanceOf(alice);
uint256 bobBalanceBefore = token.balanceOf(bob);

// Act
vm.prank(alice);
token.transfer(bob, amount);

// Assert
assertEq(token.balanceOf(alice), aliceBalanceBefore - amount);
assertEq(token.balanceOf(bob), bobBalanceBefore + amount);
}

One Assertion Per Concept

// ✅ Good - Clear failure point
function test_Transfer_DecreasesFromBalance() public {
uint256 balanceBefore = token.balanceOf(alice);
vm.prank(alice);
token.transfer(bob, 100);
assertEq(token.balanceOf(alice), balanceBefore - 100);
}

function test_Transfer_IncreasesToBalance() public {
uint256 balanceBefore = token.balanceOf(bob);
vm.prank(alice);
token.transfer(bob, 100);
assertEq(token.balanceOf(bob), balanceBefore + 100);
}

// ❌ Bad - Multiple concepts
function test_Transfer() public {
vm.prank(alice);
token.transfer(bob, 100);
assertEq(token.balanceOf(alice), 0);
assertEq(token.balanceOf(bob), 100);
assertEq(token.totalSupply(), 100);
}

Setup and Helpers

Effective setUp

contract TokenTest is Test {
Token public token;
address public alice;
address public bob;

uint256 constant INITIAL_SUPPLY = 1000e18;

function setUp() public {
token = new Token();
alice = makeAddr("alice");
bob = makeAddr("bob");

token.mint(alice, INITIAL_SUPPLY);

vm.label(alice, "Alice");
vm.label(bob, "Bob");
}
}

Helper Functions

// ✅ Good helpers
function _mintAndApprove(
address user,
address spender,
uint256 amount
) internal {
token.mint(user, amount);
vm.prank(user);
token.approve(spender, amount);
}

function _setupLiquidity(uint256 amount) internal {
token.mint(address(pool), amount);
pool.sync();
}

// Use in tests
function test_SwapWithHelper() public {
_setupLiquidity(1000e18);
_mintAndApprove(alice, address(router), 100e18);
// ... test swap
}

Modifiers for Common Patterns

modifier funded(address user) {
vm.deal(user, 100 ether);
_;
}

modifier approved(address user, address spender, uint256 amount) {
vm.prank(user);
token.approve(spender, amount);
_;
}

function test_SwapWithModifier()
public
funded(alice)
approved(alice, address(router), 100e18)
{
// Test with setup already done
}

Constants and Configuration

Use Constants

// ✅ Good
uint256 constant INITIAL_BALANCE = 1000e18;
uint256 constant MAX_SUPPLY = 1_000_000e18;
address constant ZERO_ADDRESS = address(0);

function test_Mint() public {
token.mint(alice, INITIAL_BALANCE);
assertEq(token.balanceOf(alice), INITIAL_BALANCE);
}

// ❌ Bad - Magic numbers
function test_Mint() public {
token.mint(alice, 1000000000000000000000);
assertEq(token.balanceOf(alice), 1000000000000000000000);
}

Configuration Contracts

contract TestConfig {
uint256 public constant FORK_BLOCK = 18_000_000;
string public constant MAINNET_RPC = "https://...";

address public constant UNISWAP_ROUTER = 0x7a250d5630...;
address public constant WETH = 0xC02aaA39b223FE8D...;
}

contract MyTest is Test, TestConfig {
// Use inherited constants
}

Documentation

Documenting Tests

/// @notice Test that transfer correctly updates balances
/// @dev This test verifies the core transfer functionality
function test_Transfer_UpdatesBalances() public {
// Given: Alice has 100 tokens
uint256 amount = 100;
token.mint(alice, amount);

// When: Alice transfers to Bob
vm.prank(alice);
token.transfer(bob, amount);

// Then: Balances are updated correctly
assertEq(token.balanceOf(alice), 0);
assertEq(token.balanceOf(bob), amount);
}

Inline Comments

function test_ComplexScenario() public {
// Setup initial state
_setupLiquidity(1000e18);

// First swap: Alice buys tokens
vm.prank(alice);
router.swap(tokenA, tokenB, 100e18);

// Price should have moved
uint256 priceAfterSwap = pool.getPrice();
assertGt(priceAfterSwap, initialPrice);

// Second swap: Bob sells tokens (opposite direction)
vm.prank(bob);
router.swap(tokenB, tokenA, 50e18);

// Price should move back but not to original
uint256 finalPrice = pool.getPrice();
assertLt(finalPrice, priceAfterSwap);
assertGt(finalPrice, initialPrice);
}

Error Handling

Clear Revert Messages

// ✅ Good
function test_Transfer_RevertsWhen_InsufficientBalance() public {
vm.prank(alice);
vm.expectRevert("Insufficient balance");
token.transfer(bob, 1000);
}

// For custom errors
function test_RevertsWhen_Unauthorized() public {
vm.expectRevert(Unauthorized.selector);
token.adminFunction();
}

Testing All Revert Conditions

function test_Transfer_RevertsWhen_InsufficientBalance() public {
vm.expectRevert("Insufficient balance");
token.transfer(bob, 1000);
}

function test_Transfer_RevertsWhen_ZeroAddress() public {
vm.expectRevert("Invalid recipient");
token.transfer(address(0), 100);
}

function test_Transfer_RevertsWhen_ZeroAmount() public {
vm.expectRevert("Invalid amount");
token.transfer(bob, 0);
}

State Management

Use Snapshots for Complex Tests

function test_MultipleScenarios() public {
uint256 checkpoint = vm.snapshot();

// Scenario 1
_testScenario1();

// Reset to checkpoint
vm.revertTo(checkpoint);
checkpoint = vm.snapshot();

// Scenario 2
_testScenario2();
}

Clean State Between Tests

// setUp() runs before each test automatically
function setUp() public {
// Fresh state for each test
token = new Token();
alice = makeAddr("alice");
}

Performance

Efficient Test Organization

// ✅ Good - Specific test selection
forge test --match-contract TokenTest --match-test test_Transfer

// ✅ Good - Parallel execution (default)
forge test

// ❌ Bad - Running all tests when debugging one
forge test -vvvv // Very slow

Optimize Setup

// ✅ Good - Minimal setup
function setUp() public {
token = new Token();
}

// ❌ Bad - Unnecessary setup
function setUp() public {
token = new Token();
// Don't deploy contracts not used in all tests
unusedContract = new UnusedContract();
anotherUnused = new AnotherContract();
}

Security Testing

Test Edge Cases

function test_EdgeCase_MaxUint256() public {
vm.expectRevert();
token.mint(alice, type(uint256).max);
}

function test_EdgeCase_ZeroAmount() public {
token.transfer(bob, 0);
assertEq(token.balanceOf(bob), 0);
}

Test Access Control

function test_OnlyOwner_CanMint() public {
vm.prank(owner);
token.mint(alice, 100); // Should succeed

vm.prank(alice);
vm.expectRevert("Not owner");
token.mint(bob, 100); // Should fail
}

Code Reuse

Base Test Contracts

abstract contract BaseTest is Test {
address public alice = makeAddr("alice");
address public bob = makeAddr("bob");

function _dealAndApprove(
address user,
address token,
address spender,
uint256 amount
) internal {
deal(token, user, amount);
vm.prank(user);
IERC20(token).approve(spender, amount);
}
}

contract TokenTest is BaseTest {
// Inherits common functionality
}

Next Steps

References

📢 Share this article