Test Writing Guide
SkillFiles & storageWrite or review Olympus V3 Solidity tests, including external-function coverage matrices, authorization, enabled-state, numeric boundaries, state transitions, accounting, preview/write consistency, fuzzing, invariants, file structure, naming, and error handling.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Test Writing Guide skill
What this skill tells your AI
The instructions your AI receives, as published by olympusdao/olympus-v3 in .claude/skills/test-write/SKILL.md and read by ahel’s review.
This guide covers the standards for writing test files in the Olympus V3 codebase.
File Organization
Organize Around External Actions
Give each external state-changing action a dedicated test file. Test internal functions indirectly through their external callers. Co-locate a directly corresponding preview or getter with the action when it predicts or exposes the state established by that action and coverage is preserved. Keep a standalone file when a read function has independent behavior that is not naturally established by one action.
Examples:
src/test/modules/DEPOS/mint.t.sol- Tests for themint()functionsrc/test/modules/DEPOS/burn.t.sol- Tests for theburn()functionsrc/test/modules/MINTR/periodicTasks.t.sol- Tests for periodic task functions
File naming:
- Use lowercase, descriptive names:
mint.t.sol,addPeriodicTask.t.sol - Use
.t.solextension for test files - Match the primary external action being tested
Base test contracts:
- Create a parent test contract for each contract (e.g.,
DEPOSTest.sol) - Parent contract contains setup functions, common assertions, helper functions, and state modifiers
- Individual test files inherit from the parent
- Parent contract is marked
abstractsince it's never instantiated directly
// src/test/modules/DEPOS/DEPOSTest.sol - Parent contract
abstract contract DEPOSTest {
// =======================================================================
// State Variables (accessible to all child tests)
// =======================================================================
IDepositPositionManager public DEPOS;
address public godmode;
address public admin;
address public user;
uint256 internal _positionId;
// =======================================================================
// setUp() - Contract deployment and initial configuration
// =======================================================================
function setUp() public virtual {
// Deploy contracts
godmode = address(this);
admin = makeAddr("admin");
user = makeAddr("user");
vm.startPrank(godmode);
DEPOS = IDepositPositionManager(address(new DepositPositionManager()));
vm.stopPrank();
}
// =======================================================================
// Helper Functions - Common operations used across tests
// =======================================================================
function _createPosition(
address owner_,
uint256 amount_,
uint256 conversionPrice_
) internal returns (uint256 positionId_) {
vm.prank(owner_);
positionId_ = DEPOS.mint(amount_, conversionPrice_);
return positionId_;
}
function _dealTokens(address to_, uint256 amount_) internal {
deal(address(TOKEN), to_, amount_);
}
function _warp(uint256 timestamp_) internal {
vm.warp(timestamp_);
}
// =======================================================================
// Assertion Helpers - Common state checks
// =======================================================================
function _assertPosition(
uint256 positionId_,
address expectedOwner_,
uint256 expectedRemaining_
) internal view {
(address owner,, uint256 remaining,,,) = DEPOS.positions(positionId_);
assertEq(owner, expectedOwner_, "position owner mismatch");
assertEq(remaining, expectedRemaining_, "position remaining mismatch");
}
// =======================================================================
// State Modifiers - Establish commonly-used test states
// =======================================================================
modifier givenPositionExists() {
_positionId = _createPosition(user, 100e18, 1e9);
_;
}
modifier givenContractIsEnabled() {
vm.prank(admin);
DEPOS.setEnabled(true);
_;
}
}
// src/test/modules/DEPOS/mint.t.sol - Inherits from parent
contract MintTest is DEPOSTest {
function test_whenAmountIsZero_reverts() public {
// Can use DEPOS, godmode, admin, user directly
// Can call _createPosition(), _dealTokens(), etc.
// Can apply modifiers like givenPositionExists
}
}
Parent contract structure:
- State variables - Shared contract addresses, test accounts
- setUp() - Deploy contracts, set initial state
- Helper functions -
_createPosition(),_dealTokens(),_warp() - Assertion helpers -
_assertPosition(),_assertBalance() - State modifiers -
givenPositionExists(),givenContractIsEnabled()
This keeps child test files focused on the specific function being tested, while all common setup and utilities live in the parent.
Test Modifiers for State Setup
State modifiers are defined in the parent test contract and used by child tests to establish commonly-used states.
Naming Convention
Two modifier prefixes with distinct purposes:
| Prefix | Purpose | Example |
|---|---|---|
given* | Establish existing state (objects, contracts, configuration) | givenPositionExists, givenContractIsEnabled |
when* | Denote parameter has a specific value or property | whenAmountIsZero, whenCallerIsNotAdmin |
given* modifiers - Set up state before the test:
// Creates a position before test runs
modifier givenPositionExists() {
_positionId = _createPosition(user, 100e18, 1e9);
_;
}
// Enables the contract before test runs
modifier givenContractIsEnabled() {
vm.prank(admin);
DEPOS.setEnabled(true);
_;
}
when* modifiers - Describe parameter conditions (less common, but useful for clarity):
// Indicates the test uses zero amount
modifier whenAmountIsZero() {
_; // Just a marker, actual zero passed in test
}
// Indicates the caller is not admin
modifier whenCallerIsNotAdmin() {
_; // Just a marker, actual caller set in test
}
// Usage example
function test_givenPositionExists_whenAmountIsZero_reverts() public
givenPositionExists
whenAmountIsZero
{
// Position exists (given*)
// Amount is zero (when*)
vm.expectRevert(abi.encodeWithSelector(IContract.CONTRACT_InvalidAmount.selector));
DEPOS.burn(_positionId, 0);
}
Common modifier patterns:
// GOOD - Clear state setup via modifier
modifier givenPositionExists() {
_positionId = _createPosition(user, 100e18, 1e9);
_;
}
modifier givenContractIsEnabled() {
vm.prank(admin);
contract.setEnabled(true);
_;
}
modifier givenUserHasAllowance(address owner_, uint256 amount_) {
vm.prank(owner_);
token.approve(spender, amount_);
_;
}
Usage in Tests
function test_givenPositionExists_whenAmountIsZero_reverts() public givenPositionExists {
// Test logic here - position already exists
}
Anti-Patterns to Avoid
// BAD - Setting state in each test
function test_something() public {
vm.prank(admin);
contract.setEnabled(true);
// ... test logic
}
function test_anotherThing() public {
vm.prank(admin);
contract.setEnabled(true);
// ... test logic
}
// GOOD - Use modifier instead
function test_something() public givenContractIsEnabled {
// ... test logic
}
function test_anotherThing() public givenContractIsEnabled {
// ... test logic
}
External Function Coverage Matrix
Before implementation, classify every changed external function against each category below. Add focused tests for every applicable matrix cell. Record why a category is not applicable instead of silently omitting it.
Caller Authorization
- Cover every permitted caller class separately.
- Fuzz unauthorized callers. Exclude every authorized identity and any address with distinct
semantics, such as
address(0), withvm.assume(). - Fuzz the caller for permissionless functions to prove execution does not depend on one fixed address.
- Cover direct and delegated authorization, expiry, cancellation, and self-authorization when the contract supports them.
function test_whenCallerIsNotAuthorized_reverts(address caller_) public {
vm.assume(caller_ != owner);
vm.assume(caller_ != operator);
vm.assume(caller_ != address(0));
vm.expectRevert(abi.encodeWithSelector(IContract.NotAuthorized.selector));
vm.prank(caller_);
target.restrictedAction();
}
function test_givenActionIsReady(address caller_) public {
vm.prank(caller_);
target.permissionlessAction();
assertTrue(target.actionExecuted(), "action should be executed");
}
Contract State
Classify each function as enabled-only, intentionally callable while disabled, independent of the enabled state, or dependent on re-enable validation. Test enabled and disabled behavior explicitly. Test the re-enabled state separately when re-enablement can refresh dependencies or assumptions.
Input Boundaries
- Cover zero, one, the semantic minimum and maximum, and representable values immediately below
and above each limit. For every numeric input, explicitly test its maximum representable value,
such as
type(uint256).max, whether it should succeed or revert. - For a cap
L, normally prove that the exact cap succeeds andL + 1reverts. - Cover zero/non-zero addresses, empty/single-item/maximum-size collections, duplicate entries, malformed encodings, and enum or selector boundaries when relevant.
- Cover rounding thresholds and decimal-scale combinations when arithmetic behavior can change.
- Choose each fuzzed numeric range from the behavior being proved, including valid and invalid intervals. One interior fuzz case does not prove boundary behavior.
- Use
bound()to map numeric inputs into that exact target interval. Never map an invalid-input test into the valid interval, and keep explicit boundary tests alongside range fuzzing.
State Transitions
Cover applicable pairs such as absent/existing, uninitialized/initialized, inactive/active, current/stale, before/at/after a transition, and first/subsequent execution. Assert the resulting state; proving that an operation was queued or previewed is not proof that the transition succeeds.
Accounting And External Interactions
- Assert authoritative balance, custody, or share deltas rather than nominal input amounts.
- Assert affected per-user and aggregate accounting together.
- Document and verify rounding direction.
- Verify complete rollback when an external interaction fails.
- Exercise callback or token reentrancy where the function crosses an untrusted boundary.
Read/Write Consistency
For previews and getters that correspond to a state-changing action, verify both successful output and equivalent failure conditions beside the action tests. Do not remove unique read-path coverage when consolidating files.
Invariant Tests
Add stateful invariant tests when correctness depends on relationships that must survive arbitrary call sequences. Strong candidates include aggregate accounting equaling the sum of positions, custody covering liabilities, lifecycle transitions preserving reachability, authorization never expanding unexpectedly, and enabled-state transitions preserving safety. Exercise realistic handler actions and actors. Keep explicit unit tests for individual boundaries and revert paths; invariant tests complement rather than replace them.
Branching Tree Test Naming
Use the branching tree pattern to organize tests by conditions and behaviors. Use given only for
pre-existing state and when only for function parameters or other inputs to the operation:
// given <condition>
// when <parameter>
// [X] it <expected result>
function test_given<Condition>_when<Parameter>() {
// test code with multiple assertions
}
Nested conditions - Document the branching structure in comments:
// given vault is below capacity
// when amount exceeds remaining capacity
// [X] it reverts
// when amount equals remaining capacity
// [X] it mints shares
// [X] it emits Deposit event
function test_givenVaultBelowCapacity_whenAmountExceedsRemainingCapacity_reverts() public {
// test code
}
function test_givenVaultBelowCapacity_whenAmountEqualsRemainingCapacity() public {
// test code
}
Multiple conditions in function name: A test can have multiple given* and/or when* segments.
Repeat the prefix for every condition, including multiple parameter conditions such as
whenCondition1_whenCondition2:
// Multiple given* conditions:
function test_givenPositionExists_givenContractIsEnabled() public {
// Position exists AND contract is enabled
}
// Multiple when* conditions:
function test_whenAmountIsZero_whenCallerIsNotOwner() public {
// Amount and caller are both function inputs
}
// Both given* and when* conditions:
function test_givenPositionExists_givenContractIsEnabled_whenAmountExceedsRemaining() public {
// Position exists, contract enabled, amount exceeds remaining
}
Parameterized Tests (first condition is when)
Tests that primarily vary input data parameters use when as the first condition. The key distinction:
| Prefix | Meaning | Example |
|---|---|---|
given* | Pre-existing state | givenPositionExists, givenContractIsEnabled |
when* | Function parameters or other operation inputs | whenThreePrices, whenAmountIsZero, whenStrictMode |
// when input has 3 prices
// when zero deviate from median
// [X] it returns average of all prices
function test_whenThreePrices_whenZeroDeviate() public {
// 3 prices = input parameter, not pre-existing state
uint256[] memory prices = new uint256[](3);
prices[0] = 1000e18;
prices[1] = 1050e18;
prices[2] = 1200e18;
// test logic...
}
// when strict mode is enabled
// when only one price remains
// [X] it reverts
function test_whenStrictMode_whenOneRemains_reverts() public {
// Configuration parameter test
bytes memory params = encodeDeviationParams(1000, true); // strict mode
// test logic...
}
Note: Don't include the expected result in the function name. A single test often has multiple
assertions/checks. The _reverts suffix is the sole permitted outcome suffix.
Ordering: Write error/revert tests first, then success tests. This makes failures easier to spot.
Fuzz Test Naming
Foundry identifies fuzz tests from their parameters. Name the behavior being proven; do not add a
testFuzz_ prefix or _fuzz suffix.
Pattern: test_given<Condition>(...) or test_when<Parameter>(...)
// GOOD - the name describes the property; the parameter makes it a fuzz test
function test_whenInputArrayLengthIsLessThanThree(uint8 length) public {
uint8 boundedLength = uint8(bound(length, 0, 2));
// boundedLength is now in range [0, 2]
// test logic
}
function test_whenThreePrices_whenDeviationIsValid(
uint64 price1,
uint64 price2,
uint64 price3,
uint16 deviationBps
) public view {
uint64 boundedPrice1 = uint64(bound(price1, 1e9, 1e19));
// boundedPrice1 is now in range [1e9, 1e19]
uint64 boundedPrice2 = uint64(bound(price2, 1e9, 1e19));
// boundedPrice2 is now in range [1e9, 1e19]
uint64 boundedPrice3 = uint64(bound(price3, 1e9, 1e19));
// boundedPrice3 is now in range [1e9, 1e19]
uint16 boundedDeviationBps = uint16(bound(deviationBps, 0, 10_000));
// boundedDeviationBps is now in range [0, 10_000]
// test logic
}
// BAD - fuzzing mechanism encoded in the name
function testFuzz_whenInputArrayLengthIsLessThanThree(uint8 length) public {
// ...
}
function test_whenInputArrayLengthIsLessThanThree_fuzz(uint8 length) public {
// ...
}
Fuzz test naming guidelines:
- Use the standard branching-tree name without
testFuzz_or_fuzz - Follow the same branching tree pattern as unit tests
- Choose the numeric target range from the behavior the test proves
- Use
bound()to map numeric inputs into that exact valid or invalid interval - Use
vm.assume()for non-numeric or complex constraints - Document the target interval and why it matches the tested behavior
Use bound() to target the numeric interval under test:
Foundry has a limit on the number of discarded fuzz inputs. Using vm.assume() to constrain a
numeric input can exhaust this limit when most generated values fall outside the target interval.
Derive that interval from the behavior being proved, then use bound() to map inputs into it. The
target may be a valid interval for a success test or an invalid interval for a revert test.
// BAD - vm.assume() discards too many values
function test_whenAmountIsAtMostOneThousand(uint256 amount) public {
// This discards nearly all uint256 values except 0-1000
vm.assume(amount <= 1000);
// Test will fail with "Fuzz testing ran out of inputs"
}
// GOOD - the success behavior targets the exact valid interval [0, 1000]
function test_whenAmountIsAtMostOneThousand(uint256 amount) public {
uint256 boundedAmount = bound(amount, 0, 1000);
// boundedAmount is in the valid interval this test proves
}
// GOOD - the revert behavior targets the exact invalid interval [1001, type(uint256).max]
function test_whenAmountExceedsOneThousand_reverts(uint256 amount) public {
uint256 boundedAmount = bound(amount, 1001, type(uint256).max);
// boundedAmount remains invalid; it is never mapped into the valid interval
}
// GOOD - vm.assume() for non-numeric or complex constraints
function test_whenAddressIsNotZero(address addr) public {
vm.assume(addr != address(0));
// Only discards 1 out of 2^160 values - acceptable
}
// GOOD - use bound() even for a narrow numeric target interval
function test_whenArrayLengthIsBetweenOneAndTen(uint8 length) public {
uint8 boundedLength = uint8(bound(length, 1, 10));
// boundedLength is in the exact interval this behavior requires
}
When to use bound() vs vm.assume():
| Scenario | Use | Example |
|---|---|---|
| Exact valid numeric interval | bound() | uint8(bound(len, 1, 10)) when [1, 10] should succeed |
| Exact invalid numeric interval | bound() | bound(amount, 1001, type(uint256).max) for a revert test |
| Non-numeric constraints | vm.assume() | vm.assume(addr != address(0)) |
| Complex multi-variable | vm.assume() | vm.assume(x > y) |
| Address is not zero | vm.assume() | vm.assume(addr != address(0)) |
Why bound() is better for numeric ranges:
When a fuzzer generates a random uint256, nearly all values may be outside a small target range.
For example, targeting 0 <= x <= 1000 discards all but 1,001 of the 2^256 possible inputs. The
bound() function wraps the input using modulo arithmetic to keep it in the chosen valid or invalid
interval without discarding:
// bound() implementation (simplified)
function bound(uint256 x, uint256 min, uint256 max) pure returns (uint256) {
return min + (x % (max - min + 1));
}
// This never discards - the fuzzer's random value is transformed into range
Examples
// === ERROR CONDITIONS (write these first) ===
// when caller is not admin
// [X] it reverts
function test_whenCallerIsNotAdmin_reverts(address caller_) public {
vm.assume(caller_ != admin);
vm.assume(caller_ != address(0));
vm.expectRevert(
abi.encodeWithSelector(ROLESv1.ROLES_RequireRole.selector, bytes32("admin"))
);
vm.prank(caller_);
contract.restrictedFunction(); // Uses onlyAdminRole
}
// given position exists
// when amount is zero
// [X] it reverts
function test_givenPositionExists_whenAmountIsZero_reverts() public {
uint256 positionId = _createPosition(user, 100e18, 1e9);
vm.expectRevert(abi.encodeWithSelector(IDepositPositionManager.DEPOS_InvalidAmount.selector));
DEPOS.burn(positionId, 0);
}
// given position exists
// when amount exceeds remaining
// [X] it reverts with InsufficientRemaining error
function test_givenPositionExists_whenAmountExceedsRemaining_reverts() public {
uint256 positionId = _createPosition(user, 100e18, 1e9);
vm.expectRevert(abi.encodeWithSelector(IDepositPositionManager.DEPOS_InsufficientRemaining.selector));
DEPOS.burn(positionId, 200e18);
}
// === NESTED CONDITIONS ===
// given position exists
// given position is expired
// [X] it reverts
function test_givenPositionExists_givenPositionExpired_reverts() public {
uint256 positionId = _createPosition(user, 100e18, 1e9);
_warp(block.timestamp + 31 days);
vm.expectRevert(abi.encodeWithSelector(IDepositPositionManager.DEPOS_PositionExpired.selector));
DEPOS.burn(positionId, 50e18);
}
// === SUCCESS CONDITIONS WITH MULTIPLE ASSERTIONS ===
// given position exists
// when amount equals remaining
// [X] it closes the position
// [X] it emits PositionClosed event
// [X] it returns zero
function test_givenPositionExists_whenAmountEqualsRemaining() public {
uint256 positionId = _createPosition(user, 100e18, 1e9);
vm.expectEmit(true, true, true, true);
emit IDepositPositionManager.PositionClosed(positionId, user);
DEPOS.burn(positionId, 100e18);
// Verify position is closed
(, , uint256 remaining, , , ) = DEPOS.positions(positionId);
assertEq(remaining, 0, "position should be empty");
}
// given position exists
// given position is wrapped
// given transfer caller is approved
// when transfer caller transfers the position
// [X] approval preserves the owner
// [X] transfer updates the owner
// [X] transfer preserves the remaining deposit
function test_givenPositionExists_givenPositionIsWrapped_givenTransferCallerIsApproved_whenTransferCallerTransfersPosition()
public
{
address transferCaller = makeAddr("transferCaller");
address recipient = makeAddr("recipient");
uint256 positionId = _createPosition(user, 100e18, 1e9);
vm.prank(user);
DEPOS.wrap(positionId);
vm.prank(user);
DEPOS.approve(transferCaller, positionId);
IDepositPositionManager.Position memory positionBefore = DEPOS.getPosition(positionId);
assertEq(positionBefore.owner, user, "approval should not change the position owner");
vm.prank(transferCaller);
DEPOS.transferFrom(user, recipient, positionId);
IDepositPositionManager.Position memory positionAfter = DEPOS.getPosition(positionId);
assertEq(positionAfter.owner, recipient, "transfer should update the position owner");
assertEq(
positionAfter.remainingDeposit,
positionBefore.remainingDeposit,
"transfer should preserve the remaining deposit"
);
}
Error Handling in Tests
Match Specific Revert Data
Match the failure path being tested, not merely the fact that some call reverted. Use the custom error selector, and include the encoded arguments when their values are part of the behavior being proved.
Shortened here. Read the whole file on GitHub.
Signals
- GitHub stars
- 59
- Forks
- 56
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
test-write- Source
- github.com/olympusdao/olympus-v3