Lesson 1 — forge test — the Solidity equivalent of cargo test
Question
The forge init project layout is the Foundry equivalent of cargo new --lib. forge test sits in the same slot as cargo test — sub-second feedback after first compile, same --match-* filtering. Understand Solidity's test discovery + assertion primitives + verbosity ladder + negative-path tests (vm.expectRevert) side by side with the Rust analogues.
Direct answers (Foundry 1.7.1).
- Run a subset:
forge test --match-test test_Increment(--mt),forge test --match-contract CounterTest(--mc),forge test --match-path test/Counter.t.sol(--mp). Re-run only last run's failures:forge test --rerun. [FAIL: call didn't revert at a lower depth than cheatcode call depth]means the revert happened inside the test contract itself. Since Foundry 1.0,vm.expectRevertonly catches a revert raised by a call the test makes; move the failing operation into the called contract (or opt in withallow_internal_expect_revert = true).
Verbosity, as forge test --help defines it:
| Flag | What it adds |
|---|---|
| (none) | one [PASS] / [FAIL: reason] line per test |
-vv | logs (console.log output) for all tests |
-vvv | execution traces for failing tests |
-vvvv | execution traces for all tests, and setup traces for failing tests |
-vvvvv | execution and setup traces for all tests, including storage changes and backtraces with line numbers |
Stepping through opcodes is a separate mode: the interactive debugger, forge test --debug on a single matched test.
Principle (minimum model)
- Project-layout mapping.
Cargo.toml→foundry.toml/src/lib.rs→src/Counter.sol/tests/integration_test.rs→test/Counter.t.sol/target/→out/+cache/. The Foundry team is Rust-native; the mapping is intentional. - Test discovery is by name, not by attribute. Functions named
test*are tests; any test that takes parameters is a fuzz test (testFuzz_*is a naming convention, not the trigger). Solidity has no decorators, so a naming convention does the job a Rust attribute would. - Three assertion primitives.
assertEq(value equality, overloaded for every Solidity primitive) /assertTrue(boolean) /vm.expectRevert(negative-path — asserts the next external call reverts with a specific reason). - Verbosity ladder.
-vv(logs for all tests — thecargo test -- --nocaptureanalogue) /-vvv(execution traces for failing tests — daily default) /-vvvv(traces for all tests + setup traces for failing ones) /-vvvvv(adds setup traces for all tests, storage changes and backtraces with line numbers). Opcode stepping is--debug, not a verbosity level. vm.expectReverthas a one-call lifetime. It applies to the very next call the test makes. If an intervening call (even a view call) succeeds, the test fails right there withnext call did not revert as expected.- The revert must come from the callee (Foundry 1.0+).
allow_internal_expect_revertdefaults tofalse, so a revert raised in the test contract itself — e.g. computingzero - 1as an argument — fails withcall didn't revert at a lower depth than cheatcode call depth. Put the underflow inside the called contract (c.decrement()), andPanic(0x11)bubbles up to the cheatcode. - Literal arithmetic. Bare
0 - 1passed as auint256is a compile error (int_const -1can't convert touint256);uint256(0) - 1compiles and underflows at runtime. Neither helps withvm.expectRevertif it runs in the test contract.
Worked example + steps
Lesson 1 — forge test — the Solidity equivalent of cargo test
Goal
Concepts you'll grasp in this lesson:
- The
forge initproject shape is the Foundry equivalent ofcargo new --lib. Same disciplined directory layout (src/,test/,lib/,foundry.toml) the way Rust uses (src/,tests/,target/,Cargo.toml). If you've written a Rust crate, you already know how Foundry organizes contracts and tests; the names are slightly different but the roles map 1:1. forge testis the binary that lives at the same place in your daily loop ascargo test. Same sub-second feedback after the first compile, same--match-*filtering, same convention that test functions are picked up by name (Rust:#[test]attribute; Foundry: function name starts withtest). The two tools intentionally converge on the same workflow because Foundry's authors are Rust-native and built the muscle memory there first.assertEq/assertTrue/vm.expectRevertare the three primitives that 90% of unit tests use.assertEqfor value equality (with overloads for every Solidity primitive type),assertTruefor boolean conditions,vm.expectRevertfor negative-path tests (asserting that the next call MUST revert with a specific reason). Cross-references to Rust:assertEq↔assert_eq!,vm.expectRevert↔#[should_panic]orassert!(matches!(result, Err(_))).- Verbosity ramps from no flag (one line per test) to
-vvvvv(traces for everything, plus storage changes).-vvvis the daily default — it prints the execution trace of each failing test.-vvvvvis for debugging weird reverts and adds storage changes and backtraces with line numbers. Cross-reference to Rust:cargo test -- --nocapturegives youprintln!output; Foundry's equivalent is-vv(logs for all tests), and-vvvand up add the call traces the EVM executed.
Verification:
forge test
…runs the two tests from forge init's default Counter contract. After this lesson you'll have added one negative-path test (vm.expectRevert) for a total of three; the lesson ends with all three green.
Specific changes:
src/Counter.sol— unchanged fromforge initdefault (you'll read it, not edit it).test/Counter.t.sol— appends one new test function (test_RevertWhen_DecrementBelowZero) that exercises the underflow-revert path, plus a small test-onlyCounterWithDecrementcontract that givesCounterthedecrement()it lacks. Demonstratesvm.expectRevertagainst Solidity 0.8's built-in overflow check.
Total: ~15 lines of test code added. Lesson 1 is about ergonomics and the test-discovery loop, not about a clever assertion.
Recap
After Lesson 0:
- The course's positioning is clear: same theorem, two languages, port the openhl Rust discipline to Solidity.
- The roadmap is 7 lessons: orientation (Lesson 0) → test discipline (Lesson 1–Lesson 3) → CLI + state-aware testing (Lesson 4–Lesson 5) → capstone (Lesson 6).
- You've installed Foundry (
curl -L https://foundry.paradigm.xyz | bash && foundryup) and theforge,cast,anvil,chiselbinaries are on your$PATH.
Lesson 1 starts the test-discipline track. The first verb is forge test.
Plan
Three edits:
forge init my-project && cd my-project— creates the standard Foundry project layout. Read what's produced before touching anything.- Read
src/Counter.solandtest/Counter.t.solas-is — they ship withforge initand demonstrate the conventions. Understand each line. - Append one new test to
test/Counter.t.sol:test_RevertWhen_DecrementBelowZerothat callsdecrement()on a zero-valued counter (via a test-onlyCounterWithDecrement is Countersubclass), withvm.expectRevert(stdError.arithmeticError)armed beforehand. The underflow happens inside the called contract, which is whatvm.expectRevertrequires since Foundry 1.0. Run withforge test -vvv.
(Answer: Cargo.toml → foundry.toml, src/lib.rs → src/Counter.sol (or whatever you named your main contract), tests/integration_test.rs → test/Counter.t.sol, target/ → out/ + cache/, Cargo.lock → foundry.lock (written by forge init; pins each lib/ dependency to a git tag + commit rev — the dependencies themselves are git submodules in lib/, with lib/forge-std as the standard testing library always present). The mapping is intentional: the Foundry team built it to feel familiar to Rust developers because that's their own background.)
The forge init project shape — a one-page tour
my-project/
├── foundry.toml ← Like Cargo.toml: profile config, deps, compiler flags
├── foundry.lock ← Like Cargo.lock: pins lib/forge-std to a tag + commit
├── .github/workflows/test.yml ← CI workflow that runs forge build / test
├── src/ ← Like src/: production contracts live here
│ └── Counter.sol ← The default starter contract
├── test/ ← Like tests/: integration tests live here
│ └── Counter.t.sol ← The default starter tests (note .t.sol convention)
├── script/ ← Foundry-only: deployment scripts live here (covered Lesson 4)
│ └── Counter.s.sol ← Default deploy script
├── lib/ ← Like Cargo's deps cache, but git submodules
│ └── forge-std/ ← The standard test library — always present
├── README.md
└── .gitignore ← Pre-configured to ignore out/ and cache/
Four things to notice about the layout:
.t.soland.s.solare file-naming conventions, not enforced by the compiler. Foundry treats any contract intest/whose function names start withtestas a test. The.t.solsuffix is a human-readable convention so you can grep*.t.solto find all your test files. Same convention applies to.s.solfor scripts. Foundry uses naming conventions where Rust uses attributes; the discipline is the same.lib/forge-stdis a git submodule, not an npm/cargo dep.forge initrunsforge install foundry-rs/forge-std, which clones it intolib/. Versioning is by git tag or commit. This is genuinely simpler than the Cargo/npm dependency-resolver complexity, at the cost of one git submodule per dep. Foundry's dep model trades semver complexity for git transparency — you cancd lib/forge-std && git logto see exactly what code you're depending on.out/andcache/are gitignored by default.out/holds compiled bytecode + ABI JSONs (the equivalent of Rust'starget/debug/).cache/holds incremental compilation state. Both are safe to delete and re-generate; both should never be committed.script/is for deployment scripts (covered briefly in Lesson 4). Foundry combines testing and scripting under the sameforgebinary; Hardhat splits them into separate tools (hardhat testvshardhat run). The unification is small but reduces context-switching cost over a day. One binary, one config, one mental model.
Walk-through
Step 1: forge init and look around
forge init my-foundry-lab
cd my-foundry-lab
ls -la
You should see the layout from the previous section. If lib/forge-std/ is empty (network issue during init), run forge install foundry-rs/forge-std to fix it.
forge test
Expected output (abbreviated):
Compiling 21 files with Solc 0.8.35
Solc 0.8.35 finished in 471.39ms
Compiler run successful!
Ran 2 tests for test/Counter.t.sol:CounterTest
[PASS] testFuzz_SetNumber(uint256) (runs: 256, μ: 26956, ~: 29289)
[PASS] test_Increment() (gas: 28783)
Suite result: ok. 2 passed; 0 failed; 0 skipped; finished in 10.39ms (4.76ms CPU time)
Ran 1 test suite in 16.92ms (10.39ms CPU time): 2 tests passed, 0 failed, 0 skipped (2 total tests)
(Foundry 1.7.1; the fuzz μ / ~ numbers vary run to run because inputs are random.)
Two tests, both green. The first run compiles forge-std too; later runs print No files changed, compilation skipped and go straight to the tests.
Three things to notice about the output format:
(gas: 28783)— every test reports gas used. Hardhat doesn't show this by default; Foundry treats gas as a first-class metric. (For consensus-determinism-trained engineers: gas is the EVM's analogue of consensus cost — every validator computes the same gas for the same transaction. Tracking it is part of the discipline.)(runs: 256, μ: 26956, ~: 29289)— that test is a fuzz test (we'll see why in Lesson 2).runs: 256means it ran with 256 random inputs;μis mean gas,~is median. Foundry shows fuzz statistics inline.finished in 10.39ms (4.76ms CPU time)— Foundry shows wall-clock and CPU time separately. Tests run in parallel, so the two numbers differ and CPU time can exceed wall-clock on larger suites.
Step 2: Read src/Counter.sol
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
contract Counter {
uint256 public number;
function setNumber(uint256 newNumber) public {
number = newNumber;
}
function increment() public {
number++;
}
}
Five things to notice:
pragma solidity ^0.8.13— the^is a caret-style version constraint (same syntax Cargo uses). Means "any 0.8.13+ version, but not 0.9" (Foundry picked Solc 0.8.35 in the run above). Solidity 0.8 is the discipline-line: it introduced built-in overflow checks (no moreSafeMath), which is what makes ourtest_RevertWhen_DecrementBelowZerotest possible later.uint256 public number—publicauto-generates a getter function (number()) returning the value. The state variable itself can also be written directly from inside the contract; from outside, only the auto-generated getter is callable. Solidity collapseslet pubandlet pub fn ...()into one declaration.- No constructor. Default initialization:
number = 0. Same default-zero semantics as Rust'si64::default(). setNumberandincrementarepublic— anyone can call them. (Restriction modifiers likeonlyOwnerwould go here in production; the example is intentionally permissionless.)- No
decrementfunction exists. That's a hint. Our new test will add a decrement function in the test file (via a small subclass ofCounter), then test that it reverts on underflow.
Step 3: Read test/Counter.t.sol
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import {Test} from "forge-std/Test.sol";
import {Counter} from "../src/Counter.sol";
contract CounterTest is Test {
Counter public counter;
function setUp() public {
counter = new Counter();
counter.setNumber(0);
}
function test_Increment() public {
counter.increment();
assertEq(counter.number(), 1);
}
function testFuzz_SetNumber(uint256 x) public {
counter.setNumber(x);
assertEq(counter.number(), x);
}
}
Six things to notice:
import {Test} from "forge-std/Test.sol"—Testis the base contract every test inherits from. It bundlesassertEq/assertTrue/vm.*cheatcodes etc. The same file also exportsconsole(import {Test, console} ...) —console.logis Foundry'sdbg!macro equivalent; its output shows up at-vvand above.contract CounterTest is Test— your test file is itself a contract that inherits fromforge-std'sTest. Function inheritance is how you get access toassertEqandvm.*. Solidity's inheritance is the API surface for tooling, where Rust uses traits +use.function setUp() public— runs before every test function. Same role as Rust's#[test]per-function init, just centralized in one function. OnesetUpper test contract; if you want per-test setup, you wrap it inside individual test functions.function test_Increment() public— name starts withtest, markedpublic. That's it — no annotations. Foundry's test discovery is name-based. The "underscore-suffix-or-prefix names a kind" convention is Solidity's analogue of Rust's attribute system.testFuzz_SetNumber(uint256 x)— takes a parameter, so Foundry runs it as a fuzz test (covered in Lesson 2). The parameter is what matters: atest_Foo(uint256)is fuzzed the same way; thetestFuzz_prefix is only a naming convention.setNumber(x)is called with 256 randomuint256values; the assertion must hold for all of them.assertEq(counter.number(), 1)— equality assertion.forge-std'sTestoverloadsassertEqfor every Solidity primitive type (uint,int,bool,address,bytes,string,bytes32, ...). You don't pick a typed variant; the right overload is selected by the type of your arguments. One-line assertions, nolet x = ...; let y = ...; assert_eq!(x, y);cascade like in Rust.
Step 4: Add a negative-path test with vm.expectRevert
The Counter contract has increment but no decrement. Solidity 0.8 has built-in overflow checks, so decrementing a uint256 that holds 0 reverts with Panic(uint256) (the underflow panic code, 0x11). We'll give Counter a decrement() in a test-only subclass, so the underflow happens inside the contract the test calls — which, since Foundry 1.0, is where vm.expectRevert needs the revert to come from (note 4 below).
Three edits to test/Counter.t.sol:
// (a) Import stdError alongside Test:
import {Test, stdError} from "forge-std/Test.sol";
// (b) Above `contract CounterTest`, add:
/// Test-only subclass: adds the decrement() that Counter lacks,
/// so the underflow happens inside the callee.
contract CounterWithDecrement is Counter {
function decrement() public {
number--;
}
}
// (c) Inside CounterTest, append:
function test_RevertWhen_DecrementBelowZero() public {
CounterWithDecrement c = new CounterWithDecrement(); // number == 0
// Arm the cheatcode for the NEXT call this test makes.
// stdError.arithmeticError is the encoded Panic(0x11).
vm.expectRevert(stdError.arithmeticError);
// `number--` underflows inside CounterWithDecrement — one call
// deeper than the test — and Panic(0x11) bubbles up to the
// cheatcode, which matches it.
c.decrement();
}
Six things to notice:
-
test_RevertWhen_<condition>is the naming convention for negative-path tests in Foundry's docs. Not enforced by the test runner —forge testdoesn't care about the suffix — but the convention makes your test list self-documenting. Naming conventions are documentation when the tooling doesn't enforce structure. -
vm.expectRevert(stdError.arithmeticError)— matches the exact revert data, herePanic(0x11).forge-std'sstdErrorhas the other panic codes too. The no-argumentvm.expectRevert()matches any revert; use it only when you don't care which reason (we'll match custom errors in Lesson 3). -
vm.expectRevertmust be called immediately before the call you expect to revert. It's not a wrapper; it's a one-shot cheatcode that applies to the next call the test makes. If you call something else in between — even a view call — that call is the one checked: it doesn't revert, so the test fails on the spot withnext call did not revert as expected, and the call you meant never runs. The lifetime is exactly one call:Time ──► ┌──────────────────────────────────────────────────────────┐ │ CORRECT — vm.expectRevert arms the very next ext call │ ├──────────────────────────────────────────────────────────┤ │ vm.expectRevert(); ←─── arms the trap │ │ c.decrement(); ←─── trap fires, expects revert │ │ ✓ test passes if call reverts │ └──────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────┐ │ WRONG — the intervening call is the one checked │ ├──────────────────────────────────────────────────────────┤ │ vm.expectRevert(); ←─── arms the trap │ │ c.number(); ←─── trap fires HERE, doesn't │ │ ✗ revert → test FAILS: │ │ "next call did not revert as expected" │ │ c.decrement(); ←─── never runs │ └──────────────────────────────────────────────────────────┘vm.expectReverthas a 1-call lifetime; respect the ordering. -
Why a subclass instead of underflowing inline? The Foundry 1.0+ depth rule. A tempting shortcut is to compute the underflow in the test itself:
vm.expectRevert(); uint256 zero = 0; counter.setNumber(zero - 1); // zero - 1 panics HERE, in the test contractOn Foundry 1.0 and later this fails:
[FAIL: call didn't revert at a lower depth than cheatcode call depth] test_RevertWhen_DecrementBelowZero() (gas: 5406)zero - 1is evaluated while building the argument, in the test contract's own frame, socounter.setNumberis never dispatched and the revert happens at the same depth as the cheatcode call.vm.expectRevertonly accepts a revert that comes out of a call the test makes (a lower depth). This is controlled byallow_internal_expect_revert, which defaults tofalse(forge configprintsallow_internal_expect_revert = false). Setting it totrueinfoundry.tomlmakes the inline version pass, but putting the failing operation in a callee keeps the test honest about which contract reverts. WithCounterWithDecrement,forge test -vvvvshows exactly that:[151912] CounterTest::test_RevertWhen_DecrementBelowZero() ├─ [113157] → new CounterWithDecrement@0x2e234DAe75C793f67A35089C9d99245E1C58470b │ └─ ← [Return] 565 bytes of code ├─ [0] VM::expectRevert(panic: arithmetic underflow or overflow (0x11)) │ └─ ← [Return] ├─ [2359] CounterWithDecrement::decrement() │ └─ ← [Revert] panic: arithmetic underflow or overflow (0x11) └─ ← [Stop]A side note on literals: bare
0 - 1passed as auint256doesn't compile (Error (9553): ... Invalid implicit conversion from int_const -1 to uint256 requested), butuint256(0) - 1does compile and underflows at runtime — no local-variable trick needed. Either way, if it runs in the test contract, the depth rule applies.Mapping this to a Rust mental model: the inline version is a panic while evaluating arguments on the caller's side (before the call is dispatched); the subclass version is a panic inside the callee that propagates across the call boundary.
vm.expectRevert(by default) only accepts the second kind. Reading the trace to see where a revert happened is the core debugging skill here. -
The comment block walks the test's intent step-by-step. Same
math-walk in commentsdiscipline from the liquidation course's scanner-capstone tests. A future reader debugging a failure reads the comment and re-derives the expected behavior. Math-walk comments turn one test into a worked example of the EVM behavior under test. -
No
decrement()was added toCounter.sol— the test-onlyCounterWithDecrementsubclass lives in the test file, inheritsCounter's storage and functions, and adds just the method the test needs. The production contract stays unchanged. For production contracts with realdecrementmethods, the test would callcounter.decrement()directly. Tests can construct minimal scenarios without modifying the contract under test.
Step 5: Run with forge test -vvv
forge test -vvv
Expected output (abbreviated):
Ran 3 tests for test/Counter.t.sol:CounterTest
[PASS] testFuzz_SetNumber(uint256) (runs: 256, μ: 27112, ~: 29289)
[PASS] test_Increment() (gas: 28783)
[PASS] test_RevertWhen_DecrementBelowZero() (gas: 151912)
Suite result: ok. 3 passed; 0 failed; 0 skipped; finished in 4.49ms (5.38ms CPU time)
Three tests passing. Notice test_RevertWhen_DecrementBelowZero reports far more gas (~152k) than the others (~29k). That is not the revert: the number covers the whole test function, including deploying CounterWithDecrement with new. The -vvvv trace in note 4 splits it: [113157] for the deployment, [2359] for the reverted decrement() call. A test's gas number is the whole test function; read the trace to attribute it to individual calls.
To intentionally break the test and see what -vvv shows on failure, change the test temporarily:
vm.expectRevert();
counter.setNumber(42); // This will NOT revert; the test should fail.
Re-run forge test -vvv. You'll see:
[FAIL: next call did not revert as expected] test_RevertWhen_DecrementBelowZero() (gas: 176235)
Traces:
[176235] CounterTest::test_RevertWhen_DecrementBelowZero()
├─ [113157] → new CounterWithDecrement@0x2e234DAe75C793f67A35089C9d99245E1C58470b
│ └─ ← [Return] 565 bytes of code
├─ [0] VM::expectRevert(custom error 0xf4844814)
│ └─ ← [Return]
├─ [22492] Counter::setNumber(42)
│ └─ ← [Stop]
└─ ← [Revert] next call did not revert as expected
At -vvv a failing test gets its execution trace, so you can see which call the cheatcode checked. -vvvvv adds storage changes and backtraces with line numbers. The verbosity is your debug tool.
Revert the test back to the original passing version before continuing.
Common errors:
Source "forge-std/Test.sol" not found— you didn't runforge install foundry-rs/forge-stdandlib/forge-std/is empty. Run it now. (forge initusually does this for you, but a network hiccup can skip it.)[FAIL: next call did not revert as expected]— the call right aftervm.expectRevertsucceeded. Either it really doesn't revert, or another call (even a view call such asc.number()) sits between the cheatcode and the call you meant.[FAIL: call didn't revert at a lower depth than cheatcode call depth]— the revert happened in the test contract itself, not in a called contract (note 4 in Step 4).compile error: not found: Counter— your import path is wrong. The test file saysimport {Counter} from "../src/Counter.sol"; double-check the relative path.
Step 6: Filter tests with --match-test
forge test --match-test test_Increment -vvv
Runs only the test_Increment test, ignoring the fuzz test and the revert test. Useful when iterating on one test at a time — Foundry's compile cache means subsequent runs of a single test take ~50ms.
Other useful filters:
--match-contract CounterTest— run all tests in a specific contract--match-path 'test/Counter.t.sol'— run all tests in a specific file--no-match-test testFuzz_SetNumber— skip a specific test (!-style negation)--rerun— re-run only the tests that failed on the last run
Cross-reference to Rust: forge test --match-test foo is exactly cargo test foo — partial-name match. The --match-* family makes it more explicit which axis you're filtering on. When tooling converges on the same workflow, the syntax converges too.
Design reflection
Three load-bearing decisions that shaped Foundry's forge test:
-
Tests live in Solidity, not in JavaScript. Hardhat tests are JS files calling into a contract via ethers.js. Foundry tests are contracts — same language as production code, same compiler, same bytecode. This collapses one entire layer of context-switching. When test code and production code share a compiler, you can
import {Counter} from "../src/Counter.sol"and statically type-check the entire test surface. -
Test discovery is by name, not by attribute. Foundry doesn't need
@Testannotations because Solidity doesn't have decorators. Functions namedtest*are tests. The convention is enforced byforge's grep through the test contract's function list. Conventions documented in tooling output are equivalent to attributes for the human reader; both produce the "this is a test" signal. -
vm.*cheatcodes are in-EVM calls, not JS-side wrappers. Hardhat'sevm_snapshotis an RPC method; Foundry'svm.expectRevertis an ordinary-looking call from the test contract to address0x7109709ECfa91a80626fF3989D68f67F5b1DD12D(VM_ADDRESSinforge-std'sBase.sol). Foundry's test EVM (built on revm, not a fork of it) intercepts calls to that address itself — the trace shows them asVM::expectRevert(...)with[0]gas. It is not a chain precompile, but the idea is close to openhl Stage 9's precompile-as-EVM-superpower pattern: an address whose calls the execution environment handles specially. Lesson 1 only usedvm.expectRevert; Lesson 2 and Lesson 3 will introduce more cheatcodes. Each one is a call to that address.
Answer key
This lesson's "answer key" is what forge init produces, plus your one new test function. The directory structure should look like:
my-foundry-lab/
├── foundry.toml (unchanged from init)
├── foundry.lock (unchanged from init)
├── src/Counter.sol (unchanged from init)
├── script/Counter.s.sol (unchanged from init)
├── test/Counter.t.sol (+~15 lines: stdError import, CounterWithDecrement, new test)
└── lib/forge-std/ (git submodule, unchanged)
After Lesson 1:
forge testpasses 3 tests cleanly- You've read every line of
Counter.solandCounter.t.soland understand the conventions - You've added a
vm.expectReverttest and seen what-vvvshows
The course doesn't have an in-repo answer-key for Lesson 1–Lesson 5 because forge init's output is the answer key — same output for every reader on the same Foundry version. Lesson 6's capstone changes this: at Lesson 6 you'll work against a specific InsuranceFund.sol + tests in the course's companion repository (psyto/rethlab, examples/foundry-capstone/).
Common questions
Q1: Why does forge init create script/ if this course doesn't really cover deployments?
Because forge is one binary that handles testing AND deployment scripting; the project layout has slots for both even if you only use one. We touch script/ briefly in Lesson 4 (cast send + simple deploy via forge script); a full deployment workflow is its own course (out of scope, per Lesson 0's "what's NOT in this course" list).
Q2: Why does setUp() run before every test and not once for the whole contract?
Because Foundry's test isolation runs each test against a fresh EVM state — no test can leak state to another. setUp() is the per-test initializer. If you want a one-time global init (e.g., a heavy fixture), you set it up in the constructor of the test contract; that runs once when the test contract is deployed. Per-test isolation is the default because cross-test state leaks are the #1 source of flaky tests in any test runner.
Q3: Why is assertEq(counter.number(), 1) calling number() as a function, not reading number as a field?
Because uint256 public number auto-generates a getter function with that name. Inside the same contract you'd write number; from outside (which is where the test sits — CounterTest is a different contract from Counter), you call counter.number(). Public state variables are syntactic sugar for get* functions in Solidity; the call-site syntax reflects the underlying generated function.
Q4: What does -vvv actually add over the default output?
From forge test --help (Foundry 1.7.1):
- no flag: one
[PASS]/[FAIL: reason]line per test -vv: logs for all tests (console.logoutput)-vvv: execution traces for failing tests (which contract called which, with gas and return/revert data)-vvvv: execution traces for all tests, and setup traces for failing tests-vvvvv: execution and setup traces for all tests, including storage changes and backtraces with line numbers
In practice: use -vvv for daily development (only failing tests get traces), -vvvvv when you're stuck on a weird revert. Opcode-by-opcode stepping is the interactive debugger, forge test --debug, on a single matched test.
Q5: Can I write tests in a separate file outside test/?
You can configure foundry.toml to add other test paths, but the default test/ directory is conventional and tooling integrations (IDE plugins, CI matrices) assume it. Stay with the default unless you have a real reason (e.g., a giant monorepo where contract teams want their own test/ subdirs). Convention beats configuration when the default is sane.
Q6: Why does Solidity have pragma solidity ^0.8.13 instead of [package] edition = "2024" like Rust?
Different language-evolution model. Rust's editions are epochs that change defaults (e.g., 2024 enables new keyword reservations) without breaking old syntax. Solidity's pragma constrains which compiler version can build the file, which matters more in Solidity because compiler bugs are common and consensus determinism makes mid-deploy version mismatches catastrophic. Solidity's pragma is closer to rust-version = "1.85" in Cargo.toml than to edition = "2024".
Next lesson (Lesson 2) — Fuzz testing — Solidity's proptest!
Lesson 2 turns the testFuzz_SetNumber test into a real working example of property-based testing — what the liquidation course's withdraw_shortfall lesson calls "proptest". You'll learn:
- The default 256-iteration fuzz cycle and how to bump it via
foundry.toml vm.assume(condition)— the Solidity equivalent ofprop_assume!for filtering inputs that violate preconditions- Shrinking — how Foundry reduces a 32-byte counterexample down to the minimal
uint256that triggers a failure - Corpus persistence —
cache/fuzz/saves failing inputs so re-runs immediately replay the same counterexamples
After Lesson 2 you'll have written your first conservation-law fuzz test in Solidity, the same shape as the withdraw_amount_plus_unfilled_equals_shortfall proptest from the liquidation course's withdraw_shortfall lesson.
Summary (3 lines)
forge test= the Solidity-sidecargo test. In-process REVM gives sub-second feedback;--match-test/--match-contract/--match-pathfilter;-vvvis the daily default.- Test discovery is name + parameter signature:
test*= test, any test with parameters = fuzz test (testFuzz_*is the convention),test_RevertWhen_*= negative-path convention. vm.expectReverthas a one-call lifetime — arm it immediately before the call you expect to revert. Since Foundry 1.0 the revert must come from the called contract, not the test contract itself; the-vvvvtrace shows which one reverted.